Elemental Engine Implementation Plan
Elemental Engine Implementation Plan
Section titled “Elemental Engine Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the in-game elemental system — weapons apply elements to enemies (auras + stacks), driving status effects (burn DoT, shock vulnerability), with a different element triggering a reaction (Plasma burst); plus a second weapon (fire nova) so reactions fire live.
Architecture: Per-enemy single-active-aura state lives on a new EnemyPool (columns swap-remove in lockstep). Elemental is a pure state machine (apply/reinforce/react/decay) returning a reaction event; StatusEffects maps element status names to mechanisms; the Sim owns a centralized _damage_enemy (applies shock vulnerability) and a single end-of-tick _sweep_dead, so nova/burn/reaction-burst phases never remove enemies mid-query. All values come from ContentDB/bible.json. Pure /sim, deterministic.
Tech Stack: Godot 4.6.3 / typed GDScript, GUT 9.6.0 (headless), the Cycle-3 ContentDB data pipeline, the design-bible JSON.
Global Constraints
Section titled “Global Constraints”/simpurity: everysim/fileextends RefCounted(or is a base-lessclass_namefor static-only helpers) and uses NO Node/render/Input/Engine/Time/File/JSON APIs. New sim files:enemy_pool.gd,elemental.gd,status_effects.gd,weapon_nova.gd.Elementalhas NOSimdependency (returns a reaction event; the Sim applies the burst).- Determinism: all new logic ticks on constant
Sim_Const.DT, iterates pools/hash-results in index order, draws NO RNG (Plasma is deterministic).tests/test_determinism.gdis a property test (same seed → two identical runs); it MUST stay green — both runs include the new logic identically. Never weaken its assertions. - Lockstep swap-remove: the
EnemyPoolelement columns (aura_element,stacks,aura_remaining) MUST move together with the base columns inadd/remove_at. This is the top correctness hazard; it has a dedicated test. - Deferred death sweep:
_damage_enemyonly subtracts HP (× shock vulnerability); a single_sweep_dead()at tick end drops gems / counts kills / removes. No phase removes enemies mid-tick. - Data is genuine exporter output: the two
seed.jsedits are re-exported vianode tools/design-bible/scripts/export-seed.mjs > data/bible.json(never hand-edited intobible.json). - GUT push_error rule (project gotcha): a test fails if an un-asserted
push_errorfires. Keep “can’t happen” branches silent (upstream-guarded) or consume the error withassert_push_error. - Verify test COUNT, not just “all passed” (stale-class-cache trap); if a new
class_nametest seems dropped, rungodot --headless --path . --importthen re-run. - TDD, DRY, YAGNI, frequent commits.
- Element field mapping (data → engine): element
id,status,status_base,aura_decay_s,stacks_max,per_stack_scale; reactionaura,applied,effect,base_magnitude,per_stack_scale; nova weaponbase_damage,cooldown_s,area,element.
Single-test / full-suite commands
Section titled “Single-test / full-suite commands”- Single file:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit - Full suite:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit - Boot smoke:
godot --headless --path . --quit-after 180 2>&1 | grep -ci "SCRIPT ERROR"(expect0)
File Structure
Section titled “File Structure”New: sim/enemy_pool.gd, sim/elemental.gd, sim/status_effects.gd, sim/weapon_nova.gd; tests test_enemy_pool.gd, test_elemental.gd, test_status_effects.gd, test_weapon_nova.gd, test_reactions_in_sim.gd, test_shock_vulnerability.gd.
Modified: tools/design-bible/src/seed.js + data/bible.json (re-export); sim/content_db.gd (element/reaction getters); sim/sim.gd (EnemyPool, damage/sweep/burst/status/decay, element indices, tick order, nova); main.gd (nova + VFX); tests/test_collision_damage.gd (sweep), tests/test_enemy_chase.gd (nova range); CLAUDE.md.
Task 1: Data edits — reverse Plasma cell + shock amp magnitude
Section titled “Task 1: Data edits — reverse Plasma cell + shock amp magnitude”Files:
- Modify:
tools/design-bible/src/seed.js - Modify (regenerate):
data/bible.json
Interfaces:
-
Produces:
bible.jsonwherereactionscontains bothfire→lightningandlightning→firePlasma cells, andlightning.status_base == 0.15. -
Step 1: Edit the lightning element
In tools/design-bible/src/seed.js, change the lightning row in the elements array from:
el('lightning', 'Lightning', '#ffe34d', 'shock'),to (explicit status_base of 0.15 — shock is a damage-amp, not a DoT):
el('lightning', 'Lightning', '#ffe34d', 'shock', 0.15),- Step 2: Add the reverse Plasma reaction
In the reactions array, immediately after the existing rx('fire', 'lightning', 'Plasma', 'burst', 45), line, add the reverse direction:
rx('lightning', 'fire', 'Plasma', 'burst', 45),- Step 3: Re-export the data file
Run (repo root):
node tools/design-bible/scripts/export-seed.mjs > data/bible.json- Step 4: Verify the edits landed
Run:
python3 -c "import jsond=json.load(open('data/bible.json'))['data']lt=[e for e in d['elements'] if e['id']=='lightning'][0]assert lt['status_base']==0.15, ltrx=[(r['aura'],r['applied']) for r in d['reactions'] if r['name']=='Plasma']assert ('fire','lightning') in rx and ('lightning','fire') in rx, rxprint('lightning.status_base', lt['status_base'], '| Plasma dirs', rx)"Expected: lightning.status_base 0.15 | Plasma dirs [('fire', 'lightning'), ('lightning', 'fire')]
- Step 5: Verify the loader still accepts it
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_loader.gd -gexit
Expected: PASS (the real-file test loads the edited bible without validation errors).
- Step 6: Commit
git add tools/design-bible/src/seed.js data/bible.jsongit commit -m "feat(data): reverse Plasma reaction + lightning shock-amp magnitude"Task 2: EnemyPool — element columns with lockstep swap-remove
Section titled “Task 2: EnemyPool — element columns with lockstep swap-remove”Files:
- Create:
sim/enemy_pool.gd - Test:
tests/test_enemy_pool.gd
Interfaces:
-
Consumes:
EntityPool(existing base —count,pos/vel/radius/data,add,remove_at). -
Produces:
EnemyPool extends EntityPoolwith public columnsaura_element: PackedInt32Array(-1 = none),stacks: PackedInt32Array,aura_remaining: PackedFloat32Array;addinitializes them empty;remove_atswap-moves them in lockstep. -
Step 1: Write the failing test
Create tests/test_enemy_pool.gd:
extends GutTest
func test_add_initializes_empty_aura() -> void: var p := EnemyPool.new(8) var i := p.add(Vector2(1, 2), Vector2.ZERO, 14.0, 3.0) assert_eq(i, 0) assert_eq(p.aura_element[i], -1) assert_eq(p.stacks[i], 0) assert_almost_eq(p.aura_remaining[i], 0.0, 0.0001)
func test_remove_at_moves_element_columns_in_lockstep() -> void: var p := EnemyPool.new(8) var a := p.add(Vector2(0, 0), Vector2.ZERO, 14.0, 3.0) # index 0 var b := p.add(Vector2(100, 0), Vector2.ZERO, 14.0, 5.0) # index 1 # distinct element state on the second enemy p.aura_element[b] = 4 p.stacks[b] = 3 p.aura_remaining[b] = 2.5 # remove the first; the last (b) swaps into slot 0 — base AND element data must follow together p.remove_at(a) assert_eq(p.count, 1) assert_almost_eq(p.data[0], 5.0, 0.0001, "base data of b moved to slot 0") assert_eq(p.aura_element[0], 4, "aura element of b moved with it") assert_eq(p.stacks[0], 3) assert_almost_eq(p.aura_remaining[0], 2.5, 0.0001)
func test_remove_last_no_swap() -> void: var p := EnemyPool.new(8) p.add(Vector2.ZERO, Vector2.ZERO, 14.0, 3.0) p.remove_at(0) assert_eq(p.count, 0)-
Step 2: Run it — expect FAIL (
EnemyPoolnot declared).godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_enemy_pool.gd -gexit -
Step 3: Implement
EnemyPool
Create sim/enemy_pool.gd:
class_name EnemyPoolextends EntityPool
# Adds per-enemy single-active-aura state as parallel columns. They MUST move in# lockstep with the base columns on swap-remove, or element state aliases onto the# wrong enemy. -1 element = no aura.var aura_element: PackedInt32Arrayvar stacks: PackedInt32Arrayvar aura_remaining: PackedFloat32Array
func _init(cap: int) -> void: super._init(cap) aura_element.resize(cap) stacks.resize(cap) aura_remaining.resize(cap)
func add(p: Vector2, v: Vector2, r: float, d: float) -> int: var i := super.add(p, v, r, d) if i != -1: aura_element[i] = -1 stacks[i] = 0 aura_remaining[i] = 0.0 return i
func remove_at(i: int) -> void: var last := count - 1 if i != last: aura_element[i] = aura_element[last] stacks[i] = stacks[last] aura_remaining[i] = aura_remaining[last] super.remove_at(i)-
Step 4: Run it — expect PASS (3/3).
-
Step 5: Commit
git add sim/enemy_pool.gd tests/test_enemy_pool.gdgit commit -m "feat(sim): EnemyPool — single-aura columns with lockstep swap-remove"Task 3: StatusEffects — DoT and vulnerability mechanisms
Section titled “Task 3: StatusEffects — DoT and vulnerability mechanisms”Files:
- Create:
sim/status_effects.gd - Test:
tests/test_status_effects.gd
Interfaces:
-
Produces:
StatusEffects.is_dot(status: String) -> bool/is_vuln(status: String) -> boolStatusEffects.dot_per_second(status: String, status_base: float, stacks: int) -> floatStatusEffects.vuln_multiplier(status: String, status_base: float, stacks: int) -> float
-
Step 1: Write the failing test
Create tests/test_status_effects.gd:
extends GutTest
func test_kinds() -> void: assert_true(StatusEffects.is_dot("burn")) assert_false(StatusEffects.is_vuln("burn")) assert_true(StatusEffects.is_vuln("shock")) assert_false(StatusEffects.is_dot("shock")) assert_false(StatusEffects.is_dot("chill")) # unmapped this slice assert_false(StatusEffects.is_vuln("chill"))
func test_dot_per_second() -> void: assert_almost_eq(StatusEffects.dot_per_second("burn", 2.0, 3), 6.0, 0.0001) assert_almost_eq(StatusEffects.dot_per_second("shock", 2.0, 3), 0.0, 0.0001, "shock is not a dot") assert_almost_eq(StatusEffects.dot_per_second("chill", 2.0, 3), 0.0, 0.0001)
func test_vuln_multiplier() -> void: assert_almost_eq(StatusEffects.vuln_multiplier("shock", 0.15, 2), 1.30, 0.0001) assert_almost_eq(StatusEffects.vuln_multiplier("burn", 0.15, 2), 1.0, 0.0001, "burn does not amp") assert_almost_eq(StatusEffects.vuln_multiplier("chill", 0.15, 2), 1.0, 0.0001)-
Step 2: Run it — expect FAIL (
StatusEffectsnot declared). -
Step 3: Implement
StatusEffects
Create sim/status_effects.gd:
class_name StatusEffects
# Maps an element status name to a mechanism KIND. Data drives magnitude# (status_base, stacks); this table drives mechanism. MVP kinds: dot, vuln.const KIND := { "burn": "dot", "shock": "vuln" }
static func is_dot(status: String) -> bool: return KIND.get(status, "") == "dot"
static func is_vuln(status: String) -> bool: return KIND.get(status, "") == "vuln"
static func dot_per_second(status: String, status_base: float, stacks: int) -> float: return status_base * float(stacks) if is_dot(status) else 0.0
static func vuln_multiplier(status: String, status_base: float, stacks: int) -> float: return 1.0 + status_base * float(stacks) if is_vuln(status) else 1.0-
Step 4: Run it — expect PASS (3/3).
-
Step 5: Commit
git add sim/status_effects.gd tests/test_status_effects.gdgit commit -m "feat(sim): StatusEffects — burn DoT + shock vulnerability mechanisms"Task 4: ContentDB element + reaction getters
Section titled “Task 4: ContentDB element + reaction getters”Files:
- Modify:
sim/content_db.gd - Test:
tests/test_content_db.gd(extend existing)
Interfaces:
-
Consumes: existing
ContentDB._entries,_by_id. -
Produces:
element(id) -> Dictionary,element_at(idx) -> Dictionary,element_index(id) -> int,element_count() -> int,reaction(aura_id, applied_id) -> Dictionary. -
Step 1: Add failing tests
Append to tests/test_content_db.gd:
func _element_data() -> Dictionary: return { "elements": [ {"id": "fire", "name": "Fire", "status": "burn"}, {"id": "lightning", "name": "Lightning", "status": "shock"}, ], "reactions": [ {"id": "fire-lightning", "aura": "fire", "applied": "lightning", "name": "Plasma", "effect": "burst", "base_magnitude": 45, "per_stack_scale": 1.15}, ], }
func test_element_getters() -> void: var db := ContentDB.new(_element_data()) assert_eq(db.element("lightning")["status"], "shock") assert_eq(db.element_index("fire"), 0) assert_eq(db.element_index("lightning"), 1) assert_eq(db.element_index("nope"), -1) assert_eq(db.element_at(1)["id"], "lightning") assert_eq(db.element_at(99), {}, "out-of-range index is empty") assert_eq(db.element_count(), 2)
func test_reaction_getter() -> void: var db := ContentDB.new(_element_data()) assert_eq(db.reaction("fire", "lightning")["name"], "Plasma") assert_eq(db.reaction("lightning", "fire"), {}, "unauthored pair is empty")-
Step 2: Run it — expect FAIL (
elementetc. not declared). -
Step 3: Implement the getters
In sim/content_db.gd, after the existing upgrade(id) method, add:
func element(id: String) -> Dictionary: return _by_id("elements", id)
func element_at(idx: int) -> Dictionary: var arr := _entries("elements") if idx < 0 or idx >= arr.size(): return {} return arr[idx]
func element_index(id: String) -> int: var arr := _entries("elements") for k in range(arr.size()): if arr[k] is Dictionary and arr[k].get("id", "") == id: return k return -1
func element_count() -> int: return _entries("elements").size()
func reaction(aura_id: String, applied_id: String) -> Dictionary: for r in _entries("reactions"): if r is Dictionary and r.get("aura", "") == aura_id and r.get("applied", "") == applied_id: return r return {}-
Step 4: Run it — expect PASS.
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexit -
Step 5: Commit
git add sim/content_db.gd tests/test_content_db.gdgit commit -m "feat(sim): ContentDB element + reaction getters"Task 5: Elemental — aura/stack/reaction state machine
Section titled “Task 5: Elemental — aura/stack/reaction state machine”Files:
- Create:
sim/elemental.gd - Test:
tests/test_elemental.gd
Interfaces:
-
Consumes:
EnemyPool(Task 2),ContentDB.element_at/reaction(Task 4). -
Produces:
Elemental.apply(pool: EnemyPool, i: int, element_idx: int, content: ContentDB) -> Dictionary— mutates the enemy’s aura columns; returns{}(no reaction) or{ "center": Vector2, "magnitude": float, "generic": bool }.magnitudeis meaningful only whengeneric == false(the Sim supplies the generic magnitude).Elemental.decay(pool: EnemyPool, i: int, dt: float) -> void
-
Step 1: Write the failing test
Create tests/test_elemental.gd:
extends GutTest
# element_at(0)=fire(decay 4, max 6, scale 1.15), element_at(1)=lightningfunc _content() -> ContentDB: return ContentDB.new({ "elements": [ {"id": "fire", "name": "Fire", "status": "burn", "status_base": 2, "aura_decay_s": 4, "stacks_max": 6, "per_stack_scale": 1.15}, {"id": "lightning", "name": "Lightning", "status": "shock", "status_base": 0.15, "aura_decay_s": 4, "stacks_max": 6, "per_stack_scale": 1.15}, ], "reactions": [ {"id": "fl", "aura": "fire", "applied": "lightning", "name": "Plasma", "effect": "burst", "base_magnitude": 45, "per_stack_scale": 1.15}, ], })
func _enemy() -> EnemyPool: var p := EnemyPool.new(4) p.add(Vector2(7, 0), Vector2.ZERO, 14.0, 3.0) return p
func test_apply_to_no_aura_sets_aura() -> void: var c := _content(); var p := _enemy() var ev := Elemental.apply(p, 0, 0, c) # fire assert_eq(ev, {}) assert_eq(p.aura_element[0], 0) assert_eq(p.stacks[0], 1) assert_almost_eq(p.aura_remaining[0], 4.0, 0.0001)
func test_same_element_reinforces_and_caps() -> void: var c := _content(); var p := _enemy() for n in range(10): Elemental.apply(p, 0, 0, c) # fire x10 assert_eq(p.aura_element[0], 0) assert_eq(p.stacks[0], 6, "capped at stacks_max") assert_almost_eq(p.aura_remaining[0], 4.0, 0.0001, "decay refreshed")
func test_different_element_reacts_and_replaces() -> void: var c := _content(); var p := _enemy() Elemental.apply(p, 0, 0, c) # fire, 1 stack Elemental.apply(p, 0, 0, c) # fire, 2 stacks var ev := Elemental.apply(p, 0, 1, c) # lightning onto fire(2 stacks) -> Plasma assert_false(ev.is_empty()) assert_false(ev["generic"]) assert_almost_eq(ev["magnitude"], 45.0 * pow(1.15, 2), 0.001, "base * scale^stacks at reaction time") assert_eq(ev["center"], Vector2(7, 0)) assert_eq(p.aura_element[0], 1, "aura replaced by the applied element") assert_eq(p.stacks[0], 1)
func test_unauthored_pair_is_generic() -> void: var c := _content(); var p := _enemy() Elemental.apply(p, 0, 1, c) # lightning aura var ev := Elemental.apply(p, 0, 0, c) # fire onto lightning -> no authored cell assert_true(ev["generic"]) assert_eq(p.aura_element[0], 0, "still replaced by applied element")
func test_decay_clears_at_zero() -> void: var c := _content(); var p := _enemy() Elemental.apply(p, 0, 0, c) Elemental.decay(p, 0, 1.0) assert_almost_eq(p.aura_remaining[0], 3.0, 0.0001) Elemental.decay(p, 0, 5.0) # past zero assert_eq(p.aura_element[0], -1, "aura cleared") assert_eq(p.stacks[0], 0)-
Step 2: Run it — expect FAIL (
Elementalnot declared). -
Step 3: Implement
Elemental
Create sim/elemental.gd:
class_name Elemental
# Pure aura/stack/reaction state machine over an EnemyPool index. No Sim/Engine# dependency: a reaction returns an event and the Sim applies the spatial burst.
static func apply(pool: EnemyPool, i: int, element_idx: int, content: ContentDB) -> Dictionary: var cur := pool.aura_element[i] var el := content.element_at(element_idx) var decay := float(el.get("aura_decay_s", 0.0)) if cur == -1: pool.aura_element[i] = element_idx pool.stacks[i] = 1 pool.aura_remaining[i] = decay return {} if cur == element_idx: var smax := int(el.get("stacks_max", 1)) pool.stacks[i] = mini(pool.stacks[i] + 1, smax) pool.aura_remaining[i] = decay return {} # Different element: react against the current aura, then replace. var aura_id: String = content.element_at(cur).get("id", "") var applied_id: String = el.get("id", "") var rx := content.reaction(aura_id, applied_id) var ev := {"center": pool.pos[i], "magnitude": 0.0, "generic": true} if not rx.is_empty() and rx.get("effect", "") == "burst": var base := float(rx.get("base_magnitude", 0.0)) var scale := float(rx.get("per_stack_scale", 1.0)) ev["magnitude"] = base * pow(scale, float(pool.stacks[i])) ev["generic"] = false # consume + replace with the applied element pool.aura_element[i] = element_idx pool.stacks[i] = 1 pool.aura_remaining[i] = decay return ev
static func decay(pool: EnemyPool, i: int, dt: float) -> void: if pool.aura_element[i] == -1: return pool.aura_remaining[i] -= dt if pool.aura_remaining[i] <= 0.0: pool.aura_element[i] = -1 pool.stacks[i] = 0 pool.aura_remaining[i] = 0.0-
Step 4: Run it — expect PASS (5/5).
-
Step 5: Commit
git add sim/elemental.gd tests/test_elemental.gdgit commit -m "feat(sim): Elemental — aura/stack/reaction state machine"Task 6: Sim elemental integration (EnemyPool, damage path, sweep, statuses, reactions — no nova yet)
Section titled “Task 6: Sim elemental integration (EnemyPool, damage path, sweep, statuses, reactions — no nova yet)”Wires the engine into the Sim: enemies become an EnemyPool; a centralized _damage_enemy applies shock vulnerability; a single _sweep_dead handles all deaths; pulse hits apply lightning and can trigger reaction bursts; burn DoT + decay run each tick. Nova is added in Task 7. The determinism property test must stay green.
Files:
- Modify:
sim/sim.gd - Modify:
tests/test_collision_damage.gd(death moved to sweep) - Create:
tests/test_reactions_in_sim.gd,tests/test_shock_vulnerability.gd
Interfaces:
-
Consumes:
EnemyPool(T2),StatusEffects(T3),ContentDBelement/reaction getters (T4),Elemental(T5). -
Produces:
Sim.enemies: EnemyPool;Sim.pulse_element_idx: int;Sim._damage_enemy(ei, amount);Sim._sweep_dead();Sim._reaction_burst(center, magnitude, generic);Sim._apply_status_and_decay(dt); constantsREACTION_BURST_RADIUS,GENERIC_REACTION_RADIUS,GENERIC_REACTION_MAGNITUDE. -
Step 1: Update
test_collision_damage.gdfor the deferred sweep
Death now happens in _sweep_dead, not _resolve_collisions. In tests/test_collision_damage.gd, add sim._sweep_dead() immediately after the sim._resolve_collisions() call in the two kill-asserting tests — test_projectile_damages_and_kills_enemy_dropping_gem and test_two_projectiles_kill_two_enemies_no_misattribution. (The other two tests — test_projectile_damages_without_killing, test_miss_leaves_everything — are unchanged.) Example for the first:
sim._resolve_collisions() sim._sweep_dead() assert_eq(sim.enemies.count, 0, "enemy killed")- Step 2: Write the new failing tests
Create tests/test_shock_vulnerability.gd:
extends GutTest
# A shocked enemy takes amplified weapon damage; the hit that APPLIES shock is not self-amped.func test_shocked_enemy_takes_more_damage() -> void: var sim := Sim.new(1, SimContentFixture.db()) var li := sim.content.element_index("lightning") var unshocked := sim.enemies.add(Vector2(0, 0), Vector2.ZERO, 14.0, 100.0) var shocked := sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 100.0) # give the second enemy a lightning (shock) aura with 2 stacks sim.enemies.aura_element[shocked] = li sim.enemies.stacks[shocked] = 2 sim._damage_enemy(unshocked, 10.0) sim._damage_enemy(shocked, 10.0) # unshocked: 100 - 10 = 90 ; shocked: 100 - 10*(1 + 0.15*2)=100-13 = 87 assert_almost_eq(sim.enemies.data[unshocked], 90.0, 0.001) assert_almost_eq(sim.enemies.data[shocked], 87.0, 0.001)
func test_apply_then_damage_order_is_not_self_amped() -> void: # A fresh enemy hit once by pulse should take base damage (shock applied AFTER the hit). var sim := Sim.new(1, SimContentFixture.db()) sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 100.0) sim.proj_damage = 10.0 sim.projectiles.add(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0) sim._resolve_collisions() assert_almost_eq(sim.enemies.data[0], 90.0, 0.001, "first hit is base damage, not amplified") assert_eq(sim.enemies.aura_element[0], sim.content.element_index("lightning"), "shock applied after damage")Create tests/test_reactions_in_sim.gd:
extends GutTest
# A fire-aura'd enemy hit by lightning (pulse) takes a Plasma burst that also hits neighbors.func test_plasma_burst_damages_cluster() -> void: var sim := Sim.new(1, SimContentFixture.db()) var fire := sim.content.element_index("fire") # target enemy with a fire aura (2 stacks), high HP so the burst is observable var t := sim.enemies.add(Vector2(0, 0), Vector2.ZERO, 14.0, 1000.0) sim.enemies.aura_element[t] = fire sim.enemies.stacks[t] = 2 # a neighbor within burst radius, and a far enemy outside it var near := sim.enemies.add(Vector2(40, 0), Vector2.ZERO, 14.0, 1000.0) var far := sim.enemies.add(Vector2(2000, 0), Vector2.ZERO, 14.0, 1000.0) # pulse projectile (lightning) hits the target -> reaction sim.proj_damage = 1.0 sim.projectiles.add(Vector2(0, 0), Vector2.ZERO, 6.0, 1.0) sim._resolve_collisions() assert_lt(sim.enemies.data[near], 1000.0, "neighbor took Plasma burst damage") assert_almost_eq(sim.enemies.data[far], 1000.0, 0.001, "far enemy untouched by burst")-
Step 3: Run them — expect FAIL (
_damage_enemy,_sweep_dead, etc. not present / EnemyPool columns absent). -
Step 4: Refactor
sim/sim.gd
(a) Add the reaction constants after the existing GEM_RADIUS const:
const REACTION_BURST_RADIUS: float = 120.0const GENERIC_REACTION_RADIUS: float = 70.0const GENERIC_REACTION_MAGNITUDE: float = 8.0(b) Change the enemies declaration and add the pulse element index. Replace var enemies: EntityPool with:
var enemies: EnemyPooland add (near the other content-derived vars):
var pulse_element_idx: int(c) In _init, change the enemies allocation and resolve the pulse element index. Replace enemies = EntityPool.new(ENEMY_CAP) with enemies = EnemyPool.new(ENEMY_CAP), and after weapon = WeaponPulse.new(content.weapon("pulse")) add:
pulse_element_idx = content.element_index(content.weapon("pulse").get("element", ""))(d) Replace the whole _resolve_collisions function with (damage via _damage_enemy, apply element to survivors, no removal):
func _resolve_collisions() -> void: hash.rebuild(enemies) var pi := projectiles.count - 1 while pi >= 0: var ppos := projectiles.pos[pi] var hits := hash.query_circle(ppos, projectiles.radius[pi] + enemy_radius, enemies) if hits.size() > 0: var ei: int = hits[0] projectiles.remove_at(pi) _damage_enemy(ei, proj_damage) if enemies.data[ei] > 0.0: var ev := Elemental.apply(enemies, ei, pulse_element_idx, content) if not ev.is_empty(): _reaction_burst(ev["center"], ev["magnitude"], ev["generic"]) pi -= 1(e) Add the new methods (place after _resolve_collisions):
func _damage_enemy(ei: int, amount: float) -> void: enemies.data[ei] -= amount * _vuln_mult(ei)
func _vuln_mult(ei: int) -> float: var el := enemies.aura_element[ei] if el == -1: return 1.0 var e := content.element_at(el) return StatusEffects.vuln_multiplier(e.get("status", ""), float(e.get("status_base", 0.0)), enemies.stacks[ei])
func _reaction_burst(center: Vector2, magnitude: float, generic: bool) -> void: var radius := GENERIC_REACTION_RADIUS if generic else REACTION_BURST_RADIUS var amount := GENERIC_REACTION_MAGNITUDE if generic else magnitude var hits := hash.query_circle(center, radius, enemies) for ei in hits: _damage_enemy(ei, amount)
func _apply_status_and_decay(dt: float) -> void: for i in range(enemies.count): var el := enemies.aura_element[i] if el != -1: var e := content.element_at(el) var dps := StatusEffects.dot_per_second(e.get("status", ""), float(e.get("status_base", 0.0)), enemies.stacks[i]) if dps > 0.0: _damage_enemy(i, dps * dt) Elemental.decay(enemies, i, dt)
func _sweep_dead() -> void: var i := enemies.count - 1 while i >= 0: if enemies.data[i] <= 0.0: gems.add(enemies.pos[i], Vector2.ZERO, GEM_RADIUS, _gem_xp) kills += 1 enemies.remove_at(i) i -= 1(f) Update the tick order. Replace the body of tick (after weapon.update(self, dt)) so it reads:
weapon.update(self, dt) _move_projectiles(dt) _resolve_collisions() _apply_status_and_decay(dt) _sweep_dead() _collect_gems() _check_player_hit(dt)(Nova is inserted in Task 7.)
(g) Add an aura count to snapshot_string so the determinism trace exercises element state. Change it to:
func snapshot_string() -> String: var auras := 0 for i in range(enemies.count): if enemies.aura_element[i] != -1: auras += 1 return "t=%d p=(%.3f,%.3f) hp=%.3f e=%d a=%d pr=%d g=%d k=%d xp=%.3f lv=%d" % [ int(round(run_time / Sim_Const.DT)), player.pos.x, player.pos.y, player.hp, enemies.count, auras, projectiles.count, gems.count, kills, player.xp, player.level, ]-
Step 5: Run the new tests — expect PASS.
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_shock_vulnerability.gd -gexitand-gtest=res://tests/test_reactions_in_sim.gd. -
Step 6: Run the FULL suite — fix any behavior-assertion fallout
Run the full suite. Expected to pass after Step 1’s _sweep_dead updates. The determinism property test (test_determinism.gd) must pass unchanged — both runs include the elemental logic. If any OTHER test that runs full ticks fails on a shifted exact value (elemental DoT/shock change kill timing), the correct fix is to isolate what the test means to assert (call the specific sub-method, or place enemies away from the affected area) — NOT to weaken determinism or delete coverage. No nova exists yet, so no AoE-range fallout in this task.
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
- Step 7: Commit
git add sim/sim.gd tests/git commit -m "feat(sim): elemental integration — EnemyPool, shock-amp damage path, deferred sweep, burn DoT, Plasma bursts"Task 7: WeaponNova — the second weapon (reactions fire live)
Section titled “Task 7: WeaponNova — the second weapon (reactions fire live)”Files:
- Create:
sim/weapon_nova.gd - Test:
tests/test_weapon_nova.gd - Modify:
sim/sim.gd(construct + tick nova, resolvenova_element_idx) - Modify:
tests/test_enemy_chase.gd(place the enemy outside nova range)
Interfaces:
-
Consumes:
Sim._damage_enemy,Sim._reaction_burst,Sim.nova_element_idx,Sim.hash,Sim.enemies,Elemental.apply. -
Produces:
WeaponNova.new(def: Dictionary);WeaponNova.update(sim: Sim, dt: float);Sim.nova: WeaponNova;Sim.nova_element_idx: int. -
Step 1: Write the failing test
Create tests/test_weapon_nova.gd:
extends GutTest
func test_nova_hits_in_radius_applies_fire_and_spares_far() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.player.pos = Vector2.ZERO var fire := sim.content.element_index("fire") var near := sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 100.0) # inside area (180) var far := sim.enemies.add(Vector2(500, 0), Vector2.ZERO, 14.0, 100.0) # outside # big dt forces a fire (cooldown 2s); nova base_damage 3 sim.nova.update(sim, 10.0) assert_lt(sim.enemies.data[near], 100.0, "near enemy damaged by nova") assert_eq(sim.enemies.aura_element[near], fire, "near enemy gains a fire aura") assert_almost_eq(sim.enemies.data[far], 100.0, 0.001, "far enemy untouched") assert_eq(sim.enemies.aura_element[far], -1)
func test_nova_does_not_fire_before_cooldown() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 100.0) sim.nova.update(sim, 10.0) # first fire var hp_after_first := sim.enemies.data[0] sim.nova.update(sim, 0.001) # immediately after — still cooling down assert_almost_eq(sim.enemies.data[0], hp_after_first, 0.001, "no second hit before cooldown")-
Step 2: Run it — expect FAIL (
sim.nova/WeaponNovanot present). -
Step 3: Implement
WeaponNova
Create sim/weapon_nova.gd:
class_name WeaponNovaextends RefCounted
# Periodic AoE pulse around the player: damages + applies its element to every# enemy within `area`. Pure /sim; uses the Sim's hash + damage path.var base_damage: floatvar cooldown: floatvar area: floatvar _timer: float = 0.0
func _init(def: Dictionary) -> void: base_damage = float(def["base_damage"]) cooldown = float(def["cooldown_s"]) area = float(def["area"])
func update(sim: Sim, dt: float) -> void: _timer -= dt if _timer > 0.0: return _timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01) sim.hash.rebuild(sim.enemies) var hits := sim.hash.query_circle(sim.player.pos, area, sim.enemies) var dmg := base_damage * sim.player.damage_mult for ei in hits: sim._damage_enemy(ei, dmg) if sim.enemies.data[ei] > 0.0: var ev := Elemental.apply(sim.enemies, ei, sim.nova_element_idx, sim.content) if not ev.is_empty(): sim._reaction_burst(ev["center"], ev["magnitude"], ev["generic"])- Step 4: Wire nova into the Sim
In sim/sim.gd:
- Add fields near
weapon/pulse_element_idx:
var nova: WeaponNovavar nova_element_idx: int- In
_init, after the pulse element index line, add:
nova = WeaponNova.new(content.weapon("nova")) nova_element_idx = content.element_index(content.weapon("nova").get("element", ""))- In
tick, insertnova.update(self, dt)immediately afterweapon.update(self, dt):
weapon.update(self, dt) nova.update(self, dt) _move_projectiles(dt)- Step 5: Fix nova-range fallout in
test_enemy_chase.gd
Nova (area 180) now one-shots a 3-HP enemy placed at distance 100 within a full tick, removing it and breaking the chase assertion. Move the enemy outside nova range. In tests/test_enemy_chase.gd, change the add position from Vector2(100, 0) to Vector2(400, 0) (outside area 180; it still moves measurably closer in one tick):
var idx := sim.enemies.add(Vector2(400, 0), Vector2.ZERO, 14.0, 3.0)- Step 6: Run the nova test, then the FULL suite
-gtest=res://tests/test_weapon_nova.gd → PASS. Then the full suite → exit 0. The determinism property test stays green (nova is deterministic). Fix any further full-tick test that now fails because an enemy sits inside nova’s radius by relocating that enemy outside area (180), per the Task-6 Step-6 principle. Verify the test COUNT rose by the new files.
- Step 7: Commit
git add sim/weapon_nova.gd sim/sim.gd tests/git commit -m "feat(sim): WeaponNova (fire AoE) — both weapons fire, Plasma reactions live"Task 8: Render — nova pulse + reaction burst visuals
Section titled “Task 8: Render — nova pulse + reaction burst visuals”Files:
- Modify:
main.gd
Interfaces:
-
Consumes: the Sim’s nova firing + reaction bursts (render reads sim state / events; no sim change).
-
Step 1: Expose burst events for rendering
The render layer needs to know where bursts/nova pulses happened this tick. In sim/sim.gd, record render-only events (cleared each tick; reading them does not affect the sim). Add a field var fx_bursts: Array[Vector2] = [] and, at the top of tick (after the game_over guard), fx_bursts.clear(). In _reaction_burst, append the center: fx_bursts.append(center). (This is a render hint, not sim state — it is not in snapshot_string and never read by sim logic, so determinism is unaffected.)
Add the field near the other vars:
var fx_bursts: Array[Vector2] = []In tick, after var dt := Sim_Const.DT:
fx_bursts.clear()In _reaction_burst, as the first line:
fx_bursts.append(center)- Step 2: Render the burst flashes + a nova ring in
main.gd
In main.gd _process, after the existing sync calls, draw a short-lived flash at each burst center and a faint ring at the player for nova. Keep it simple — spawn a Polygon2D/Node2D flash that fades, or (simplest, no lifetime tracking) draw via a Node2D with queue_redraw. Minimal approach using a dedicated overlay node added in _new_run:
# in _new_run(), after gem_renderer setup: fx_layer = Node2D.new() add_child(fx_layer)Add the field var fx_layer: Node2D at the top. Then in _process, after hud.update_hud(sim):
for c in fx_layer.get_children(): var f := c as _Flash if f != null and f.tick_fade(): c.queue_free() for center in sim.fx_bursts: var flash := _Flash.new() flash.position = center fx_layer.add_child(flash)And define a tiny inner flash node at the bottom of main.gd:
class _Flash extends Node2D: var _life := 0.18 func _ready() -> void: var poly := Polygon2D.new() var pts := PackedVector2Array() for k in range(12): var a := TAU * float(k) / 12.0 pts.append(Vector2(cos(a), sin(a)) * 90.0) poly.polygon = pts poly.color = Color(1.0, 0.55, 0.2, 0.5) add_child(poly) func tick_fade() -> bool: _life -= 1.0 / 60.0 modulate.a = maxf(_life / 0.18, 0.0) return _life <= 0.0(If a cleaner expanding-ring is preferred, that is fine — the requirement is only that bursts are visibly flagged and nova reads as an area pulse. Render details are not unit-tested.)
- Step 3: Boot smoke
godot --headless --path . --quit-after 240 2>&1 | grep -ci "SCRIPT ERROR" → 0.
-
Step 4: Full suite still green (render change must not regress sim tests).
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit→ exit 0. -
Step 5: Commit
git add sim/sim.gd main.gdgit commit -m "feat(game): nova + reaction-burst visuals (render-only fx hints)"Task 9: Documentation + final verification
Section titled “Task 9: Documentation + final verification”Files:
-
Modify:
CLAUDE.md -
Step 1: Full suite + boot, record totals
Run the full suite (exit 0; note totals — the new test files should raise the count by ~17) and the boot smoke (0). Confirm test_determinism and the new element/nova/reaction tests all executed (not silently dropped).
- Step 2: Update
CLAUDE.md
Under the “Godot 4.6 gotchas” or a new “Elemental engine (M2 cycle 4)” subsection, add:
## Elemental engine (M2 cycle 4, DONE)In-game auras/stacks/reactions, data-driven from `bible.json`. Pipeline:- **Per-enemy single-active-aura** lives on `sim/enemy_pool.gd` (`EnemyPool extends EntityPool`): columns `aura_element` (-1=none), `stacks`, `aura_remaining`, which **swap-remove in lockstep** with the base columns. Adding columns to the swarm = subclass the pool + override `add`/`remove_at`; a side-car array that doesn't swap together silently aliases element state onto the wrong enemy.- **`sim/elemental.gd`** (`Elemental`): pure apply/reinforce/react/decay state machine. A *different* element on an existing aura **reacts** (returns a reaction event; the Sim turns it into a burst) then replaces the aura. No `Sim` dependency.- **`sim/status_effects.gd`** (`StatusEffects`): maps an element `status` name to a mechanism — `burn`→DoT, `shock`→damage-vulnerability. Data drives magnitude (`status_base × stacks`), the table drives mechanism. `status_base` is interpreted per kind (DoT = HP/s/stack; vuln = fractional/stack — that's why `lightning.status_base` is 0.15, not the DoT-tuned 2).- **Deferred death sweep:** `Sim._damage_enemy(ei, amount)` only subtracts HP (× shock vuln); a single `Sim._sweep_dead()` at tick end drops gems / counts kills / removes. ALL damage sources (pulse collision, nova AoE, Plasma burst, burn DoT) route through `_damage_enemy`, so no phase removes an enemy mid-query and the hash never goes stale. This replaced M1's inline collision-death.- **Reactions** realized as `Sim._reaction_burst` (AoE via the spatial hash). `REACTION_BURST_RADIUS`/`GENERIC_*` are Sim constants (not in the reaction schema). Plasma (lightning↔fire, both directions authored in `seed.js`) is the headline; unauthored pairs use the generic fallback.- **Two weapons** auto-fire from start: `WeaponPulse` (lightning projectile) + `sim/weapon_nova.gd` (`WeaponNova`, fire AoE pulse). Element ids resolve to indices once at `Sim._init` (`pulse_element_idx`/`nova_element_idx`).- **Determinism:** no RNG in the elemental path; `test_determinism` stays a property test (same seed → identical, now including an aura count in `snapshot_string`).- **Nested-query caveat:** a reaction burst queries the hash while a weapon iterates its own `query_circle` result — safe only because `query_circle` returns a fresh array. If result-array pooling is added (perf backlog), bursts must snapshot/defer.- **Tick order:** spawn → move enemies → pulse → nova → move projectiles → resolve collisions → status+decay → sweep dead → collect gems → player hit. Every hash-querying phase rebuilds first; one sweep removes all dead.Also add a done line at the top of the “Milestone 2 backlog” section:
- ✅ **Elemental engine (cycle 4) — DONE.** Auras/stacks/reactions in-game (single-active-aura), burn DoT + shock vulnerability, Plasma reaction, nova weapon; see "Elemental engine" above. Next: multi-aura, more status/reaction kinds, more weapons/elements, enemy resists.- Step 3: Commit
git add CLAUDE.mdgit commit -m "docs: document the elemental engine (cycle 4) + mark done"- Step 4: Final whole-suite + boot confirmation
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --headless --path . --quit-after 240 2>&1 | grep -ci "SCRIPT ERROR"Expected: suite exit 0; second prints 0.
Self-Review
Section titled “Self-Review”1. Spec coverage (against 2026-06-22-elemental-engine-design.md):
- §3.1
EnemyPoollockstep columns → Task 2. ✓ - §3.2
Elementalapply/decay/reaction-event → Task 5. ✓ - §3.3
ContentDBelement/reaction getters → Task 4. ✓ - §3.4
StatusEffectsdot/vuln → Task 3. ✓ - §3.5
_reaction_burst(authored + generic) → Task 6. ✓ - §3.6 Sim: EnemyPool,
_damage_enemy+vuln,_sweep_dead, element-apply-on-hit, burn DoT + decay, tick order, element indices → Task 6 (+ nova half in Task 7). ✓ - §3.7
WeaponNova→ Task 7. ✓ - §3.8 main + render (nova ring + burst flash) → Task 8. ✓
- §3.9 two
seed.jsedits + re-export → Task 1. ✓ - §4 tests (enemy_pool, elemental, status_effects, reactions_in_sim, weapon_nova, shock_vuln, determinism unchanged, count guard) → Tasks 2–9. ✓
- §5 success criteria → two weapons (T7), aura+stacks+decay (T5/T6), burn DoT + shock vuln (T6), reactions incl. Plasma both directions (T1/T6), data-driven (all), determinism property (T6/T7), EnemyPool lockstep (T2), suite+count (T9). ✓
- §6 out-of-scope respected (single-aura, only dot+vuln, only burst+fallback, no cascade, no other weapons/resists). ✓
- §7 risks addressed (lockstep test T2, deferred-sweep T6, nested-query caveat documented T9). ✓
2. Placeholder scan: No TBD/TODO/“handle edge cases”/“similar to”. Render details in Task 8 give concrete code with an explicit “cleaner alternative is fine” note (render is not unit-tested). ✓
3. Type consistency:
EnemyPoolcolumnsaura_element/stacks/aura_remaining— same names in T2, T5, T6, T7. ✓Elemental.apply(pool, i, element_idx, content) -> Dictionarywith{center, magnitude, generic}— consumed identically in T6_resolve_collisionsand T7 nova. ✓StatusEffects.dot_per_second/vuln_multiplier(status, status_base, stacks)— consistent in T3, T6. ✓ContentDB.element_at/element_index/reaction— defined T4, used T5/T6/T7. ✓Sim._damage_enemy/_reaction_burst/_sweep_dead/_apply_status_and_decay,pulse_element_idx/nova_element_idx— defined T6/T7, used consistently. ✓WeaponNova.new(def)readsbase_damage/cooldown_s/area; nova bible def has all three. ✓snapshot_stringaddsa=%d— onlytest_determinismconsumes it (property test, no hardcoded golden). ✓
No issues found.
Execution Handoff
Section titled “Execution Handoff”Plan complete and saved to docs/superpowers/plans/2026-06-22-elemental-engine.md.