LmCast :: Stay tuned in

Buildcraft Is a Compiler Problem

Recorded: May 24, 2026, 5 a.m.

Original Summarized

Buildcraft Is a Compiler Problem — mitander@xyz

mitander@xyz
~
>

blog
rss
github

Buildcraft Is a Compiler Problem
2026-05-20

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
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
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.
The tempting path is a growing pile of special cases:
if skill == cleave and support == wide_sweep:
make cleave radius bigger

if skill == cleave and support == focused_edge:
make cleave smaller but stronger

if skill == cleave and support == twin_cleave and rule == guarded_arc:
quietly move to the woods

That can work for a demo. It gets rough once the game has lots of skills, supports, items,
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
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
slot 3?" By the time a skill resolves, that question has become lower-level runtime data:
increased_damage_bp = 500
area_radius_bonus_subunits = 1000
area_sweep_profile = default
status_payload_count = 0
more_multipliers = [...]

"Compiler" here doesn't mean a grand language system. It means source facts become runtime facts
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
vocabulary.
support.zigconst support_modifier_max = constants.support_modifiers_max;
const support_behavior_max = constants.support_behaviors_max;

const ModifierSlots = [support_modifier_max]SupportModifier;
const BehaviorSlots = [support_behavior_max]SupportBehavior;

pub const SupportDef = struct {
scope: SupportScope,
modifiers: ModifierSlots = undefined,
modifier_count: u8 = 0,
behaviors: BehaviorSlots = undefined,
behavior_count: u8 = 0,
};

A 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.
Here is one support definition:
support.zigconst wide_sweep_index = @intFromEnum(SupportId.cleave_wide_sweep);

table[wide_sweep_index] = add_behaviors(
make_def(.skill, &.{
.{
.stat = .damage_increased_bp,
.op = .increased_bp,
.damage_type = .physical,
.value = 500,
},
}),
&.{
.{
.kind = .area_radius_subunits,
.value = constants.subunits_per_unit,
.tag_require = TagMask.init(&.{ .melee, .area }),
},
},
);

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
applicability tags. It says "melee + area," not "call the Cleave implementation and change its
radius."
Future 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.

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);
generate_support_behavior_emissions(
behavior_store,
entity_index,
skill_slot,
skill,
mask,
);

This pass turns an equipped skill and its active supports into rows:
active skill gem + active support mask
-> stat modifier rows
-> behavior emission rows

The active_support_mask matters because a socketed support is not always active. In this codebase,
gem level controls which support slots are unlocked. I want that detail in one compiler pass, not
scattered through combat code.
The 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.
In a system that can remove a source fact, deletion needs to be as deliberate as generation.
Otherwise stale compiled output will leak into later results.

Rows need provenance
The pipeline keeps source identity.
support.zig// Both IDs encode skill_slot | support_slot.
// Skill-scoped rows set the high bit so cleanup can target the right rows.
pub fn encode_entity_support_source_id(skill_slot: u8, support_slot: u8) u32 {
return (@as(u32, skill_slot) << 8) | @as(u32, support_slot);
}

pub fn encode_skill_support_source_id(skill_slot: u8, support_slot: u8) u32 {
return (1 << 15) | (@as(u32, skill_slot) << 8) | @as(u32, support_slot);
}

Small thing, lots of weight.
A 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.
Without provenance, the math can still add up, but the system cannot answer the more useful
question:

Why is this number here?

Entity scope and skill scope are different channels
Some supports change the whole entity. Some only change the supported skill.
The distinction gets compiled into the modifier row:
support_rebuild.zigconst modifier_scope: StatScope = switch (def.scope) {
.entity => .entity,
.skill => .skill_specific,
};

// Skill-scoped rows are keyed by skill identity.
const scope_param: u16 = if (modifier_scope == .skill_specific)
@intFromEnum(skill.gem.skill_id)
else
0;

Boring until it is wrong.
If 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.
So the pipeline keeps scope explicit:
entity scope -> affects the actor
skill-specific -> affects a skill identity
rule emission -> changes how applicability is interpreted

This is where the compiler analogy keeps paying rent: scopes, namespaces, and rewrites in a very
small form.

Behavior emissions carry shape, not behavior code
Stat rows are simple enough: add damage, increase health, apply a multiplier.
Behavior changes are messier. Pierce, chain, fork, extra projectiles, area radius, conversion,
status payloads, and gem-level deltas are different shapes of change.
So they go through a separate emission type and fold into a skill cache:
modifier.zigconst more_multiplier_max = 4;
const status_payload_max = constants.skill_support_status_payloads_max;

const MoreMultipliers = [more_multiplier_max]i32;
const StatusPayloadSlots = [status_payload_max]SupportStatusPayload;

pub const SkillCacheEntry = extern struct {
increased_damage_bp: i32 = 0,
more_multipliers: MoreMultipliers = .{0} ** more_multiplier_max,
more_count: u8 = 0,
pierce_count: u8 = 0,
chain_count: u8 = 0,
fork_count: u8 = 0,
extra_projectile_count: u8 = 0,
convert_override: ConvertOverride = .{},
area_radius_bonus_subunits: i32 = 0,
status_payloads: StatusPayloadSlots = .{.{}} ** status_payload_max,
status_payload_count: u8 = 0,
effective_gem_level: u8 = 0,
gem_level_delta: i8 = 0,
area_sweep_profile: AreaSweepProfile = .default,
};

The cache entry is the runtime summary for a skill slot.
It 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:
damage modifiers
projectile behavior counts
conversion override
area radius delta
status payloads
effective gem level

That split matters. Combat consumes the summary. Inspection and cleanup can still use the source
rows.

Dirty domains keep rebuilds bounded
The 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.
stats_rebuild.zigconst domains = dirty_domains_for_entity(world, entity_index);
const rebuild_skill_cache = modifier_store.is_skill_cache_dirty(entity_index) or
behavior_store.is_dirty(entity_index) or
scope_rewire_store.is_dirty(entity_index);
if (!domains.any() and !rebuild_skill_cache) {
modifier_store.clear_dirty(entity_index);
behavior_store.clear_dirty(entity_index);
scope_rewire_store.clear_dirty(entity_index);
runtime_rule_store.clear_dirty(entity_index);
continue;
}

rebuild_entity(
modifier_store,
behavior_store.span(entity_index),
scope_rewire_store.span(entity_index),
runtime_rule_store.span(entity_index),
entities,
entity_index,
domains,
rebuild_skill_cache,
);

This is incremental compilation in miniature.
A 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.
Partly 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.
The systems that use stats should not need to ask, "has anyone updated this yet?" The rebuild phase
is what makes that true.

Tags are an applicability filter, not a skill matrix
Once rows exist, the behavior rebuild pass folds them into each skill cache.
behavior_rebuild.zigreset_skill_cache(cached);

for (skill_slots.skills, 0..) |skill, slot| {
if (skill.is_empty()) continue;

const skill_id = skill.gem.skill_id;
const skill_tags = catalog.skill.def(skill_id).tag_mask;
const entry = &cached.skill_cache[slot];

entry.effective_gem_level = skill.gem_level;

// Fold rows into the slot-local runtime summary.
fold_skill_damage_modifiers(...);
fold_entity_applicable_damage_modifiers(...);
fold_behavior_emissions(behaviors, slot, skill_tags, entry);
apply_runtime_skill_rules(skill_id, cached.rules, entry);
finalize_effective_gem_level(entry, skill.gem_level);
}

The 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.
Applicability is tag-based:
pub fn tags_match(skill_tags: TagMask, require: TagMask, exclude: TagMask) bool {
const skill_bits = skill_tags.bits();
const require_bits = require.bits();
const exclude_bits = exclude.bits();
assert((require_bits & exclude_bits) == 0);

const has_required = (skill_bits & require_bits) == require_bits;
const has_excluded = (skill_bits & exclude_bits) != 0;
return has_required and !has_excluded;
}

A 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.
That lets content say:
requires melee + area
requires attack + melee + physical
excludes cold

This 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.
The 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.
Still plenty of room for bugs, but at least the bug has a small vocabulary.

Rule rewrites are separate from stat math
Some build effects are not stats. They change how other facts should be interpreted.
Example shape: "spell damage applies to melee." Not a flat damage number. It changes whether a
spell-only modifier can apply to a melee skill.
The behavior rebuild has a small place for that:
const direct_match = stat_applicability_matches(skill_tags, m.applicability);
const rewire_match = entity_rules.spell_damage_applies_to_melee and
skill_tags.melee and m.applicability == .spell_only;
if (!direct_match and !rewire_match) continue;

The 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.
In 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.

Resolution consumes compiled facts
When a skill resolves, the projectile path starts from the cache.
resolution.zigconst skill_cache_entry = cached.skill_cache[use_skill.action_slot];
const caster_ctx = build_caster_context(use_skill.caster, cached);

delivery_projectile.execute(
skill_id,
skill_preview.def,
caster_ctx,
spatial_ctx,
skill_preview.gem_level,
cached,
skill_cache_entry,
&world.projectiles,
);

Projectile delivery then consumes the fields it cares about:
delivery_projectile.zigconst projectile_count = resolve_projectile_spawn_count(skill_cache_entry);
const increased_damage_bp = payload.saturating_add_i32(
cached_stats.damage_increased_bp,
skill_cache_entry.increased_damage_bp,
);

And when it allocates the projectile:
delivery_projectile.zig_ = projs.allocate(.{
.source = caster_ctx.id,
.damage = resolved_payload.base_damage,
.more_multipliers = skill_cache_entry.more_multipliers,
.more_count = skill_cache_entry.more_count,
.pierce_remaining = resolve_projectile_pierce(skill_cache_entry),
.chain_remaining = resolve_projectile_chain(skill_cache_entry),
.fork_remaining = resolve_projectile_fork(skill_cache_entry),
.convert_override = resolve_projectile_convert(skill_cache_entry),
}) catch continue;

Notice what is mostly missing from this layer: support IDs.
Projectile delivery doesn't need to care whether pierce came from a support gem, an item, a status,
or a future shrine. It gets pierce_remaining.
The source attribution still exists upstream for cleanup, inspection, debugging, and tests. The hot
path gets the compiled facts.

Bounded weirdness
There are caps in the content contract:
constants.zigpub const support_slots_max: u8 = 9;
pub const support_modifiers_max: u8 = 3;
pub const support_behaviors_max: u8 = 1;

The behavior cache also has fixed payload slots, fixed more-multiplier storage, and fixed projectile
behavior counts.
The 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.
If 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.

Where this leaves the design
The pipeline currently looks like this:
authored support data
-> active slot mask
-> stat modifier rows
-> behavior emission rows
-> dirty domains
-> cached stats and skill cache
-> delivery resolution
-> combat/projectile/status queues

Each stage has a job.
Authoring 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.
This feels promising because adding a support becomes adding a source fact, not negotiating with
every combat system individually.
The 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.
The current shape gives me seams to test and places to put the weirdness:
express the build effect
-> emit rows
-> rebuild caches
-> consume facts

The goal is no giant skill/support matrix, no stale support rows, and no combat archaeology unless I
am debugging the compiler passes themselves.
Just a small compiler with a sword.

← index

drink coffee, produce bugs
contact: carl@mitander.xyz

The challenge in designing buildcraft mechanics within an ARPG game engine stems from the combinatorial explosion that occurs when combining skills, supports, items, statuses, and runtime rules, which leads to numerous special cases. The author argues that this complexity can be managed by reframing buildcraft as a small compiler pipeline, where authored content serves as the source input that must be compiled into runtime facts. Supports, items, statuses, and affixes are defined to emit these facts, which are then folded into derived caches that the combat system consumes, thereby avoiding the need for skill resolution to constantly query specific runtime dependencies.

In this system, the compiler treats source facts as inputs that generate runtime facts before the hot path must account for their origin. A support, for instance, should function as encapsulated data with a narrow vocabulary, rather than executable gameplay code. This approach involves defining how supports emit stat modifiers and behavior changes, ensuring that they only specify applicability tags, such as "melee plus area," rather than directly executing functions or patching specific skill implementations. This separates the definition of effects from their execution, allowing future skills to interact with existing support rules without requiring explicit wiring for every combination.

The process involves compiling support effects into rows that link active skills and their corresponding supports to derived stat modifier rows and behavior emission rows. This compilation pass must handle the deletion of old effects alongside the generation of new ones to prevent stale compiled output from persisting. To maintain traceability, rows must encode provenance, using identifiers to track the source of modifiers, linking them back to skill slots and support slots. This provenance ensures that mathematical results are accompanied by context, allowing for debugging and inspection of why specific values were generated.

Scope management is critical, distinguishing between entity scope, which affects the entire actor, and skill-specific scope, which affects the identity of a particular skill. This distinction is compiled into the derived rows, allowing the system to differentiate how changes propagate. For example, a status effect might apply an entity-wide change, whereas an item effect imposes a skill-specific rule. This separation prevents unintended inheritance of effects, such as ensuring that a skill does not automatically inherit a stat bonus from an unrelated skill just because both are equipped.

Behavior emissions are separated from stat modifications, as behaviors like piercing, chaining, or area radius are distinct shapes of change. These behavioral emissions are folded into a skill cache entry, which serves as a compact runtime summary for a skill slot, summarizing only the relevant resolved attributes such as damage modifiers and projectile behavior counts, rather than storing the underlying source data. This cache is updated incrementally; mutation marks dirty domains, triggering a rebuild pass that incrementally updates the skill caches based on the newly compiled facts.

Applicability is then determined by tag-based filtering. Instead of maintaining a complex skill-support identity matrix, applicability is checked using tags, ensuring that a behavior emission only applies if the runtime entity possesses the required tags (like melee and area) and lacks any excluded tags. This structure keeps the system modular, providing explicit separation between stat math, behavioral rewrites, and applicability rules.

Ultimately, when a skill resolves during combat, it consumes these pre-compiled facts from the skill cache. The resolution step focuses solely on consuming the summarized state—damage multipliers, projectile counts, and derived effects—rather than needing to perform complex lookups into the history of which supports or items caused those values. This compiler approach aims to eliminate combat archaeology by ensuring that the complex buildcraft logic is resolved during a dedicated compilation phase, leaving the hot path focused on efficient execution.