Bullet Heaven — Elemental Engine (Spec)
Bullet Heaven — Elemental Engine (Spec)
Section titled “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)
1. Purpose
Section titled “1. Purpose”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.
2. Architecture & the load-bearing rules
Section titled “2. Architecture & the load-bearing rules”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. Components
Section titled “3. Components”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 tocap.add(p, v, r, d) -> int—var i := super.add(p, v, r, d); ifi != -1, initializeaura_element[i] = -1,stacks[i] = 0,aura_remaining[i] = 0.0. Returnsi.remove_at(i)— move the three element columns fromlast = count - 1intoi(wheni != last) before callingsuper.remove_at(i)(which swaps base columns and decrementscount). Order matters: the column swap uses the pre-decrementcount.
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 auraelement_idx,stacks = 1,aura_remaining = aura_decay_s(from the element’s data). Return{}. - Same element (
aura_element[i] == element_idx): reinforce —stacks = min(stacks + 1, stacks_max), refreshaura_remaining = aura_decay_s. Return{}. - Different element: react — resolve the reaction for
(current aura element id, applied element id)viaContentDB.reaction(...). Computemagnitudefrom the current stacks:base_magnitude × per_stack_scale^stacksfor an authoredburstreaction, elseGENERIC_REACTION_MAGNITUDE(a found-but-non-burst reaction or no reaction →generic = true). Then consume + replace: set the aura to the newly-applied element withstacks = 1,aura_remaining = aura_decay_s. Return{ center = pool.pos[i], magnitude, generic }. (All authored MVP reactions areconsumes_aura: true; replace-with-applied is the single deterministic rule this slice implements.)
- No aura (
static func decay(pool: EnemyPool, i: int, dt: float) -> void— ifaura_element[i] != -1:aura_remaining[i] -= dt; at<= 0, clear (aura_element[i] = -1,stacks[i] = 0,aura_remaining[i] = 0.0).Elementalowns only aura bookkeeping + reaction resolution. Per-tick status math lives inStatusEffects(§3.4); the spatial burst lives inSim(§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) -> Dictionaryfunc element_index(id: String) -> int— index in theelementsarray document order, or-1.func element_count() -> intfunc reaction(aura_id: String, applied_id: String) -> Dictionary— finds thereactionsentry whoseaura==aura_id andapplied==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) -> boolstatic func dot_per_second(status, status_base, stacks) -> float—status_base × stacksifis_dot, else0.0.static func vuln_multiplier(status, status_base, stacks) -> float—1.0 + status_base × stacksifis_vuln, else1.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— querieshash.query_circle(center, radius, enemies)(radius =GENERIC_REACTION_RADIUSifgenericelseREACTION_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_collisionsandnova.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
burstmagnitude comes from the reaction data (base_magnitude × per_stack_scale^stacks); the generic fallback uses the fixedGENERIC_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).
3.6 Sim changes (sim/sim.gd)
Section titled “3.6 Sim changes (sim/sim.gd)”enemiesbecomes anEnemyPool(wasEntityPool).- 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— multiplyamountby the enemy’s shock vulnerability (StatusEffects.vuln_multiplierfrom its current aura status + stacks, viacontent), thenenemies.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: iterateifromcount - 1down to0; for any enemy withdata[i] <= 0.0,gems.add(pos[i], Vector2.ZERO, GEM_RADIUS, _gem_xp),kills += 1,enemies.remove_at(i)(theEnemyPoolswap-remove moves element columns in lockstep). Descending order keeps swap-remove indices valid.- This replaces M1’s inline gem/kill + per-
_resolve_collisionsdeferred-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), callvar ev := Elemental.apply(enemies, ei, pulse_element_idx, content)(pulse’s element, for projectile hits in_resolve_collisions) and, ifevis 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
dotaura,_damage_enemy(ei, StatusEffects.dot_per_second(status, status_base, stacks) * dt); thenElemental.decay(enemies, ei, dt). No removal here — the sweep handles deaths. - Tick order (
tick(input)):player.integrate→run_time += dt→_spawn_enemies→_move_enemies→weapon.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 newnovaweapon both update each tick (§3.7).Simresolves and holdspulse_element_idxandnova_element_idx(publicint), each from its weapon definition’selementfield viacontent.element_index(...)once at_init._resolve_collisionsusespulse_element_idx;nova.updatereadssim.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)— readsbase_damage,cooldown_s,area(the AoE radius) from the nova weapon definition.elementresolved by the Sim.update(sim, dt)— cooldown timer (scaled byplayer.fire_rate_mult); on fire:sim.hash.rebuild(sim.enemies)thensim.hash.query_circle(player.pos, area, enemies), and for each hitei: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 withplayer.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 inmain(render is out of the sim; see §3.8).
3.8 main.gd + render (thin)
Section titled “3.8 main.gd + render (thin)”- 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):
- 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). - 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-tuned2.
fire.status_base stays 2 (burn = 2 HP/s/stack). These edits are validated by the existing loader (ContentLoader.validate) at boot.
4. Testing
Section titled “4. Testing”TDD throughout (GUT, headless). New / updated:
test_enemy_pool.gd—addinitializes element columns to empty;remove_atswap-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 tostacks_maxand cap, decay refreshes); different-element triggers the reaction path and replaces the aura;decayreduces the timer and clears at zero. Uses a small hand-builtContentDB.test_status_effects.gd—dot_per_second=status_base × stacksfor burn,0for a non-dot;vuln_multiplier=1 + status_base × stacksfor shock,1.0for a non-vuln; unmapped status is inert.test_reactions_in_sim.gd— with the realbible.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 withinarea, applies fire (aura set) to survivors, and ignores enemies outsidearea.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. Strengthensnapshot_stringonly 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_determinismand the new tests actually executed. - GUT push_error rule (project gotcha): any new
push_erroron a “can’t happen” branch either stays silent (upstream-guarded) or is consumed in its test withassert_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.
5. Success criteria
Section titled “5. Success criteria”- Two weapons (pulse = lightning, nova = fire) auto-fire from run start; nova is an AoE pulse.
- Hitting an enemy applies the weapon’s element: builds an aura + stacks (to
stacks_max), which decays over time. - burn ticks DoT (
status_base × stacksHP/s); shock makes enemies take amplified weapon damage. - A different element on an existing aura triggers a reaction; lightning+fire (either order) fires Plasma (AoE burst); unauthored pairs fire the generic fallback.
- All values come from
ContentDB/bible.json(magnitudes, decay, stacks_max, reaction base/scale); the twoseed.jsedits are re-exported and load-validated. - The sim stays pure
/simand deterministic; the determinism property test passes unchanged. - The
EnemyPoolkeeps element columns consistent through swap-remove (tested). - Full GUT suite passes; test count increased by the new files.
6. Out of scope (explicit)
Section titled “6. Out of scope (explicit)”- 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.
7. Risks & notes
Section titled “7. Risks & notes”- Swap-remove desync is the top risk — mitigated by
EnemyPoolowning 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 45and burn2/stackare strong vshp 3swarmers — 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.jsedits must be re-exported (not hand-edited intobible.json) to keep the file genuine exporter output (Cycle-3 rule). - Nested hash query during a hit loop: a reaction burst calls
hash.query_circlewhile a weapon (_resolve_collisions/nova.update) is still iterating its ownquery_circleresult. This is safe today becauseSpatialHash.query_circlereturns 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 inSpatialHashwhen pooling is introduced.