{"slug": "buildcraft-is-a-compiler-problem", "title": "Buildcraft Is a Compiler Problem", "summary": "The article argues that buildcraft in action role-playing games (ARPGs) is best understood as a compiler problem rather than a content problem, as the combinatorial complexity of skills, supports, and items quickly overwhelms simple special-case logic. The proposed solution treats authored content as source input that emits facts (stat modifiers and behavior changes), which are then compiled into derived caches for combat to consume, ensuring skill resolution relies on low-level runtime data rather than checking specific combinations. This approach maintains provenance for each modifier, enabling clean updates when equipment changes and allowing the system to explain where stats come from.", "body_md": "Buildcraft Is a Compiler Problem\nARPG buildcraft looks like a content problem until the combinations start piling up.\nThe examples here are excerpts from a Zig ARPG game engine where skills, supports, items, and runtime rules all need to compose.\nAt first each rule seems harmless:\n- this support adds damage\n- this support makes projectiles pierce\n- this item makes spell damage apply to melee\n- this status adds a temporary stat\n- this affix gives the pack more speed\n- this unique changes a rule\nThen the combinations show up.\nCleave with a bigger radius. Cleave with a smaller radius but more damage. Cleave with a bleed payload. Cleave with a twin flank. A projectile skill with pierce, chain, and fork. An item that says a spell stat now applies to a melee attack. A status that temporarily changes the same stat that an item and support already touched.\nThe tempting path is a growing pile of special cases:\nif skill == cleave and support == wide_sweep:\nmake cleave radius bigger\nif skill == cleave and support == focused_edge:\nmake cleave smaller but stronger\nif skill == cleave and support == twin_cleave and rule == guarded_arc:\nquietly move to the woods\nThat can work for a demo. It gets rough once the game has lots of skills, supports, items, statuses, and encounter rules.\nThe framing that has worked better here is:\nBuildcraft can be treated as a small compiler pipeline.\nAuthored content is the source input. Supports, items, statuses, affixes, and class rules emit facts. Those facts get folded into derived caches. Combat consumes the caches.\nIn this design, skill resolution should not have to ask, \"is this Cleave with Wide Sweep in support slot 3?\" By the time a skill resolves, that question has become lower-level runtime data:\nincreased_damage_bp = 500\narea_radius_bonus_subunits = 1000\narea_sweep_profile = default\nstatus_payload_count = 0\nmore_multipliers = [...]\nAuthoring data should stay boring\nA support definition is not executable gameplay code. In this design it is data with a narrow vocabulary.\nA support can emit stat modifiers and behavior changes. That's the box. It doesn't directly reach into projectile storage, call combat, or patch a random field in the world.\nHere is one support definition:\nRead it as content, not behavior code:\n- skill-scoped increased physical damage\n- one behavior emission\n- the behavior is an area-radius delta\n- it only applies to skills tagged melee and area\nThe support may be socketed next to Cleave, but the emitted behavior still speaks in internal applicability tags. It says \"melee + area,\" not \"call the Cleave implementation and change its radius.\"\nFuture skills can work with existing support rules without wiring every skill to every support by hand. It also keeps an important distinction visible: player-facing labels and runtime applicability tags don't have to be the same thing.\nSupports compile into rows\nWhen a skill slot changes, the old compiled output has to go away before new output is emitted.\nThis pass turns an equipped skill and its active supports into rows:\nactive skill gem + active support mask\n-> stat modifier rows\n-> behavior emission rows\nThe active_support_mask\nmatters because a socketed support is not always active. In this codebase,\ngem level controls which support slots are unlocked. I want that detail in one compiler pass, not\nscattered through combat code.\nThe deletion step is just as important as the generation step. If a support is removed and its old rows survive, the build keeps power it no longer earned.\nRows need provenance\nThe pipeline keeps source identity.\nSmall thing, lots of weight.\nA modifier row is more useful if it says not only \"+500 damage increased,\" but also where that number came from: skill slot, support slot, and scope. Later systems can use that to remove the right rows, rebuild the right cache, and eventually explain the result to a test or inspector.\nWithout provenance, the math can still add up, but the system cannot answer the more useful question:\nWhy is this number here?\nEntity scope and skill scope are different channels\nSome supports change the whole entity. Some only change the supported skill.\nThe distinction gets compiled into the modifier row:\nBoring until it is wrong.\nIf a Cleave support says \"more Cleave damage,\" Dash should not inherit that just because both skills are equipped. If an item says \"spell damage applies to melee,\" that is a broader rule and flows through a different channel.\nSo the pipeline keeps scope explicit:\nentity scope -> affects the actor\nskill-specific -> affects a skill identity\nrule emission -> changes how applicability is interpreted\nThis is where the compiler analogy keeps paying rent: scopes, namespaces, and rewrites in a very small form.\nBehavior emissions carry shape, not behavior code\nStat rows are simple enough: add damage, increase health, apply a multiplier.\nBehavior changes are messier. Pierce, chain, fork, extra projectiles, area radius, conversion, status payloads, and gem-level deltas are different shapes of change.\nSo they go through a separate emission type and fold into a skill cache:\nThe cache entry is the runtime summary for a skill slot.\nIt doesn't answer \"which supports are socketed?\" or \"which item caused this?\" That information exists upstream in the rows. The cache answers the question resolution cares about:\ndamage modifiers\nprojectile behavior counts\nconversion override\narea radius delta\nstatus payloads\neffective gem level\nThat split matters. Combat consumes the summary. Inspection and cleanup can still use the source rows.\nDirty domains keep rebuilds bounded\nThe sim should not rebuild every derived fact every tick just because it can. Mutation marks dirty domains, then the rebuild pass handles the affected entities.\nThis is incremental compilation in miniature.\nA support changed? Mark the skill cache dirty. A defensive stat changed? Rebuild defense. A runtime rule changed? Rebuild rules. Nothing changed? Clear stale flags and move on.\nPartly performance, partly ownership. Mutation says what kind of derived state it invalidated. The rebuild pass does the derived work in a known phase before intent and combat read it.\nThe systems that use stats should not need to ask, \"has anyone updated this yet?\" The rebuild phase is what makes that true.\nTags are an applicability filter, not a skill matrix\nOnce rows exist, the behavior rebuild pass folds them into each skill cache.\nThe cache is rebuilt from rows. Instead of patching one cached field because an old support used to touch it, the rebuild pass clears the summary and folds the current facts back in.\nApplicability is tag-based:\npub fn tags_match(skill_tags: TagMask, require: TagMask, exclude: TagMask) bool {\nconst skill_bits = skill_tags.bits();\nconst require_bits = require.bits();\nconst exclude_bits = exclude.bits();\nassert((require_bits & exclude_bits) == 0);\nconst has_required = (skill_bits & require_bits) == require_bits;\nconst has_excluded = (skill_bits & exclude_bits) != 0;\nreturn has_required and !has_excluded;\n}\nA behavior emission applies if the runtime thing being modified has the required tags and none of the excluded tags. In the snippet above that is the skill's catalog tags. For more complicated skills, the same idea can move down a level to delivery or payload tags.\nThat lets content say:\nrequires melee + area\nrequires attack + melee + physical\nexcludes cold\nThis avoids a giant skill/support identity matrix. It also gives me one obvious place to look when a support applies to the wrong thing: the emitted requirement is wrong, the target tags are wrong, or the applicability rule is wrong.\nThe tricky part is tag granularity. A gem can grant a skill with multiple parts: a melee hit, a projectile, an explosion, an ailment payload. A single display tag on the gem may not be precise enough to decide every modifier interaction. The tag that matters is the one attached to the thing being modified.\nStill plenty of room for bugs, but at least the bug has a small vocabulary.\nRule rewrites are separate from stat math\nSome build effects are not stats. They change how other facts should be interpreted.\nExample shape: \"spell damage applies to melee.\" Not a flat damage number. It changes whether a spell-only modifier can apply to a melee skill.\nThe behavior rebuild has a small place for that:\nconst direct_match = stat_applicability_matches(skill_tags, m.applicability);\nconst rewire_match = entity_rules.spell_damage_applies_to_melee and\nskill_tags.melee and m.applicability == .spell_only;\nif (!direct_match and !rewire_match) continue;\nThe source fact doesn't mutate every melee skill. It doesn't clone spell modifiers into attack modifiers. It emits a rule. The cache rebuild interprets that rule while folding applicable damage modifiers.\nIn the current codebase, this kind of rule can come from authored item effects, class-tree effects, or imprint effects. The weirdness stays in one layer instead of spreading through every skill implementation.\nResolution consumes compiled facts\nWhen a skill resolves, the projectile path starts from the cache.\nProjectile delivery then consumes the fields it cares about:\nAnd when it allocates the projectile:\nNotice what is mostly missing from this layer: support IDs.\nProjectile delivery doesn't need to care whether pierce came from a support gem, an item, a status,\nor a future shrine. It gets pierce_remaining\n.\nThe source attribution still exists upstream for cleanup, inspection, debugging, and tests. The hot path gets the compiled facts.\nBounded weirdness\nThere are caps in the content contract:\nThe behavior cache also has fixed payload slots, fixed more-multiplier storage, and fixed projectile behavior counts.\nThe exact caps are game-specific. The useful part is that the limit is visible. A support can be interesting, but in this version it cannot emit an unbounded pile of behavior. A skill can carry status payloads, but only inside storage the engine knows how to resolve.\nIf content needs more shape than the current caps allow, the content model and engine contract have to change together. I would rather make that visible than quietly grow a hidden pile of effects.\nWhere this leaves the design\nThe pipeline currently looks like this:\nauthored support data\n-> active slot mask\n-> stat modifier rows\n-> behavior emission rows\n-> dirty domains\n-> cached stats and skill cache\n-> delivery resolution\n-> combat/projectile/status queues\nEach stage has a job.\nAuthoring data stays declarative. Stores preserve source identity. Dirty bits say what needs rebuilding. Tags decide applicability. Rules handle weird rewrites. Skill caches become compact runtime summaries. Combat reads the summaries.\nThis feels promising because adding a support becomes adding a source fact, not negotiating with every combat system individually.\nThe open questions are concrete: tag granularity, the behavior-emission vocabulary, the fixed caps, and how explicit rule rewrites need to become as more of them appear. I like those problems more than a pile of one-off combat branches.\nThe current shape gives me seams to test and places to put the weirdness:\nexpress the build effect\n-> emit rows\n-> rebuild caches\n-> consume facts\nThe goal is no giant skill/support matrix, no stale support rows, and no combat archaeology unless I am debugging the compiler passes themselves.\nJust a small compiler with a sword.", "url": "https://wpnews.pro/news/buildcraft-is-a-compiler-problem", "canonical_source": "https://mitander.xyz/posts/buildcraft-is-a-compiler-problem/", "published_at": "2026-05-22 09:50:48+00:00", "updated_at": "2026-05-24 05:04:20.981674+00:00", "lang": "en", "topics": ["developer-tools", "research"], "entities": ["Zig"], "alternates": {"html": "https://wpnews.pro/news/buildcraft-is-a-compiler-problem", "markdown": "https://wpnews.pro/news/buildcraft-is-a-compiler-problem.md", "text": "https://wpnews.pro/news/buildcraft-is-a-compiler-problem.txt", "jsonld": "https://wpnews.pro/news/buildcraft-is-a-compiler-problem.jsonld"}}