Skip to content
HN On Hacker News ↗

Buildcraft Is a Compiler Problem — mitander@xyz

▲ 13 points 2 comments by mitander 2d ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully AI-generated

99 %

AI likelihood · overall

AI
0% human-written 100% AI-generated
SEGMENTS · HUMAN 0 of 6
SEGMENTS · AI 6 of 6
WORD COUNT 1,589
PEAK AI % 100% · §1
Analyzed
May 24
backend: pangram/v3.3
Segments scanned
6 windows
avg 265 words each
Distribution
0 / 100%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,589 words · 6 segments analyzed

Human AI-generated
§1 AI · 100%

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 = [...]

Authoring data should stay boring A support definition is not executable gameplay code. In this design it is data with a narrow vocabulary.

§2 AI · 100%

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.

§3 AI · 100%

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.

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.

§4 AI · 100%

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 = .{.{}} **

§5 AI · 100%

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.

§6 AI · 100%

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.