Skip to content

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)


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.


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.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 application
var reaction_damage_mult: float = 1.0
var aura_duration_mult: float = 1.0

3.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 as StatEffects.TABLE).
  • static func is_known(effect: String) -> bool
  • static func apply(effect: String, magnitude: float, mods: ModState) -> voidadd: 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; a push_error here would only trip GUT, per the project gotcha).
  • static func describe(effect: String, magnitude: float) -> Stringmul"+50% reaction damage" ((mag-1)*100); add"+1 element stack per hit" (whole-number flat). Identical formatting rules to StatEffects.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 out

Document order preserved (deterministic rolls). upgrade(id) is unchanged (iterates upgrades()).

Dispatch application + display across both vocabularies.

  • static func apply(id: String, content: ContentDB, player: PlayerState, mods: ModState) -> void — look up the mod; if StatEffects.is_known(effect)StatEffects.apply(effect, magnitude, player); elif SimMods.is_known(effect)SimMods.apply(effect, magnitude, mods); else push_error (genuinely-unknown id is a bug — but no test triggers it). Signature gains mods.
  • static func choice_display(id, content) -> Dictionarydesc = StatEffects.describe(...) if it’s a stat effect, else SimMods.describe(...). name from the mod’s name. (No mods needed — describe is magnitude-only.)
  • roll_choices(rng, content, n) — unchanged (rolls ids from content.upgrades(), which now includes the sim mods).
  • 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 existing pending_levelups decrement).
  • _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_collisions and _apply_status_and_decay/anywhere calling Elemental.apply pass mods (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 to stacks = 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).

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.


TDD throughout (GUT, headless). New / updated:

  • test_sim_mods.gd (NEW) — is_known for the three effects + false for unknown/stat effects; apply mutates the right ModState field by the right op (add vs mul); describe formats %-vs-flat correctly.
  • test_mod_state.gd (NEW) — defaults are the no-ops (stack_bonus 0, mults 1.0).
  • test_elemental.gd (UPDATE) — existing calls get a no-op ModState arg (behavior unchanged); ADD: with stack_bonus = 1, a fresh application sets 2 stacks (and caps at stacks_max); with aura_duration_mult = 2.0, aura_remaining = aura_decay_s * 2.
  • test_upgrades.gd (UPDATE) — apply signature gains a ModState; ADD: applying a sim-mod id (e.g. catalyst) mutates the ModState (not the player); choice_display("catalyst", content) returns the SimMods description; roll_choices can now surface transformative mods.
  • test_content_db.gd (UPDATE) — upgrades() now includes the three transformative mods (8 offerable: 5 stat + 3 sim) and still excludes crit/pierce/split.
  • test_mods_in_sim.gd (NEW, integration vs real bible.json) — drive the sim with sim.mods set:
    • 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.
  • test_determinism.gd (UNCHANGED assertions) — must still pass: Sim.new builds a no-op ModState, 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).


  1. Level-up offers a mixed pool: the 5 stat upgrades plus Overcharge, Catalyst, Lingering.
  2. Overcharge adds an extra stack per hit; Catalyst multiplies reaction-burst damage; Lingering extends aura duration. Each stacks when picked repeatedly.
  3. All values come from ContentDB/bible.json; the three seed.js mods are re-exported and load clean.
  4. An un-modded run is byte-identical to before (default ModState no-ops); the determinism property test passes, confirmed by a before/after trace-hash check.
  5. /sim stays pure; Elemental keeps no Sim dependency (takes ModState, plain data).
  6. The bible’s pierce/split remain excluded from the offer pool (unknown to both vocabularies).
  7. Full GUT suite passes; test count rises by the new files.

  • 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.

  • Elemental.apply ripple: the new mods param touches its two production callers + test_elemental.gd (the only direct test caller; the sim-path tests go through the Sim, which passes sim.mods). Mechanical, and the no-op default keeps behavior identical.
  • Determinism: guarded two ways — no-op ModState defaults + 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 via SimMods.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).
  • ModState on Sim vs PlayerState: chosen Sim because these modify sim/elemental behavior, not player movement. It is plain data, passed to Elemental so that unit stays Sim-free.