Buildcraft Is a Compiler Problem — mitander@xyz
ARPG buildcraft looks like a content problem until the combinations start piling up.
The examples here are excerpts from a Zig ARPG game engine where skills, supports, items, and<br>runtime rules all need to compose.
At first each rule seems harmless:
this support adds damage
this support makes projectiles pierce
this item makes spell damage apply to melee
this status adds a temporary stat
this affix gives the pack more speed
this unique changes a rule
Then the combinations show up.
Cleave with a bigger radius. Cleave with a smaller radius but more damage. Cleave with a bleed<br>payload. Cleave with a twin flank. A projectile skill with pierce, chain, and fork. An item that<br>says a spell stat now applies to a melee attack. A status that temporarily changes the same stat<br>that an item and support already touched.
The tempting path is a growing pile of special cases:
if skill == cleave and support == wide_sweep:<br>make cleave radius bigger
if skill == cleave and support == focused_edge:<br>make cleave smaller but stronger
if skill == cleave and support == twin_cleave and rule == guarded_arc:<br>quietly move to the woods
That can work for a demo. It gets rough once the game has lots of skills, supports, items,<br>statuses, and encounter rules.
The framing that has worked better here is:
Buildcraft can be treated as a small compiler pipeline.
Authored content is the source input. Supports, items, statuses, affixes, and class rules emit<br>facts. Those facts get folded into derived caches. Combat consumes the caches.
In this design, skill resolution should not have to ask, "is this Cleave with Wide Sweep in support<br>slot 3?" By the time a skill resolves, that question has become lower-level runtime data:
increased_damage_bp = 500<br>area_radius_bonus_subunits = 1000<br>area_sweep_profile = default<br>status_payload_count = 0<br>more_multipliers = [...]
"Compiler" here doesn't mean a grand language system. It means source facts become runtime facts<br>before the hot path has to care where they came from.
Authoring data should stay boring
A support definition is not executable gameplay code. In this design it is data with a narrow<br>vocabulary.
support.zigconst support_modifier_max = constants.support_modifiers_max;<br>const support_behavior_max = constants.support_behaviors_max;
const ModifierSlots = [support_modifier_max]SupportModifier;<br>const BehaviorSlots = [support_behavior_max]SupportBehavior;
pub const SupportDef = struct {<br>scope: SupportScope,<br>modifiers: ModifierSlots = undefined,<br>modifier_count: u8 = 0,<br>behaviors: BehaviorSlots = undefined,<br>behavior_count: u8 = 0,<br>};
A support can emit stat modifiers and behavior changes. That's the box. It doesn't directly reach<br>into projectile storage, call combat, or patch a random field in the world.
Here is one support definition:
support.zigconst wide_sweep_index = @intFromEnum(SupportId.cleave_wide_sweep);
table[wide_sweep_index] = add_behaviors(<br>make_def(.skill, &.{<br>.{<br>.stat = .damage_increased_bp,<br>.op = .increased_bp,<br>.damage_type = .physical,<br>.value = 500,<br>},<br>}),<br>&.{<br>.{<br>.kind = .area_radius_subunits,<br>.value = constants.subunits_per_unit,<br>.tag_require = TagMask.init(&.{ .melee, .area }),<br>},<br>},<br>);
Read it as content, not behavior code:
skill-scoped increased physical damage
one behavior emission
the behavior is an area-radius delta
it only applies to skills tagged melee and area
The support may be socketed next to Cleave, but the emitted behavior still speaks in internal<br>applicability tags. It says "melee + area," not "call the Cleave implementation and change its<br>radius."
Future skills can work with existing support rules without wiring every skill to every support by<br>hand. It also keeps an important distinction visible: player-facing labels and runtime applicability<br>tags don't have to be the same thing.
Supports compile into rows
When a skill slot changes, the old compiled output has to go away before new output is emitted.
support_rebuild.zigconst mask = skill_data.active_support_mask(skill);
clear_support_effects(modifier_store, behavior_store, entity_index, skill_slot);
generate_support_stat_modifiers(modifier_store, entity_index, skill_slot, skill, mask);<br>generate_support_behavior_emissions(<br>behavior_store,<br>entity_index,<br>skill_slot,<br>skill,<br>mask,<br>);
This pass turns an equipped skill and its active supports into rows:
active skill gem + active support mask<br>-> stat modifier rows<br>-> behavior emission rows
The active_support_mask matters because a socketed support is not always active. In this codebase,<br>gem level controls which support slots are unlocked. I want that detail in one compiler pass, not<br>scattered through combat code.
The deletion step is just as important as the generation step. If a support is removed and its old<br>rows survive, the build keeps power it no longer earned.
In a system that can remove a source fact, deletion needs to be as deliberate as generation.<br>Otherwise stale compiled output...