Skip to content

Bullet Heaven — Elemental Engine (Spec)

Date: 2026-06-22 Status: Approved (design) — pending spec review Type: Game systems — Milestone 2, Cycle 4 (the build-craft pillar: in-game auras / stacks / reactions)


Implement the elemental system in-game (the design-bible spec §3): weapons apply elements to enemies, building auras with stacks that drive status effects, and a different element on an existing aura triggers a reaction. This is the game’s combinatorial novelty engine and the build-craft pillar.

This first slice makes the headline loop playable: two auto-firing weapons of different elements (pulse = lightning, nova = fire) land on the same enemies, so reactions fire live. It builds the complete engine — apply / reinforce / react / decay, status effects, the reaction lookup — as pure deterministic sim driven by ContentDB, plus the one new weapon archetype needed to demonstrate it.

Scope is deliberately bounded: single-active-aura per enemy (not multi-simultaneous), two status-effect kinds (DoT, vulnerability), one reaction effect kind (burst) plus a generic fallback, and one new weapon (nova). Everything is the framework the rest of the elemental content hangs off; later slices add multi-aura, the remaining status/reaction kinds, and the other weapons/elements.


Unchanged keystones from M1: one-way data flow (Input → Sim → Render); /sim is pure RefCounted logic (no Node/render/Input/Engine/Time/File/JSON APIs); constant-DT deterministic tick; data-oriented EntityPool swarm; content from ContentDB (Cycle 3).

Determinism: all new logic is pure sim on Sim_Const.DT, deterministic iteration order, no RNG (Plasma is a deterministic AoE — it draws nothing from rng or upgrade_rng). tests/test_determinism.gd is a property test (same seed → two identical runs), not a hardcoded golden string, so adding deterministic elemental logic keeps it green with no re-baselining — both runs include the new logic identically. Verify it still passes; do not weaken it.

The single correctness hazard — lockstep swap-remove. Per-enemy element state lives in extra columns on the enemy pool. When an enemy is swap-removed, those columns MUST swap-remove in the same step, or element state aliases onto the wrong enemy. This is the same discipline as the existing deferred-collision-removal. Guarded by a dedicated EnemyPool whose add/remove_at move the element columns together with the base columns, and by a unit test asserting it.


3.1 sim/enemy_pool.gd (NEW) — EnemyPool extends EntityPool

Section titled “3.1 sim/enemy_pool.gd (NEW) — EnemyPool extends EntityPool”

Adds three parallel columns sized to capacity, holding per-enemy single-aura state:

  • aura_element: PackedInt32Array — element index into a fixed element order (see §3.4), -1 = no aura.
  • stacks: PackedInt32Array — current stack count.
  • aura_remaining: PackedFloat32Array — seconds of aura decay left.

Overrides:

  • _init(cap)super._init(cap) then resize the three columns to cap.
  • add(p, v, r, d) -> intvar i := super.add(p, v, r, d); if i != -1, initialize aura_element[i] = -1, stacks[i] = 0, aura_remaining[i] = 0.0. Returns i.
  • remove_at(i) — move the three element columns from last = count - 1 into i (when i != last) before calling super.remove_at(i) (which swaps base columns and decrements count). Order matters: the column swap uses the pre-decrement count.

3.2 sim/elemental.gd (NEW) — Elemental (pure logic class)

Section titled “3.2 sim/elemental.gd (NEW) — Elemental (pure logic class)”

The aura/stack/reaction state machine. Operates on an EnemyPool index + a ContentDB. No Node/Engine APIs, and no dependency on Sim — it returns a reaction event and the Sim applies the spatial effect (§3.5/§3.6). This keeps Elemental a pure, isolated state machine.

  • static func apply(pool: EnemyPool, i: int, element_idx: int, content: ContentDB) -> Dictionary — mutates the enemy’s aura columns and returns a reaction event: {} when no reaction fired, or { "center": Vector2, "magnitude": float, "generic": bool } when one did (the Sim turns this into a burst).
    • No aura (aura_element[i] == -1): set aura element_idx, stacks = 1, aura_remaining = aura_decay_s (from the element’s data). Return {}.
    • Same element (aura_element[i] == element_idx): reinforcestacks = min(stacks + 1, stacks_max), refresh aura_remaining = aura_decay_s. Return {}.
    • Different element: react — resolve the reaction for (current aura element id, applied element id) via ContentDB.reaction(...). Compute magnitude from the current stacks: base_magnitude × per_stack_scale^stacks for an authored burst reaction, else GENERIC_REACTION_MAGNITUDE (a found-but-non-burst reaction or no reaction → generic = true). Then consume + replace: set the aura to the newly-applied element with stacks = 1, aura_remaining = aura_decay_s. Return { center = pool.pos[i], magnitude, generic }. (All authored MVP reactions are consumes_aura: true; replace-with-applied is the single deterministic rule this slice implements.)
  • static func decay(pool: EnemyPool, i: int, dt: float) -> void — if aura_element[i] != -1: aura_remaining[i] -= dt; at <= 0, clear (aura_element[i] = -1, stacks[i] = 0, aura_remaining[i] = 0.0).
  • Elemental owns only aura bookkeeping + reaction resolution. Per-tick status math lives in StatusEffects (§3.4); the spatial burst lives in Sim (§3.5/§3.6).

3.3 ContentDB additions (sim/content_db.gd)

Section titled “3.3 ContentDB additions (sim/content_db.gd)”

Add typed getters for the new categories (pure data, same pattern as Cycle 3):

  • func element(id: String) -> Dictionary / func element_at(idx: int) -> Dictionary
  • func element_index(id: String) -> int — index in the elements array document order, or -1.
  • func element_count() -> int
  • func reaction(aura_id: String, applied_id: String) -> Dictionary — finds the reactions entry whose aura==aura_id and applied==applied_id; {} if none (caller uses the generic fallback).

The fixed element order is the elements array’s document order (deterministic). aura_element indices are these.

3.4 sim/status_effects.gd (NEW) — StatusEffects (pure logic, analogous to StatEffects)

Section titled “3.4 sim/status_effects.gd (NEW) — StatusEffects (pure logic, analogous to StatEffects)”

Maps an element’s status name to a status kind and applies it. The DATA (status_base, per_stack_scale, stacks_max) drives magnitude; this table drives mechanism. MVP kinds:

status element kind mechanism
burn fire dot enemy loses status_base × stacks HP per second while aura active
shock lightning vuln enemy takes × (1 + status_base × stacks) weapon damage while aura active
  • const KIND := { "burn": "dot", "shock": "vuln" } (other statuses unmapped this slice → no per-tick / no amp; harmless).
  • static func is_dot(status: String) -> bool / static func is_vuln(status: String) -> bool
  • static func dot_per_second(status, status_base, stacks) -> floatstatus_base × stacks if is_dot, else 0.0.
  • static func vuln_multiplier(status, status_base, stacks) -> float1.0 + status_base × stacks if is_vuln, else 1.0.

These take already-extracted numbers (not the enemy/pool) so they are trivially unit-testable in isolation.

3.5 Reaction burst — Sim._reaction_burst (needs the spatial hash + damage path)

Section titled “3.5 Reaction burst — Sim._reaction_burst (needs the spatial hash + damage path)”

The reaction effect enum is broad; this slice realizes every reaction as a burst (the only spatial effect), with magnitude/radius distinguishing authored vs generic:

  • func _reaction_burst(center: Vector2, magnitude: float, generic: bool) -> void — queries hash.query_circle(center, radius, enemies) (radius = GENERIC_REACTION_RADIUS if generic else REACTION_BURST_RADIUS) and _damage_enemy(ei, magnitude) each hit. The hash is already fresh whenever a reaction can fire (reactions are only produced inside _resolve_collisions and nova.update, both of which rebuild the hash before querying — see §3.6). The burst subtracts HP only; deaths are handled by the end-of-tick sweep, so a burst never removes an enemy mid-phase and never staleness the hash.
  • Called by the Sim immediately after Elemental.apply(...) returns a non-empty reaction event: _reaction_burst(ev.center, ev.magnitude, ev.generic). The burst does NOT apply an element (no cascade this slice).
  • Authored burst magnitude comes from the reaction data (base_magnitude × per_stack_scale^stacks); the generic fallback uses the fixed GENERIC_REACTION_MAGNITUDE.

REACTION_BURST_RADIUS, GENERIC_REACTION_RADIUS, and GENERIC_REACTION_MAGNITUDE are Sim constants (engine tuning, not in the reaction schema — like SPAWN_RING).

  • enemies becomes an EnemyPool (was EntityPool).
  • Damage is decoupled from death (deferred death sweep). This is the change that makes multiple new damage phases coherent:
    • func _damage_enemy(ei: int, amount: float) -> void — multiply amount by the enemy’s shock vulnerability (StatusEffects.vuln_multiplier from its current aura status + stacks, via content), then enemies.data[ei] -= amount. No removal, no gem, no kill count here — pure HP subtraction. Safe to call from any phase, any number of times, without touching pool size or the hash.
    • func _sweep_dead() -> void — once per tick, AFTER all damage phases: iterate i from count - 1 down to 0; for any enemy with data[i] <= 0.0, gems.add(pos[i], Vector2.ZERO, GEM_RADIUS, _gem_xp), kills += 1, enemies.remove_at(i) (the EnemyPool swap-remove moves element columns in lockstep). Descending order keeps swap-remove indices valid.
    • This replaces M1’s inline gem/kill + per-_resolve_collisions deferred-removal list. Same outcome (one gem + one kill per dead enemy, dropped at its position), now uniform across all damage sources and removal-safe.
  • Element application on hit: after a weapon’s _damage_enemy, if the enemy still survives (data[ei] > 0), call var ev := Elemental.apply(enemies, ei, pulse_element_idx, content) (pulse’s element, for projectile hits in _resolve_collisions) and, if ev is non-empty, _reaction_burst(ev.center, ev.magnitude, ev.generic). Order is damage first, then apply (so the hit that applies shock isn’t retroactively self-amped).
  • Burn DoT + decay — one per-enemy pass each tick: if the enemy has a dot aura, _damage_enemy(ei, StatusEffects.dot_per_second(status, status_base, stacks) * dt); then Elemental.decay(enemies, ei, dt). No removal here — the sweep handles deaths.
  • Tick order (tick(input)): player.integraterun_time += dt_spawn_enemies_move_enemiesweapon.update (pulse fires a projectile) → nova.update (rebuilds hash, AoE damage + fire application, may produce reaction bursts) → _move_projectiles_resolve_collisions (rebuilds hash, projectile hits: damage + lightning application + reaction bursts) → _apply_status_and_decay (burn DoT + aura decay) → _sweep_dead_collect_gems_check_player_hit. Every phase that queries the hash rebuilds it first; no phase removes enemies; one sweep at the end removes all dead.
  • weapon (pulse) and a new nova weapon both update each tick (§3.7).
  • Sim resolves and holds pulse_element_idx and nova_element_idx (public int), each from its weapon definition’s element field via content.element_index(...) once at _init. _resolve_collisions uses pulse_element_idx; nova.update reads sim.nova_element_idx.

3.7 sim/weapon_nova.gd (NEW) — WeaponNova

Section titled “3.7 sim/weapon_nova.gd (NEW) — WeaponNova”

Second weapon archetype: a periodic AoE pulse centered on the player.

  • _init(def: Dictionary) — reads base_damage, cooldown_s, area (the AoE radius) from the nova weapon definition. element resolved by the Sim.
  • update(sim, dt) — cooldown timer (scaled by player.fire_rate_mult); on fire: sim.hash.rebuild(sim.enemies) then sim.hash.query_circle(player.pos, area, enemies), and for each hit ei: sim._damage_enemy(ei, base_damage × player.damage_mult); if it survives, var ev := Elemental.apply(sim.enemies, ei, sim.nova_element_idx, sim.content) and, if non-empty, sim._reaction_burst(ev.center, ev.magnitude, ev.generic). (Like pulse, damage scales with player.damage_mult; both weapons share the player’s stat multipliers. No removals — the end-of-tick sweep handles deaths.)
  • Pure /sim; rendered by reusing the swarm/visual layer or a simple expanding-ring effect in main (render is out of the sim; see §3.8).
  • Construct both weapons; nova is created from content.weapon("nova"). Both auto-fire from run start (no weapon-acquisition meta this slice).
  • A minimal visual for nova’s pulse and for reaction bursts (e.g. a short-lived expanding Polygon2D/ring at the burst center) so the player sees them. Render-only, reads sim events; kept out of /sim. Aura/stack state may also tint enemies later — not required this slice (a nova ring + burst flash is enough to make it readable).

3.9 Data edits (tools/design-bible/src/seed.js → re-export data/bible.json)

Section titled “3.9 Data edits (tools/design-bible/src/seed.js → re-export data/bible.json)”

Two tuning edits, applied in seed.js then re-exported via node tools/design-bible/scripts/export-seed.mjs > data/bible.json (the Cycle-3 pipeline):

  1. Reverse Plasma cell: add rx('lightning', 'fire', 'Plasma', 'burst', 45) so the headline reaction fires in both application orders (both weapons auto-fire, so both orders occur).
  2. Shock amp magnitude: change lightning’s creation to el('lightning', 'Lightning', '#ffe34d', 'shock', 0.15) so shock-as-vulnerability is +15%/stack (max +90% at 6 stacks), not the DoT-tuned 2.

fire.status_base stays 2 (burn = 2 HP/s/stack). These edits are validated by the existing loader (ContentLoader.validate) at boot.


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

  • test_enemy_pool.gdadd initializes element columns to empty; remove_at swap-moves element columns in lockstep with base columns (set distinct element/stacks on two enemies, remove the first, assert the survivor’s element state followed its base data).
  • test_elemental.gd — apply on no-aura sets aura+1 stack+decay; same-element reinforces (stacks rise to stacks_max and cap, decay refreshes); different-element triggers the reaction path and replaces the aura; decay reduces the timer and clears at zero. Uses a small hand-built ContentDB.
  • test_status_effects.gddot_per_second = status_base × stacks for burn, 0 for a non-dot; vuln_multiplier = 1 + status_base × stacks for shock, 1.0 for a non-vuln; unmapped status is inert.
  • test_reactions_in_sim.gd — with the real bible.json: an enemy given a fire aura then hit with lightning takes Plasma burst damage to itself AND nearby enemies (place a cluster, assert neighbor HP dropped); an unauthored pair takes the smaller generic burst.
  • test_weapon_nova.gd — nova fires after its cooldown, damages all enemies within area, applies fire (aura set) to survivors, and ignores enemies outside area.
  • test_shock_vulnerability.gd — a shocked enemy takes amplified weapon damage vs an unshocked one (same base hit, different post-damage HP); the hit that applies shock is NOT self-amplified (damage-before-apply order).
  • test_determinism.gd (UNCHANGED assertions) — must still pass: same seed → identical trace, with all elemental logic active. Strengthen snapshot_string only if needed to include an aggregate (e.g. total enemies with an aura) so the trace actually exercises element state; if changed, keep it deterministic and keep the existing assertions.
  • Test-count guard: after the full run, confirm the suite count rose by the new files (the stale-class-cache trap); confirm test_determinism and the new tests actually executed.
  • GUT push_error rule (project gotcha): any new push_error on a “can’t happen” branch either stays silent (upstream-guarded) or is consumed in its test with assert_push_error.

Boot smoke (godot --headless --quit-after 180) shows no SCRIPT ERROR with elements active. Feel (burn ticking, shock-then-nuke, Plasma bursts) verified by playtest.


  1. Two weapons (pulse = lightning, nova = fire) auto-fire from run start; nova is an AoE pulse.
  2. Hitting an enemy applies the weapon’s element: builds an aura + stacks (to stacks_max), which decays over time.
  3. burn ticks DoT (status_base × stacks HP/s); shock makes enemies take amplified weapon damage.
  4. A different element on an existing aura triggers a reaction; lightning+fire (either order) fires Plasma (AoE burst); unauthored pairs fire the generic fallback.
  5. All values come from ContentDB/bible.json (magnitudes, decay, stacks_max, reaction base/scale); the two seed.js edits are re-exported and load-validated.
  6. The sim stays pure /sim and deterministic; the determinism property test passes unchanged.
  7. The EnemyPool keeps element columns consistent through swap-remove (tested).
  8. Full GUT suite passes; test count increased by the new files.

  • Multi-simultaneous auras (an enemy burning AND chilled at once) — single-active-aura only.
  • Status kinds beyond DoT + vulnerability (chill/freeze slow, knockback, mark, etc.) — later.
  • Reaction effect kinds beyond burst + generic fallback (shatter/cc/pull/spread/special) — they only matter once their element pairs can occur in-game.
  • Reaction cascades (a reaction applying a new element that itself reacts) — bursts deal damage only, no element application.
  • The other weapons (orbit/beam/turret) and other enemy elements/resists — later content slices.
  • Weapon acquisition via level-up, elemental mods/upgrades, evolutions — later.
  • Enemy element resistance (enemies[].resist) — the schema has it; not consumed this slice.
  • Per-enemy aura tint/VFX polish — a minimal nova ring + burst flash only.

  • Swap-remove desync is the top risk — mitigated by EnemyPool owning the columns + a lockstep test.
  • Deferred-removal interaction: reaction bursts kill enemies during collision resolution; the kills must join the existing deferred set and respect the rebuild-once / remove-descending order so the hash never goes stale. Centralizing kills in _damage_enemy (recording, not immediately removing) is how this stays correct.
  • Determinism: no RNG in the elemental path; iteration over pools/hash-query results is index-ordered. If a reaction ever needs randomness later, it must draw from rng (sim stream), never wall-clock.
  • Balance: Plasma base_magnitude 45 and burn 2/stack are strong vs hp 3 swarmers — intended for a visible headline; real balance is a tool-tuning pass later, and now fully data-driven so it needs no code change.
  • Data/schema: the two seed.js edits must be re-exported (not hand-edited into bible.json) to keep the file genuine exporter output (Cycle-3 rule).
  • Nested hash query during a hit loop: a reaction burst calls hash.query_circle while a weapon (_resolve_collisions / nova.update) is still iterating its own query_circle result. This is safe today because SpatialHash.query_circle returns a fresh array per call (no removals occur mid-phase either). If the M2-backlog perf pass adds query-result-array pooling, nested bursts would corrupt the outer iteration — at that point bursts must snapshot their hits or be deferred to after the loop. Flag this in SpatialHash when pooling is introduced.