Bullet Heaven — Transformative Elemental Mods (Spec)
Bullet Heaven — Transformative Elemental Mods (Spec)
Section titled “Bullet Heaven — Transformative Elemental Mods (Spec)”Date: 2026-06-23 Status: Approved (design) — pending spec review Type: Game systems — Milestone 2, Cycle 5 (the build-craft pillar: transformative mods that deepen the elemental engine)
1. Purpose
Section titled “1. Purpose”Add the first transformative mods — run modifiers chosen at level-up that change how the elemental engine behaves (not just flat player stats). This is the build-craft pillar made interactive: stack synergies that make elemental builds deeper. Three mods, sharing one framework:
- Overcharge —
+1 element stack per hit(every application adds an extra stack; stronger DoT and bigger reactions). - Catalyst —
+50% reaction damage(Plasma and generic bursts hit harder). - Lingering —
+50% aura duration(auras decay slower, so combos hold longer).
They join the level-up choice pool alongside the existing stat upgrades, and stack when picked repeatedly.
Deferred (later cycles, not here): weapon evolutions (need the mod system + evolved-weapon authoring first), projectile-mechanic mods (the bible’s pierce/split — no engine behavior this slice), elemental-mod gating, and any new elements/weapons.
2. Architecture & invariants
Section titled “2. Architecture & invariants”Unchanged keystones: one-way data flow; /sim is pure RefCounted logic (no Node/render/Input/Engine/Time/File/JSON APIs); constant-DT deterministic tick; content from ContentDB.
Determinism is preserved by no-op defaults. The run’s modifiers live in a ModState whose defaults are no-ops (stack_bonus 0, all multipliers 1.0). Every code path reads ModState, but with defaults it computes exactly what it did before — so an un-modded run (and the determinism property test, which applies no upgrades in its tick loop) produces a byte-identical trace. Mods are applied only via apply_upgrade (outside the tick), mutating ModState; the tick then reads it deterministically. No RNG is added.
Two effect vocabularies, one dispatch. Stat upgrades already map an effect to a PlayerState mutation via StatEffects. This slice adds a sibling SimMods mapping an effect to a ModState mutation. Upgrades.apply dispatches to whichever vocabulary knows the effect. The offer filter mirrors the existing one: a mod is offerable iff it is a known stat effect OR a known sim-mod effect — so the bible’s pierce/split (unknown to both) stay excluded.
3. Components
Section titled “3. Components”3.1 sim/mod_state.gd (NEW) — ModState (pure data)
Section titled “3.1 sim/mod_state.gd (NEW) — ModState (pure data)”class_name ModState extends RefCounted. The run’s accumulated build modifiers. No I/O, no logic beyond holding fields. Defaults are no-ops:
var stack_bonus: int = 0 # extra element stacks added per applicationvar reaction_damage_mult: float = 1.0var aura_duration_mult: float = 1.03.2 sim/sim_mods.gd (NEW) — SimMods (pure static, sibling of StatEffects)
Section titled “3.2 sim/sim_mods.gd (NEW) — SimMods (pure static, sibling of StatEffects)”Maps a transformative mod effect name to a ModState mutation + a label. Data drives magnitude; this table drives mechanism.
| effect | ModState field | op | label |
|---|---|---|---|
stack_bonus |
stack_bonus |
add |
element stack per hit |
reaction_damage_mult |
reaction_damage_mult |
mul |
reaction damage |
aura_duration_mult |
aura_duration_mult |
mul |
aura duration |
const TABLE := { ... }(same shape asStatEffects.TABLE).static func is_known(effect: String) -> boolstatic func apply(effect: String, magnitude: float, mods: ModState) -> void—add:mods.set(field, mods.get(field) + magnitude)(int field tolerates int magnitude);mul:mods.set(field, mods.get(field) * magnitude). Unknown effect → silent no-op (upstream-guarded; apush_errorhere would only trip GUT, per the project gotcha).static func describe(effect: String, magnitude: float) -> String—mul→"+50% reaction damage"((mag-1)*100);add→"+1 element stack per hit"(whole-number flat). Identical formatting rules toStatEffects.describe.
3.3 ContentDB.upgrades() (modify sim/content_db.gd)
Section titled “3.3 ContentDB.upgrades() (modify sim/content_db.gd)”Offer stat mods AND transformative mods the engine can apply:
func upgrades() -> Array: var out: Array = [] for m in _entries("mods"): if not (m is Dictionary): continue var kind: String = m.get("kind", "") var effect: String = m.get("effect", "") if kind == "stat" and StatEffects.is_known(effect): out.append(m) elif kind == "transformative" and SimMods.is_known(effect): out.append(m) return outDocument order preserved (deterministic rolls). upgrade(id) is unchanged (iterates upgrades()).
3.4 Upgrades (modify sim/upgrades.gd)
Section titled “3.4 Upgrades (modify sim/upgrades.gd)”Dispatch application + display across both vocabularies.
static func apply(id: String, content: ContentDB, player: PlayerState, mods: ModState) -> void— look up the mod; ifStatEffects.is_known(effect)→StatEffects.apply(effect, magnitude, player); elifSimMods.is_known(effect)→SimMods.apply(effect, magnitude, mods); elsepush_error(genuinely-unknown id is a bug — but no test triggers it). Signature gainsmods.static func choice_display(id, content) -> Dictionary—desc=StatEffects.describe(...)if it’s a stat effect, elseSimMods.describe(...).namefrom the mod’sname. (Nomodsneeded — describe is magnitude-only.)roll_choices(rng, content, n)— unchanged (rolls ids fromcontent.upgrades(), which now includes the sim mods).
3.5 Sim (modify sim/sim.gd)
Section titled “3.5 Sim (modify sim/sim.gd)”- New field
var mods: ModState, constructed in_init(mods = ModState.new()— no-op defaults). apply_upgrade(id)threads it:Upgrades.apply(id, content, player, mods)(then the existingpending_levelupsdecrement)._reaction_burst(center, magnitude, generic)scales the burst:var amount := (GENERIC_REACTION_MAGNITUDE if generic else magnitude) * mods.reaction_damage_mult. (Radius unchanged this slice.)_resolve_collisionsand_apply_status_and_decay/anywhere callingElemental.applypassmods(see §3.6).
3.6 Elemental.apply (modify sim/elemental.gd) — gains mods
Section titled “3.6 Elemental.apply (modify sim/elemental.gd) — gains mods”static func apply(pool, i, element_idx, content, mods: ModState) -> Dictionary. The mods param adjusts stacks and aura duration; the reaction-event shape is unchanged (the Sim applies reaction_damage_mult in _reaction_burst).
- No aura:
stacks = mini(1 + mods.stack_bonus, stacks_max);aura_remaining = aura_decay_s * mods.aura_duration_mult. - Same element (reinforce):
stacks = mini(stacks + 1 + mods.stack_bonus, stacks_max);aura_remaining = aura_decay_s * mods.aura_duration_mult. - Different element (react): magnitude computed from the current stacks as today (
base * per_stack_scale^stacks); consume + replace sets the new aura tostacks = mini(1 + mods.stack_bonus, stacks_max),aura_remaining = aura_decay_s * mods.aura_duration_mult.
With a default ModState (stack_bonus 0, aura_duration_mult 1.0) this is identical to the current behavior.
Callers updated to pass mods: Sim._resolve_collisions (Elemental.apply(enemies, ei, pulse_element_idx, content, mods)), WeaponNova.update (Elemental.apply(sim.enemies, ei, sim.nova_element_idx, sim.content, sim.mods)). Elemental stays pure (ModState is plain data, not Sim).
3.7 Data edits (tools/design-bible/src/seed.js → re-export data/bible.json)
Section titled “3.7 Data edits (tools/design-bible/src/seed.js → re-export data/bible.json)”Add three transformative mods to the mods array (the mod(...) helper is mod(id, name, kind, effect, magnitude, applies = [])):
mod('overcharge', 'Overcharge', 'transformative', 'stack_bonus', 1),mod('catalyst', 'Catalyst', 'transformative', 'reaction_damage_mult', 1.5),mod('lingering', 'Lingering', 'transformative', 'aura_duration_mult', 1.5),Re-export via node tools/design-bible/scripts/export-seed.mjs > data/bible.json (Cycle-3 pipeline). They load through the existing validator (not required entries, so no new validation needed; they become offerable via SimMods.is_known).
3.8 main.gd / UI
Section titled “3.8 main.gd / UI”No change required: _open_levelup already builds {id, name, desc} via Upgrades.choice_display(id, sim.content) (which now describes sim mods too) and sim.apply_upgrade(id) already threads sim.mods internally. The mixed pool and the new mods flow through the existing level-up panel automatically.
4. Testing
Section titled “4. Testing”TDD throughout (GUT, headless). New / updated:
test_sim_mods.gd(NEW) —is_knownfor the three effects + false for unknown/stat effects;applymutates the rightModStatefield by the right op (add vs mul);describeformats%-vs-flat correctly.test_mod_state.gd(NEW) — defaults are the no-ops (stack_bonus 0, mults1.0).test_elemental.gd(UPDATE) — existing calls get a no-opModStatearg (behavior unchanged); ADD: withstack_bonus = 1, a fresh application sets2stacks (and caps atstacks_max); withaura_duration_mult = 2.0,aura_remaining = aura_decay_s * 2.test_upgrades.gd(UPDATE) —applysignature gains aModState; ADD: applying a sim-mod id (e.g.catalyst) mutates theModState(not the player);choice_display("catalyst", content)returns the SimMods description;roll_choicescan now surface transformative mods.test_content_db.gd(UPDATE) —upgrades()now includes the three transformative mods (8 offerable: 5 stat + 3 sim) and still excludescrit/pierce/split.test_mods_in_sim.gd(NEW, integration vs realbible.json) — drive the sim withsim.modsset:- Overcharge: with
stack_bonus = 1, a pulse hit leaves the enemy at 2 stacks (vs 1 unmodded). - Catalyst: with
reaction_damage_mult = 2.0, a Plasma burst deals double the neighbor damage of an unmodded burst. - Lingering: with
aura_duration_mult = 2.0, an aura survives twice as many decay ticks.
- Overcharge: with
test_determinism.gd(UNCHANGED assertions) — must still pass:Sim.newbuilds a no-opModState, no upgrades applied in the tick loop, so the trace is byte-identical. Strengthen check: capture a 600-tick trace hash before/after to confirm byte-identity (as done for the perf change), since this touches the elemental path.- Test-count guard (
scripts/check-test-count.sh) must stay green; confirm the count rose by the new files.
Boot smoke (--quit-after 240) clean. Build feel verified by playtest (pick Overcharge/Catalyst/Lingering, observe stronger DoT / bigger bursts / longer auras).
5. Success criteria
Section titled “5. Success criteria”- Level-up offers a mixed pool: the 5 stat upgrades plus Overcharge, Catalyst, Lingering.
- Overcharge adds an extra stack per hit; Catalyst multiplies reaction-burst damage; Lingering extends aura duration. Each stacks when picked repeatedly.
- All values come from
ContentDB/bible.json; the threeseed.jsmods are re-exported and load clean. - An un-modded run is byte-identical to before (default
ModStateno-ops); the determinism property test passes, confirmed by a before/after trace-hash check. /simstays pure;Elementalkeeps noSimdependency (takesModState, plain data).- The bible’s
pierce/splitremain excluded from the offer pool (unknown to both vocabularies). - Full GUT suite passes; test count rises by the new files.
6. Out of scope (explicit)
Section titled “6. Out of scope (explicit)”- Weapon evolutions (max-level + required-mod → evolved weapon) — needs this mod system first; later cycle.
- Projectile-mechanic mods (
pierce,split) — they need per-projectile state + split spawning; not this slice. - Reaction-radius mod, elemental-resist interactions, per-weapon (vs global) mod scoping — later.
- Mod rarity/weighting in the roll, removing/rerolling mods, mod caps — the roll stays the existing uniform Fisher-Yates.
- Any new element, weapon, enemy, or status kind.
7. Risks & notes
Section titled “7. Risks & notes”- Elemental.apply ripple: the new
modsparam touches its two production callers +test_elemental.gd(the only direct test caller; the sim-path tests go through the Sim, which passessim.mods). Mechanical, and the no-op default keeps behavior identical. - Determinism: guarded two ways — no-op
ModStatedefaults + the before/after trace-hash check. Mods never draw RNG. - Schema/validation: the three mods are
kind: transformative, not required entries, so the existing validator accepts them without change; they self-gate viaSimMods.is_known. A malformed one would simply not be offered (acceptable; not a required entry). - Stacking unboundedness: Overcharge stacks are capped by
stacks_max; Catalyst/Lingering multipliers are uncapped (intended — runaway scaling is the fun, and balance is a later tool-tuning pass, fully data-driven). ModStateonSimvsPlayerState: chosenSimbecause these modify sim/elemental behavior, not player movement. It is plain data, passed toElementalso that unit staysSim-free.