Skip to content

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.

  • /sim purity: every sim/ file extends RefCounted (or is a base-less class_name for 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. Elemental has NO Sim dependency (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.gd is 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 EnemyPool element columns (aura_element, stacks, aura_remaining) MUST move together with the base columns in add/remove_at. This is the top correctness hazard; it has a dedicated test.
  • Deferred death sweep: _damage_enemy only 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.js edits are re-exported via node tools/design-bible/scripts/export-seed.mjs > data/bible.json (never hand-edited into bible.json).
  • GUT push_error rule (project gotcha): a test fails if an un-asserted push_error fires. Keep “can’t happen” branches silent (upstream-guarded) or consume the error with assert_push_error.
  • Verify test COUNT, not just “all passed” (stale-class-cache trap); if a new class_name test seems dropped, run godot --headless --path . --import then 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; reaction aura, applied, effect, base_magnitude, per_stack_scale; nova weapon base_damage, cooldown_s, area, element.
  • 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" (expect 0)

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.json where reactions contains both fire→lightning and lightning→fire Plasma cells, and lightning.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):

Terminal window
node tools/design-bible/scripts/export-seed.mjs > data/bible.json
  • Step 4: Verify the edits landed

Run:

Terminal window
python3 -c "
import json
d=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, lt
rx=[(r['aura'],r['applied']) for r in d['reactions'] if r['name']=='Plasma']
assert ('fire','lightning') in rx and ('lightning','fire') in rx, rx
print('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
Terminal window
git add tools/design-bible/src/seed.js data/bible.json
git 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 EntityPool with public columns aura_element: PackedInt32Array (-1 = none), stacks: PackedInt32Array, aura_remaining: PackedFloat32Array; add initializes them empty; remove_at swap-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 (EnemyPool not 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 EnemyPool
extends 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: PackedInt32Array
var stacks: PackedInt32Array
var 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

Terminal window
git add sim/enemy_pool.gd tests/test_enemy_pool.gd
git 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) -> bool
    • StatusEffects.dot_per_second(status: String, status_base: float, stacks: int) -> float
    • StatusEffects.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 (StatusEffects not 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

Terminal window
git add sim/status_effects.gd tests/test_status_effects.gd
git 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 (element etc. 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

Terminal window
git add sim/content_db.gd tests/test_content_db.gd
git 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 }. magnitude is meaningful only when generic == 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)=lightning
func _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 (Elemental not 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

Terminal window
git add sim/elemental.gd tests/test_elemental.gd
git 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), ContentDB element/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); constants REACTION_BURST_RADIUS, GENERIC_REACTION_RADIUS, GENERIC_REACTION_MAGNITUDE.

  • Step 1: Update test_collision_damage.gd for 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.0
const GENERIC_REACTION_RADIUS: float = 70.0
const GENERIC_REACTION_MAGNITUDE: float = 8.0

(b) Change the enemies declaration and add the pulse element index. Replace var enemies: EntityPool with:

var enemies: EnemyPool

and 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 -gexit and -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
Terminal window
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, resolve nova_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 / WeaponNova not present).

  • Step 3: Implement WeaponNova

Create sim/weapon_nova.gd:

class_name WeaponNova
extends 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: float
var cooldown: float
var area: float
var _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: WeaponNova
var 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, insert nova.update(self, dt) immediately after weapon.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
Terminal window
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

Terminal window
git add sim/sim.gd main.gd
git 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
Terminal window
git add CLAUDE.md
git commit -m "docs: document the elemental engine (cycle 4) + mark done"
  • Step 4: Final whole-suite + boot confirmation
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . --quit-after 240 2>&1 | grep -ci "SCRIPT ERROR"

Expected: suite exit 0; second prints 0.


1. Spec coverage (against 2026-06-22-elemental-engine-design.md):

  • §3.1 EnemyPool lockstep columns → Task 2. ✓
  • §3.2 Elemental apply/decay/reaction-event → Task 5. ✓
  • §3.3 ContentDB element/reaction getters → Task 4. ✓
  • §3.4 StatusEffects dot/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.js edits + 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:

  • EnemyPool columns aura_element/stacks/aura_remaining — same names in T2, T5, T6, T7. ✓
  • Elemental.apply(pool, i, element_idx, content) -> Dictionary with {center, magnitude, generic} — consumed identically in T6 _resolve_collisions and 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) reads base_damage/cooldown_s/area; nova bible def has all three. ✓
  • snapshot_string adds a=%d — only test_determinism consumes it (property test, no hardcoded golden). ✓

No issues found.

Plan complete and saved to docs/superpowers/plans/2026-06-22-elemental-engine.md.