Skip to content

Enemy Variety & Weapons 3–5 Implementation Plan

Enemy Variety & Weapons 3–5 Implementation Plan

Section titled “Enemy Variety & Weapons 3–5 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: Add all four remaining enemy types (tank, shooter, splitter, elite) and three remaining weapons (orbit, beam, turret) from the Design Bible, making all seven live in-game.

Architecture: Replace Sim’s uniform enemy scalars with per-enemy columns in EnemyPool; add ProjPool extends EntityPool to carry per-projectile damage + element; wire three new weapon classes into Sim’s tick; add a shooter-projectile pool for enemy fire. All new code lives in /sim (pure RefCounted, no Node/Engine APIs).

Tech Stack: Godot 4.6.3, GDScript (typed), GUT 9.6.0 headless tests.

  • All /sim files extend RefCounted. No Node, Input, Engine, Time, OS, or RandomNumberGenerator APIs. No push_error in /sim (use silent no-op instead).
  • /ui and /render files may extend Node. They may READ sim state but MUST NOT mutate it.
  • GUT 9.6 treats any un-handled push_error as a test failure. Consume expected errors with assert_push_error_count(n) or assert_push_error("substr").
  • Test runner command (headless, all tests): godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
  • Single-test command: same as above but add -gtest=res://tests/<file>.gd
  • Commit after each task. Conventional prefix: feat: for new behaviour, refactor: for structural migrations.
  • bible.json is the committed content contract. data/bible.json must be re-exported via node tools/design-bible/scripts/export-seed.mjs > data/bible.json whenever seed.js changes.
  • Determinism property must be preserved: same seed → identical tick trace. Baseline checksums WILL change (more enemy types); update them in Task 11.

Task 1: ProjPool — per-projectile damage and element

Section titled “Task 1: ProjPool — per-projectile damage and element”

Files:

  • Create: sim/proj_pool.gd
  • Create: tests/test_proj_pool.gd

Interfaces:

  • Produces: class_name ProjPool extends EntityPool with add(p, v, r, lifetime, dmg, el_idx=-1) -> int, damage: PackedFloat32Array, element_idx: PackedInt32Array. Used by Tasks 4–7.

  • Step 1: Write the failing tests

Create tests/test_proj_pool.gd:

extends GutTest
func test_add_stores_damage_and_element() -> void:
var p := ProjPool.new(8)
var i := p.add(Vector2(1, 2), Vector2(100, 0), 6.0, 1.5, 4.2, 3)
assert_eq(i, 0)
assert_almost_eq(p.damage[i], 4.2, 0.0001)
assert_eq(p.element_idx[i], 3)
func test_add_default_element_minus_one() -> void:
var p := ProjPool.new(8)
var i := p.add(Vector2.ZERO, Vector2.ZERO, 6.0, 1.5, 1.0)
assert_eq(p.element_idx[i], -1)
func test_remove_at_moves_damage_in_lockstep() -> void:
var p := ProjPool.new(8)
p.add(Vector2.ZERO, Vector2.ZERO, 6.0, 1.5, 1.0, 0)
p.add(Vector2(100, 0), Vector2.ZERO, 6.0, 1.5, 7.5, 2)
p.remove_at(0)
assert_eq(p.count, 1)
assert_almost_eq(p.damage[0], 7.5, 0.0001, "second proj's damage moved to slot 0")
assert_eq(p.element_idx[0], 2, "second proj's element moved to slot 0")
func test_remove_last_no_swap() -> void:
var p := ProjPool.new(8)
p.add(Vector2.ZERO, Vector2.ZERO, 6.0, 1.5, 3.0, 1)
p.remove_at(0)
assert_eq(p.count, 0)
  • Step 2: Run tests — expect FAIL (class not found)
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_proj_pool.gd -gexit 2>&1 | tail -10

Expected: FAIL / script error (ProjPool undefined).

  • Step 3: Create sim/proj_pool.gd
class_name ProjPool
extends EntityPool
var damage: PackedFloat32Array
var element_idx: PackedInt32Array
func _init(cap: int) -> void:
super._init(cap)
damage.resize(cap)
element_idx.resize(cap)
func add(p: Vector2, v: Vector2, r: float, lifetime: float, dmg: float, el: int = -1) -> int:
var i := super.add(p, v, r, lifetime)
if i != -1:
damage[i] = dmg
element_idx[i] = el
return i
func remove_at(i: int) -> void:
if i < 0 or i >= count: return
var last := count - 1
if i != last:
damage[i] = damage[last]
element_idx[i] = element_idx[last]
super.remove_at(i)
  • Step 4: Run tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_proj_pool.gd -gexit 2>&1 | tail -10

Expected: 4/4 passed.

  • Step 5: Commit
Terminal window
git add sim/proj_pool.gd tests/test_proj_pool.gd
git commit -m "feat: ProjPool — per-projectile damage and element columns"

Task 2: EnemyPool — per-enemy stat columns

Section titled “Task 2: EnemyPool — per-enemy stat columns”

Files:

  • Modify: sim/enemy_pool.gd
  • Modify: tests/test_enemy_pool.gd

Interfaces:

  • Produces: EnemyPool with TYPE_* int constants (0–4), new columns armor, speed, contact_dmg, xp_val (PackedFloat32Array), type_id (PackedInt32Array); updated add(p, v, r, d, armor_v, speed_v, contact_v, xp_v, tid) with defaults; remove_at swaps all in lockstep. Used by Tasks 3 and 4.

  • Step 1: Add failing tests to tests/test_enemy_pool.gd

Append to the file:

func test_add_initializes_new_columns_to_defaults() -> void:
var p := EnemyPool.new(8)
var i := p.add(Vector2(1, 2), Vector2.ZERO, 14.0, 3.0)
assert_almost_eq(p.armor[i], 0.0, 0.0001)
assert_almost_eq(p.speed[i], 70.0, 0.0001)
assert_almost_eq(p.contact_dmg[i], 12.0, 0.0001)
assert_almost_eq(p.xp_val[i], 1.0, 0.0001)
assert_eq(p.type_id[i], EnemyPool.TYPE_SWARMER)
func test_add_accepts_explicit_stats() -> void:
var p := EnemyPool.new(8)
var i := p.add(Vector2.ZERO, Vector2.ZERO, 22.0, 30.0, 4.0, 40.0, 18.0, 4.0, EnemyPool.TYPE_TANK)
assert_almost_eq(p.armor[i], 4.0, 0.0001)
assert_almost_eq(p.speed[i], 40.0, 0.0001)
assert_almost_eq(p.contact_dmg[i], 18.0, 0.0001)
assert_almost_eq(p.xp_val[i], 4.0, 0.0001)
assert_eq(p.type_id[i], EnemyPool.TYPE_TANK)
func test_remove_at_moves_new_columns_in_lockstep() -> void:
var p := EnemyPool.new(8)
p.add(Vector2.ZERO, Vector2.ZERO, 14.0, 3.0, 0.0, 70.0, 12.0, 1.0, EnemyPool.TYPE_SWARMER)
p.add(Vector2(50, 0), Vector2.ZERO, 22.0, 30.0, 4.0, 40.0, 18.0, 4.0, EnemyPool.TYPE_TANK)
p.remove_at(0)
assert_eq(p.count, 1)
assert_almost_eq(p.armor[0], 4.0, 0.0001)
assert_almost_eq(p.speed[0], 40.0, 0.0001)
assert_eq(p.type_id[0], EnemyPool.TYPE_TANK)
func test_type_constants_are_unique() -> void:
var vals := [EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_TANK, EnemyPool.TYPE_SHOOTER,
EnemyPool.TYPE_SPLITTER, EnemyPool.TYPE_ELITE]
assert_eq(vals.size(), 5)
for i in range(vals.size()):
for j in range(vals.size()):
if i != j:
assert_ne(vals[i], vals[j], "TYPE constants must be unique")
  • Step 2: Run tests — expect FAIL
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_enemy_pool.gd -gexit 2>&1 | tail -10

Expected: new tests fail (columns/constants don’t exist yet).

  • Step 3: Replace sim/enemy_pool.gd with updated version
class_name EnemyPool
extends EntityPool
const TYPE_SWARMER := 0
const TYPE_TANK := 1
const TYPE_SHOOTER := 2
const TYPE_SPLITTER := 3
const TYPE_ELITE := 4
# Aura columns (existing)
var aura_element: PackedInt32Array
var stacks: PackedInt32Array
var aura_remaining: PackedFloat32Array
# Per-enemy stat columns (new)
var armor: PackedFloat32Array
var speed: PackedFloat32Array
var contact_dmg: PackedFloat32Array
var xp_val: PackedFloat32Array
var type_id: PackedInt32Array
func _init(cap: int) -> void:
super._init(cap)
aura_element.resize(cap)
stacks.resize(cap)
aura_remaining.resize(cap)
armor.resize(cap)
speed.resize(cap)
contact_dmg.resize(cap)
xp_val.resize(cap)
type_id.resize(cap)
func add(p: Vector2, v: Vector2, r: float, d: float,
armor_v: float = 0.0, speed_v: float = 70.0,
contact_v: float = 12.0, xp_v: float = 1.0,
tid: int = TYPE_SWARMER) -> int:
var i := super.add(p, v, r, d)
if i != -1:
aura_element[i] = -1
stacks[i] = 0
aura_remaining[i] = 0.0
armor[i] = armor_v
speed[i] = speed_v
contact_dmg[i] = contact_v
xp_val[i] = xp_v
type_id[i] = tid
return i
func remove_at(i: int) -> void:
if i < 0 or i >= count: return
var last := count - 1
if i != last:
aura_element[i] = aura_element[last]
stacks[i] = stacks[last]
aura_remaining[i] = aura_remaining[last]
armor[i] = armor[last]
speed[i] = speed[last]
contact_dmg[i] = contact_dmg[last]
xp_val[i] = xp_val[last]
type_id[i] = type_id[last]
super.remove_at(i)
  • Step 4: Run ALL tests — expect PASS (all existing + new)
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15

Expected: all tests pass (old add callers with 4 args still work via defaults).

  • Step 5: Commit
Terminal window
git add sim/enemy_pool.gd tests/test_enemy_pool.gd
git commit -m "feat: EnemyPool — per-enemy stat columns and TYPE constants"

Task 3: SpawnDirector — multi-type selection

Section titled “Task 3: SpawnDirector — multi-type selection”

Files:

  • Modify: sim/spawn_director.gd
  • Modify: tests/test_spawn_director.gd

Interfaces:

  • Produces: SpawnDirector.pick_type(run_time: float, rng: SeededRng) -> int returning an EnemyPool.TYPE_* constant. Used by Task 4.

  • Consumes: EnemyPool.TYPE_* constants (Task 2), SeededRng.randf().

  • Step 1: Add failing tests to tests/test_spawn_director.gd

Append:

func test_pick_type_early_returns_swarmer() -> void:
var d := SpawnDirector.new()
var rng := SeededRng.new(1)
# t < 30 → always swarmer regardless of rng
for i in range(20):
assert_eq(d.pick_type(0.0, rng), EnemyPool.TYPE_SWARMER)
func test_pick_type_mid_can_return_tank() -> void:
var d := SpawnDirector.new()
# With many rolls at t=60 we should see at least one tank (20% chance)
var found_tank := false
for seed in range(50):
var rng := SeededRng.new(seed)
if d.pick_type(60.0, rng) == EnemyPool.TYPE_TANK:
found_tank = true
break
assert_true(found_tank, "should get at least one tank in 50 rolls at t=60")
func test_pick_type_late_can_return_all_types() -> void:
var d := SpawnDirector.new()
var seen := {}
for seed in range(200):
var rng := SeededRng.new(seed)
seen[d.pick_type(200.0, rng)] = true
assert_true(seen.has(EnemyPool.TYPE_SWARMER))
assert_true(seen.has(EnemyPool.TYPE_TANK))
assert_true(seen.has(EnemyPool.TYPE_ELITE))
assert_true(seen.has(EnemyPool.TYPE_SPLITTER))
assert_true(seen.has(EnemyPool.TYPE_SHOOTER))
  • Step 2: Run tests — expect FAIL
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_spawn_director.gd -gexit 2>&1 | tail -10

Expected: new tests fail (method doesn’t exist yet).

  • Step 3: Add pick_type to sim/spawn_director.gd

Append to the existing class (keep the existing rate_at and spawn_count_for methods):

func pick_type(run_time: float, rng: SeededRng) -> int:
var r := rng.randf()
if run_time < 30.0:
return EnemyPool.TYPE_SWARMER
elif run_time < 90.0:
return EnemyPool.TYPE_TANK if r < 0.2 else EnemyPool.TYPE_SWARMER
elif run_time < 180.0:
if r < 0.2: return EnemyPool.TYPE_TANK
elif r < 0.4: return EnemyPool.TYPE_ELITE
else: return EnemyPool.TYPE_SWARMER
else:
if r < 0.20: return EnemyPool.TYPE_TANK
elif r < 0.40: return EnemyPool.TYPE_ELITE
elif r < 0.55: return EnemyPool.TYPE_SPLITTER
elif r < 0.65: return EnemyPool.TYPE_SHOOTER
else: return EnemyPool.TYPE_SWARMER
  • Step 4: Run tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_spawn_director.gd -gexit 2>&1 | tail -10

Expected: all spawn_director tests pass.

  • Step 5: Commit
Terminal window
git add sim/spawn_director.gd tests/test_spawn_director.gd
git commit -m "feat: SpawnDirector.pick_type — threshold-based multi-enemy selection"

Task 4: Sim core migration — per-enemy stats, armor, ProjPool, splitter

Section titled “Task 4: Sim core migration — per-enemy stats, armor, ProjPool, splitter”

Files:

  • Modify: sim/sim.gd (large change — per-enemy columns, armor, ProjPool, splitter split, multi-type spawn)
  • Modify: sim/weapon_pulse.gd (use ProjPool.add 6-arg form)
  • Modify: tests/test_collision_damage.gd (remove proj_damage refs; pass dmg to ProjPool.add)
  • Modify: tests/test_fx_events.gd (remove enemy_radius ref)
  • Modify: tests/test_reactions_in_sim.gd (remove proj_damage ref)
  • Modify: tests/test_mods_in_sim.gd (remove proj_damage ref)
  • Modify: tests/test_shock_vulnerability.gd (remove proj_damage ref)
  • Modify: tests/test_elemental_coverage.gd (remove proj_damage ref)

Interfaces:

  • Consumes: ProjPool (Task 1), EnemyPool with TYPE_* and per-enemy columns (Task 2), SpawnDirector.pick_type (Task 3).

  • Produces: Sim.projectiles: ProjPool, Sim._enemy_types: Array[Dictionary] (internal), Sim._damage_enemy with armor reduction. Removes Sim.proj_damage and Sim.enemy_radius as public fields.

  • Step 1: Update sim/weapon_pulse.gd to use ProjPool 6-arg add

Replace the update function:

func update(sim: Sim, dt: float) -> void:
_timer -= dt
if _timer > 0.0:
return
var target := nearest_enemy_index(sim)
if target == -1:
return
var dir := (sim.enemies.pos[target] - sim.player.pos).normalized()
var dmg := base_damage * sim.player.damage_mult
sim.projectiles.add_proj(sim.player.pos, dir * proj_speed, proj_radius, proj_lifetime,
dmg, sim.pulse_element_idx)
_timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01)

(Removes the sim.proj_damage = ... line; damage is now stored per-projectile.)

  • Step 2: Replace sim/sim.gd

Write the complete new sim/sim.gd. Key changes from the original:

  • projectiles: ProjPool (was EntityPool)
  • Remove proj_damage, enemy_radius, _enemy_hp, _enemy_speed, _contact_dps, _gem_xp
  • Add var _enemy_types: Array[Dictionary] = []
  • _init: build _enemy_types, change projectiles = ProjPool.new(...)
  • _spawn_enemies: call spawner.pick_type, look up per-enemy stats
  • _move_enemies: use enemies.speed[i]
  • _resolve_collisions: use per-enemy radius + per-projectile damage/element
  • _damage_enemy: apply armor
  • _sweep_dead: handle splitter, use enemies.xp_val[i]
  • _check_player_hit: per-enemy radius + per-enemy contact_dmg
  • state_checksum: unchanged structure (still works because enemy columns stay deterministic)
class_name Sim
extends RefCounted
const ENEMY_CAP: int = 6000
const PROJ_CAP: int = 4000
const GEM_CAP: int = 4000
const HASH_CELL: float = 64.0
const SPAWN_RING: float = 1100.0
const GEM_RADIUS: float = 8.0
const REACTION_BURST_RADIUS: float = 120.0
const GENERIC_REACTION_RADIUS: float = 70.0
const GENERIC_REACTION_MAGNITUDE: float = 8.0
const MAX_ENEMY_RADIUS: float = 26.0 # elite radius — used for broad-phase proj collision
var rng: SeededRng
var upgrade_rng: SeededRng
var player: PlayerState
var enemies: EnemyPool
var projectiles: ProjPool
var gems: EntityPool
var hash: SpatialHash
var run_time: float = 0.0
var kills: int = 0
var game_over: bool = false
var spawner: SpawnDirector
var weapon: WeaponPulse
var nova: WeaponNova
var nova_element_idx: int
var pulse_element_idx: int
var _spawn_accum: float = 0.0
var pending_levelups: int = 0
var fx_events: Array[Dictionary] = []
var mods: ModState
var content: ContentDB
var _enemy_types: Array[Dictionary] = []
func _init(seed_value: int, content_db: ContentDB) -> void:
rng = SeededRng.new(seed_value)
upgrade_rng = SeededRng.new(seed_value + 2654435769)
player = PlayerState.new()
enemies = EnemyPool.new(ENEMY_CAP)
projectiles = ProjPool.new(PROJ_CAP)
gems = EntityPool.new(GEM_CAP)
hash = SpatialHash.new(HASH_CELL)
spawner = SpawnDirector.new()
content = content_db
mods = ModState.new()
weapon = WeaponPulse.new(content.weapon("pulse"))
pulse_element_idx = content.element_index(content.weapon("pulse").get("element", ""))
nova = WeaponNova.new(content.weapon("nova"))
nova_element_idx = content.element_index(content.weapon("nova").get("element", ""))
_build_enemy_types()
func _build_enemy_types() -> void:
_enemy_types.resize(5)
_enemy_types[EnemyPool.TYPE_SWARMER] = content.enemy("swarmer")
_enemy_types[EnemyPool.TYPE_TANK] = content.enemy("tank")
_enemy_types[EnemyPool.TYPE_SHOOTER] = content.enemy("shooter")
_enemy_types[EnemyPool.TYPE_SPLITTER] = content.enemy("splitter")
_enemy_types[EnemyPool.TYPE_ELITE] = content.enemy("elite")
func tick(input: InputState) -> void:
if game_over:
return
var dt := Sim_Const.DT
fx_events.clear()
player.integrate(input, dt)
run_time += dt
_spawn_enemies(dt)
_move_enemies(dt)
weapon.update(self, dt)
nova.update(self, dt)
_move_projectiles(dt)
_resolve_collisions()
_apply_status_and_decay(dt)
_sweep_dead()
_collect_gems()
_check_player_hit(dt)
func _spawn_enemies(dt: float) -> void:
var r := spawner.spawn_count_for(run_time, dt, _spawn_accum)
_spawn_accum = r["accum"]
for _i in range(r["to_spawn"]):
var spawn_pos := player.pos + rng.rand_unit_dir() * SPAWN_RING
var tid := spawner.pick_type(run_time, rng)
var e: Dictionary = _enemy_types[tid]
enemies.add(
spawn_pos, Vector2.ZERO,
float(e.get("radius", 14)),
float(e["hp"]),
float(e.get("armor", 0.0)),
float(e["speed"]),
float(e["contact_damage"]),
float(e["xp_value"]),
tid
)
func _move_enemies(dt: float) -> void:
for i in range(enemies.count):
var dir := (player.pos - enemies.pos[i])
var d := dir.length()
if d > 0.001:
enemies.pos[i] += dir / d * enemies.speed[i] * dt
func _move_projectiles(dt: float) -> void:
var i := projectiles.count - 1
while i >= 0:
projectiles.pos[i] += projectiles.vel[i] * dt
projectiles.data[i] -= dt
if projectiles.data[i] <= 0.0:
projectiles.remove_at(i)
i -= 1
func _resolve_collisions() -> void:
hash.rebuild(enemies)
var pi := projectiles.count - 1
while pi >= 0:
var ppos := projectiles.pos[pi]
var pr := projectiles.radius[pi]
var candidates := hash.query_circle(ppos, pr + MAX_ENEMY_RADIUS, enemies)
var hit_ei := -1
for c in candidates:
var hit_r := pr + enemies.radius[c]
if ppos.distance_squared_to(enemies.pos[c]) <= hit_r * hit_r:
hit_ei = c
break
if hit_ei != -1:
var dmg: float = projectiles.damage[pi]
var el: int = projectiles.element_idx[pi]
projectiles.remove_at(pi)
_damage_enemy(hit_ei, dmg)
if el != -1 and enemies.data[hit_ei] > 0.0:
var ev := Elemental.apply(enemies, hit_ei, el, content, mods)
if not ev.is_empty():
_reaction_burst(ev["center"], ev["magnitude"], ev["generic"], el)
pi -= 1
func _damage_enemy(ei: int, amount: float) -> void:
var armor := enemies.armor[ei]
var effective := maxf(amount - armor, amount * 0.1)
enemies.data[ei] -= effective * _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, element_idx: int) -> void:
fx_events.append({"kind": "reaction", "pos": center, "element": element_idx})
var radius := GENERIC_REACTION_RADIUS if generic else REACTION_BURST_RADIUS
var amount := (GENERIC_REACTION_MAGNITUDE if generic else magnitude) * mods.reaction_damage_mult
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:
fx_events.append({"kind": "death", "pos": enemies.pos[i], "element": enemies.aura_element[i]})
var dead_pos := enemies.pos[i]
var dead_type := enemies.type_id[i]
var dead_xp := enemies.xp_val[i]
gems.add(dead_pos, Vector2.ZERO, GEM_RADIUS, dead_xp)
kills += 1
enemies.remove_at(i)
if dead_type == EnemyPool.TYPE_SPLITTER:
var sw: Dictionary = _enemy_types[EnemyPool.TYPE_SWARMER]
for off in [Vector2(20.0, 0.0), Vector2(-20.0, 0.0)]:
enemies.add(dead_pos + off, Vector2.ZERO,
float(sw.get("radius", 14)),
float(sw["hp"]),
0.0, float(sw["speed"]),
float(sw["contact_damage"]),
float(sw["xp_value"]),
EnemyPool.TYPE_SWARMER)
i -= 1
func _collect_gems() -> void:
var pr2 := player.pickup_radius * player.pickup_radius
var i := gems.count - 1
while i >= 0:
if player.pos.distance_squared_to(gems.pos[i]) <= pr2:
fx_events.append({"kind": "pickup", "pos": gems.pos[i], "element": -1})
player.xp += gems.data[i]
gems.remove_at(i)
i -= 1
while player.xp >= player.xp_to_next:
player.xp -= player.xp_to_next
player.level += 1
player.xp_to_next *= 1.35
pending_levelups += 1
func _check_player_hit(dt: float) -> void:
var total_dps := 0.0
for i in range(enemies.count):
var reach := player.radius + enemies.radius[i]
if player.pos.distance_squared_to(enemies.pos[i]) <= reach * reach:
total_dps += enemies.contact_dmg[i]
if total_dps > 0.0:
player.hp -= total_dps * dt
if player.hp <= 0.0:
player.hp = 0.0
game_over = true
func roll_upgrade_choices(n: int) -> Array[String]:
return Upgrades.roll_choices(upgrade_rng, content, n)
func apply_upgrade(id: String) -> void:
Upgrades.apply(id, content, player, mods)
pending_levelups = maxi(pending_levelups - 1, 0)
func state_checksum() -> int:
var parts: Array = []
parts.append(player.pos)
parts.append(player.hp)
parts.append(player.xp)
parts.append(player.level)
for i in range(enemies.count):
parts.append(enemies.pos[i])
parts.append(enemies.data[i])
parts.append(enemies.aura_element[i])
parts.append(enemies.stacks[i])
parts.append(enemies.aura_remaining[i])
for i in range(projectiles.count):
parts.append(projectiles.pos[i])
parts.append(projectiles.vel[i])
for i in range(gems.count):
parts.append(gems.pos[i])
return hash(parts)
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 3: Update affected test files

tests/test_collision_damage.gd — replace all 4 test functions:

extends GutTest
func test_projectile_damages_and_kills_enemy_dropping_gem() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 1.0)
sim.projectiles.add_proj(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0, 1.0)
sim._resolve_collisions()
sim._sweep_dead()
assert_eq(sim.enemies.count, 0, "enemy killed")
assert_eq(sim.projectiles.count, 0, "projectile consumed")
assert_eq(sim.gems.count, 1, "gem dropped")
assert_eq(sim.kills, 1)
func test_projectile_damages_without_killing() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 3.0)
sim.projectiles.add_proj(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0, 1.0)
sim._resolve_collisions()
assert_eq(sim.enemies.count, 1, "enemy survives")
assert_almost_eq(sim.enemies.data[0], 2.0, 0.001, "HP reduced by damage")
assert_eq(sim.projectiles.count, 0, "projectile consumed")
func test_miss_leaves_everything() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.enemies.add(Vector2(1000, 0), Vector2.ZERO, 14.0, 3.0)
sim.projectiles.add_proj(Vector2(0, 0), Vector2.ZERO, 6.0, 1.0, 1.0)
sim._resolve_collisions()
assert_eq(sim.enemies.count, 1)
assert_eq(sim.projectiles.count, 1)
func test_two_projectiles_kill_two_enemies_no_misattribution() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.enemies.add(Vector2(0, 0), Vector2.ZERO, 14.0, 1.0)
sim.enemies.add(Vector2(500, 0), Vector2.ZERO, 14.0, 1.0)
sim.projectiles.add_proj(Vector2(0, 0), Vector2.ZERO, 6.0, 1.0, 1.0)
sim.projectiles.add_proj(Vector2(500, 0), Vector2.ZERO, 6.0, 1.0, 1.0)
sim._resolve_collisions()
sim._sweep_dead()
assert_eq(sim.enemies.count, 0, "both enemies killed")
assert_eq(sim.gems.count, 2, "two gems dropped")
assert_eq(sim.kills, 2, "two kills credited")
assert_eq(sim.projectiles.count, 0, "both projectiles consumed")

tests/test_fx_events.gd — change sim.enemy_radius to 14.0 in test_death_event_recorded_on_sweep:

var ei := sim.enemies.add(Vector2(10, 20), Vector2.ZERO, 14.0, -1.0) # hp<=0 => dead

tests/test_reactions_in_sim.gd, tests/test_mods_in_sim.gd, tests/test_shock_vulnerability.gd, tests/test_elemental_coverage.gd — in each file, find every sim.proj_damage = X.0 line and the sim.projectiles.add_proj(...) call that follows it. Replace:

Before (pattern in each file):

sim.proj_damage = 1.0 # (or 10.0)
sim.projectiles.add_proj(pos, vel, r, lifetime)

After:

sim.projectiles.add_proj(pos, vel, r, lifetime, 1.0) # (match the proj_damage value)

Remove the sim.proj_damage = X line entirely.

  • Step 4: Run ALL tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15

Expected: all tests pass. (Determinism checksums will fail in test_determinism_checksum.gd because the checksums changed — that is expected and will be fixed in Task 11.)

If test_determinism_checksum.gd fails, temporarily comment out the checksum assertions so the rest of the suite can be confirmed green, then uncomment before Task 11.

  • Step 5: Add a new test for armor and splitter

Add tests/test_enemy_variety.gd:

extends GutTest
func test_armor_reduces_damage_but_not_below_10_pct() -> void:
var sim := Sim.new(1, SimContentFixture.db())
# Add a tank with 4 armor, 30 HP
sim.enemies.add(Vector2.ZERO, Vector2.ZERO, 22.0, 30.0,
4.0, 40.0, 18.0, 4.0, EnemyPool.TYPE_TANK)
# Projectile with 5 damage — armor reduces to max(5-4, 5*0.1) = max(1, 0.5) = 1
sim.projectiles.add_proj(Vector2.ZERO, Vector2.ZERO, 6.0, 1.0, 5.0)
sim._resolve_collisions()
assert_almost_eq(sim.enemies.data[0], 29.0, 0.01, "5 dmg - 4 armor = 1 effective")
func test_armor_floor_at_10_pct() -> void:
var sim := Sim.new(1, SimContentFixture.db())
# 100 armor, 50 HP enemy — shot with 1 damage
# max(1-100, 1*0.1) = max(-99, 0.1) = 0.1
sim.enemies.add(Vector2.ZERO, Vector2.ZERO, 14.0, 50.0,
100.0, 70.0, 12.0, 1.0, EnemyPool.TYPE_SWARMER)
sim.projectiles.add_proj(Vector2.ZERO, Vector2.ZERO, 6.0, 1.0, 1.0)
sim._resolve_collisions()
assert_almost_eq(sim.enemies.data[0], 49.9, 0.01, "10% floor: 50 - 0.1 = 49.9")
func test_splitter_spawns_two_swarmers_on_death() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2(5000, 5000) # far from spawn_ring
# Add one splitter with 1 HP
sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 1.0,
0.0, 60.0, 10.0, 3.0, EnemyPool.TYPE_SPLITTER)
assert_eq(sim.enemies.count, 1)
# Kill it
sim.projectiles.add_proj(Vector2(200, 0), Vector2.ZERO, 6.0, 1.0, 5.0)
sim._resolve_collisions()
sim._sweep_dead()
assert_eq(sim.enemies.count, 2, "splitter death spawns 2 swarmers")
assert_eq(sim.enemies.type_id[0], EnemyPool.TYPE_SWARMER)
assert_eq(sim.enemies.type_id[1], EnemyPool.TYPE_SWARMER)
func test_splitter_children_do_not_count_as_kills() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 1.0,
0.0, 60.0, 10.0, 3.0, EnemyPool.TYPE_SPLITTER)
sim.projectiles.add_proj(Vector2(200, 0), Vector2.ZERO, 6.0, 1.0, 5.0)
sim._resolve_collisions()
sim._sweep_dead()
assert_eq(sim.kills, 1, "only the splitter itself counted as a kill")
  • Step 6: Run ALL tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15

Expected: all tests pass (excluding temporarily-commented checksum test if needed).

  • Step 7: Commit
Terminal window
git add sim/sim.gd sim/weapon_pulse.gd tests/test_collision_damage.gd \
tests/test_fx_events.gd tests/test_reactions_in_sim.gd \
tests/test_mods_in_sim.gd tests/test_shock_vulnerability.gd \
tests/test_elemental_coverage.gd tests/test_enemy_variety.gd
git commit -m "feat: Sim — per-enemy stats, armor, ProjPool, splitter death, multi-type spawning"

Task 5: WeaponOrbit — three cold orbital shards

Section titled “Task 5: WeaponOrbit — three cold orbital shards”

Files:

  • Create: sim/weapon_orbit.gd
  • Create: tests/test_weapon_orbit.gd
  • Modify: sim/sim.gd (wire orbit weapon)

Interfaces:

  • Consumes: Sim (reads player.pos, player.damage_mult, hash, enemies, content, mods; calls _damage_enemy, _reaction_burst), Elemental.apply.

  • Produces: class_name WeaponOrbit extends RefCounted with update(sim: Sim, dt: float) and cooldown_frac() -> float. Sim.orbit: WeaponOrbit, Sim.orbit_element_idx: int.

  • Step 1: Write failing tests

Create tests/test_weapon_orbit.gd:

extends GutTest
func _def() -> Dictionary:
return {"base_damage": 0.8, "cooldown_s": 0.0}
func test_orbit_damages_nearby_enemy_over_time() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
# Enemy at orbit radius — will be hit by a shard
sim.enemies.add(Vector2(120, 0), Vector2.ZERO, 14.0, 100.0)
var orbit := WeaponOrbit.new(_def())
# Run 60 ticks (1 second) — continuous DPS should reduce HP
for _i in range(60):
sim.hash.rebuild(sim.enemies)
orbit.update(sim, Sim_Const.DT)
assert_lt(sim.enemies.data[0], 100.0, "orbit should deal damage over time")
func test_orbit_cooldown_frac_always_one() -> void:
var w := WeaponOrbit.new({"base_damage": 1.0, "cooldown_s": 0.0})
assert_almost_eq(w.cooldown_frac(), 1.0, 0.001, "orbit is always active")
func test_orbit_three_shards_can_hit_surrounding_enemies() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
# Place 3 enemies at 120° apart at orbit radius
var hit := [false, false, false]
for k in range(3):
var angle := k * TAU / 3.0
sim.enemies.add(Vector2(cos(angle), sin(angle)) * 120.0, Vector2.ZERO, 14.0, 100.0)
var orbit := WeaponOrbit.new(_def())
for _i in range(120): # 2 seconds
sim.hash.rebuild(sim.enemies)
orbit.update(sim, Sim_Const.DT)
# All 3 should have taken damage
for i in range(3):
assert_lt(sim.enemies.data[i], 100.0, "enemy %d should be damaged" % i)
  • Step 2: Run — expect FAIL
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_orbit.gd -gexit 2>&1 | tail -10
  • Step 3: Create sim/weapon_orbit.gd
class_name WeaponOrbit
extends RefCounted
const ORBIT_RADIUS: float = 120.0
const SHARD_HIT_RADIUS: float = 18.0
const ORBIT_SPEED_DEG: float = 90.0
const SHARD_COUNT: int = 3
var base_damage: float
var _phase: float = 0.0
func _init(def: Dictionary) -> void:
base_damage = float(def["base_damage"])
func update(sim: Sim, dt: float) -> void:
_phase += deg_to_rad(ORBIT_SPEED_DEG) * dt
# hash must be rebuilt by caller or by the previous phase; rebuild here to be safe
sim.hash.rebuild(sim.enemies)
var dmg := base_damage * sim.player.damage_mult * dt
for k in range(SHARD_COUNT):
var angle := _phase + float(k) * TAU / float(SHARD_COUNT)
var spos := sim.player.pos + Vector2(cos(angle), sin(angle)) * ORBIT_RADIUS
var hits := sim.hash.query_circle(spos, SHARD_HIT_RADIUS, sim.enemies)
for ei in hits:
sim._damage_enemy(ei, dmg)
if sim.orbit_element_idx != -1 and sim.enemies.data[ei] > 0.0:
var ev := Elemental.apply(sim.enemies, ei, sim.orbit_element_idx, sim.content, sim.mods)
if not ev.is_empty():
sim._reaction_burst(ev["center"], ev["magnitude"], ev["generic"], sim.orbit_element_idx)
func cooldown_frac() -> float:
return 1.0 # always active — no cooldown
  • Step 4: Wire orbit into sim/sim.gd

Add to Sim vars block (after nova_element_idx):

var orbit: WeaponOrbit
var orbit_element_idx: int

In _init, after the nova lines:

if content.has_weapon("orbit"):
orbit = WeaponOrbit.new(content.weapon("orbit"))
orbit_element_idx = content.element_index(content.weapon("orbit").get("element", ""))

In tick, after nova.update(self, dt):

if orbit:
orbit.update(self, dt)
  • Step 5: Run ALL tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15
  • Step 6: Commit
Terminal window
git add sim/weapon_orbit.gd tests/test_weapon_orbit.gd sim/sim.gd
git commit -m "feat: WeaponOrbit — 3 cold orbital shards, continuous DPS"

Files:

  • Create: sim/weapon_beam.gd
  • Create: tests/test_weapon_beam.gd
  • Modify: sim/sim.gd (wire beam)

Interfaces:

  • Produces: class_name WeaponBeam extends RefCounted with update(sim, dt) and cooldown_frac() -> float. Sim.beam: WeaponBeam, Sim.beam_element_idx: int.

  • Step 1: Write failing tests

Create tests/test_weapon_beam.gd:

extends GutTest
func _def() -> Dictionary:
return {"base_damage": 0.4, "cooldown_s": 0.1}
func test_beam_hits_enemy_in_path() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
# Enemy directly to the right
sim.enemies.add(Vector2(300, 0), Vector2.ZERO, 14.0, 10.0)
var beam := WeaponBeam.new(_def())
# Force cooldown to 0 so it fires on first update
beam._timer = 0.0
beam.update(sim, Sim_Const.DT)
assert_lt(sim.enemies.data[0], 10.0, "beam should hit enemy in path")
func test_beam_pierces_multiple_enemies() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
# Two enemies along the same ray
sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 10.0)
sim.enemies.add(Vector2(400, 0), Vector2.ZERO, 14.0, 10.0)
var beam := WeaponBeam.new(_def())
beam._timer = 0.0
beam.update(sim, Sim_Const.DT)
assert_lt(sim.enemies.data[0], 10.0, "first enemy hit")
assert_lt(sim.enemies.data[1], 10.0, "second enemy hit (pierce)")
func test_beam_does_not_hit_enemy_beside_path() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
# Enemy far to the side of the beam path
sim.enemies.add(Vector2(300, 0), Vector2.ZERO, 14.0, 10.0)
sim.enemies.add(Vector2(100, 200), Vector2.ZERO, 14.0, 10.0) # way off to the side
var beam := WeaponBeam.new(_def())
beam._timer = 0.0
beam.update(sim, Sim_Const.DT)
assert_lt(sim.enemies.data[0], 10.0, "nearest enemy hit")
assert_almost_eq(sim.enemies.data[1], 10.0, 0.001, "off-path enemy untouched")
func test_beam_cooldown_frac_starts_full() -> void:
var w := WeaponBeam.new(_def())
assert_almost_eq(w.cooldown_frac(), 1.0, 0.001)
  • Step 2: Run — expect FAIL
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_beam.gd -gexit 2>&1 | tail -10
  • Step 3: Create sim/weapon_beam.gd
class_name WeaponBeam
extends RefCounted
const BEAM_WIDTH: float = 20.0
const BEAM_RANGE: float = 900.0
var base_damage: float
var cooldown: float
var _timer: float = 0.0
func _init(def: Dictionary) -> void:
base_damage = float(def["base_damage"])
cooldown = float(def["cooldown_s"])
func _nearest_enemy_index(sim: Sim) -> int:
var best := -1
var best_d2 := INF
for i in range(sim.enemies.count):
var d2 := sim.player.pos.distance_squared_to(sim.enemies.pos[i])
if d2 < best_d2:
best_d2 = d2
best = i
return best
func update(sim: Sim, dt: float) -> void:
_timer -= dt
if _timer > 0.0:
return
_timer = cooldown / maxf(sim.player.fire_rate_mult, 0.01)
var target := _nearest_enemy_index(sim)
if target == -1:
return
var dir := (sim.enemies.pos[target] - sim.player.pos).normalized()
var dmg := base_damage * sim.player.damage_mult
# Scan all enemies — O(n) linear, fires infrequently
for i in range(sim.enemies.count):
var to_enemy := sim.enemies.pos[i] - sim.player.pos
# Only hit enemies in front (positive projection onto dir)
var proj := to_enemy.dot(dir)
if proj < 0.0 or proj > BEAM_RANGE:
continue
# Perpendicular distance to the ray
var perp := absf(to_enemy.cross(dir))
if perp <= BEAM_WIDTH:
sim._damage_enemy(i, dmg)
if sim.beam_element_idx != -1 and sim.enemies.data[i] > 0.0:
var ev := Elemental.apply(sim.enemies, i, sim.beam_element_idx, sim.content, sim.mods)
if not ev.is_empty():
sim._reaction_burst(ev["center"], ev["magnitude"], ev["generic"], sim.beam_element_idx)
sim.fx_events.append({"kind": "beam", "pos": sim.player.pos, "dir": dir, "length": BEAM_RANGE})
func cooldown_frac() -> float:
return clampf(1.0 - _timer / maxf(cooldown, 0.001), 0.0, 1.0)
  • Step 4: Wire beam into sim/sim.gd

Add vars (after orbit_element_idx):

var beam: WeaponBeam
var beam_element_idx: int

In _init, after the orbit block:

if content.has_weapon("beam"):
beam = WeaponBeam.new(content.weapon("beam"))
beam_element_idx = content.element_index(content.weapon("beam").get("element", ""))

In tick, after if orbit: orbit.update(self, dt):

if beam:
beam.update(self, dt)
  • Step 5: Run ALL tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15
  • Step 6: Commit
Terminal window
git add sim/weapon_beam.gd tests/test_weapon_beam.gd sim/sim.gd
git commit -m "feat: WeaponBeam — pierce laser, O(n) scan, light element"

Files:

  • Create: sim/weapon_turret.gd
  • Create: tests/test_weapon_turret.gd
  • Modify: sim/sim.gd (wire turret)

Interfaces:

  • Produces: class_name WeaponTurret extends RefCounted with update(sim, dt) and cooldown_frac() -> float. Sim.turret: WeaponTurret.

  • Step 1: Write failing tests

Create tests/test_weapon_turret.gd:

extends GutTest
func _def() -> Dictionary:
return {"base_damage": 0.7, "cooldown_s": 0.4}
func test_turret_deploys_on_first_ready() -> void:
var sim := Sim.new(1, SimContentFixture.db())
var turret := WeaponTurret.new(_def())
# Advance past deploy cooldown
turret._deploy_timer = WeaponTurret.DEPLOY_COOLDOWN + 0.01
turret.update(sim, Sim_Const.DT)
assert_eq(turret._turrets.size(), 1, "first turret deployed")
func test_turret_max_two_at_once() -> void:
var sim := Sim.new(1, SimContentFixture.db())
var turret := WeaponTurret.new(_def())
turret._deploy_timer = WeaponTurret.DEPLOY_COOLDOWN + 0.01
turret.update(sim, Sim_Const.DT)
turret._deploy_timer = WeaponTurret.DEPLOY_COOLDOWN + 0.01
turret.update(sim, Sim_Const.DT)
turret._deploy_timer = WeaponTurret.DEPLOY_COOLDOWN + 0.01
turret.update(sim, Sim_Const.DT)
assert_eq(turret._turrets.size(), 2, "never more than 2 turrets")
func test_turret_expires_after_lifetime() -> void:
var sim := Sim.new(1, SimContentFixture.db())
var turret := WeaponTurret.new(_def())
turret._deploy_timer = WeaponTurret.DEPLOY_COOLDOWN + 0.01
turret.update(sim, Sim_Const.DT)
assert_eq(turret._turrets.size(), 1)
# Fast-forward past lifetime
for t in turret._turrets:
t["life"] = 0.0
turret.update(sim, Sim_Const.DT)
assert_eq(turret._turrets.size(), 0, "turret removed after lifetime expires")
func test_turret_fires_at_nearby_enemy() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0)
var turret := WeaponTurret.new(_def())
turret._deploy_timer = WeaponTurret.DEPLOY_COOLDOWN + 0.01
turret.update(sim, Sim_Const.DT)
# Fast-forward turret fire timer
turret._turrets[0]["fire_timer"] = WeaponTurret.FIRE_COOLDOWN + 0.01
turret.update(sim, Sim_Const.DT)
assert_gt(sim.projectiles.count, 0, "turret fired a projectile")
func test_cooldown_frac_decreases_then_resets() -> void:
var w := WeaponTurret.new(_def())
assert_almost_eq(w.cooldown_frac(), 1.0, 0.001, "fresh = 1.0")
  • Step 2: Run — expect FAIL
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_weapon_turret.gd -gexit 2>&1 | tail -10
  • Step 3: Create sim/weapon_turret.gd
class_name WeaponTurret
extends RefCounted
const DEPLOY_COOLDOWN: float = 6.0
const TURRET_LIFETIME: float = 8.0
const MAX_TURRETS: int = 2
const FIRE_COOLDOWN: float = 0.4 # matches bible cooldown_s
const PROJ_SPEED: float = 500.0
const PROJ_RADIUS: float = 6.0
const PROJ_LIFETIME: float = 1.5
var base_damage: float
var _deploy_timer: float = DEPLOY_COOLDOWN # ready to deploy immediately
var _turrets: Array[Dictionary] = [] # [{pos, life, fire_timer}]
func _init(def: Dictionary) -> void:
base_damage = float(def["base_damage"])
func update(sim: Sim, dt: float) -> void:
# 1. Try to deploy a new turret
_deploy_timer += dt
if _deploy_timer >= DEPLOY_COOLDOWN and _turrets.size() < MAX_TURRETS:
_deploy_timer = 0.0
_turrets.append({"pos": sim.player.pos, "life": TURRET_LIFETIME, "fire_timer": FIRE_COOLDOWN})
# 2. Update existing turrets
var i := _turrets.size() - 1
while i >= 0:
var t: Dictionary = _turrets[i]
t["life"] -= dt
if t["life"] <= 0.0:
_turrets.remove_at(i)
i -= 1
continue
t["fire_timer"] += dt
if t["fire_timer"] >= FIRE_COOLDOWN:
t["fire_timer"] = 0.0
_fire(sim, t["pos"])
i -= 1
func _fire(sim: Sim, from_pos: Vector2) -> void:
# Find nearest enemy to THIS turret position
var best := -1
var best_d2 := INF
for i in range(sim.enemies.count):
var d2 := from_pos.distance_squared_to(sim.enemies.pos[i])
if d2 < best_d2:
best_d2 = d2
best = i
if best == -1:
return
var dir := (sim.enemies.pos[best] - from_pos).normalized()
var dmg := base_damage * sim.player.damage_mult
sim.projectiles.add_proj(from_pos, dir * PROJ_SPEED, PROJ_RADIUS, PROJ_LIFETIME, dmg, -1)
func cooldown_frac() -> float:
return clampf(_deploy_timer / DEPLOY_COOLDOWN, 0.0, 1.0)
  • Step 4: Wire turret into sim/sim.gd

Add var (after beam_element_idx):

var turret: WeaponTurret

In _init, after the beam block:

if content.has_weapon("turret"):
turret = WeaponTurret.new(content.weapon("turret"))

In tick, after if beam: beam.update(self, dt):

if turret:
turret.update(self, dt)
  • Step 5: Run ALL tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15
  • Step 6: Commit
Terminal window
git add sim/weapon_turret.gd tests/test_weapon_turret.gd sim/sim.gd
git commit -m "feat: WeaponTurret — deployable summon, fires kinetic projectiles"

Task 8: Shooter enemy — fires projectiles at player

Section titled “Task 8: Shooter enemy — fires projectiles at player”

Files:

  • Modify: sim/sim.gd (add enemy_proj: EntityPool, _shooter_timers: Dictionary, _update_shooters, _move_enemy_proj, update _check_player_hit, update state_checksum)
  • Create: tests/test_shooter_enemy.gd

Interfaces:

  • Produces: Sim.enemy_proj: EntityPool. _check_player_hit now also checks enemy projectile collisions.

  • Step 1: Write failing tests

Create tests/test_shooter_enemy.gd:

extends GutTest
func _shooter_sim() -> Sim:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
return sim
func test_enemy_proj_pool_exists() -> void:
var sim := _shooter_sim()
assert_not_null(sim.enemy_proj)
assert_eq(sim.enemy_proj.count, 0)
func test_shooter_fires_after_delay() -> void:
var sim := _shooter_sim()
# Add a shooter at close range
sim.enemies.add(Vector2(300, 0), Vector2.ZERO, 14.0, 8.0,
0.0, 55.0, 10.0, 3.0, EnemyPool.TYPE_SHOOTER)
# Force its timer to be ready
sim._shooter_timers[0] = Sim.SHOOTER_FIRE_INTERVAL + 0.01
sim._update_shooters(Sim_Const.DT)
assert_gt(sim.enemy_proj.count, 0, "shooter fired a projectile at player")
func test_enemy_proj_moves_and_despawns() -> void:
var sim := _shooter_sim()
sim.enemy_proj.add(Vector2(100, 0), Vector2(300, 0), 6.0, 0.01) # lifetime 0.01s
sim._move_enemy_proj(1.0) # 1 full second — way past lifetime
assert_eq(sim.enemy_proj.count, 0, "projectile despawned after lifetime")
func test_enemy_proj_damages_player_on_contact() -> void:
var sim := _shooter_sim()
sim.player.pos = Vector2.ZERO
sim.player.hp = 50.0
# Place enemy projectile on top of player
sim.enemy_proj.add(Vector2.ZERO, Vector2.ZERO, 6.0, 5.0)
sim._check_player_hit(Sim_Const.DT)
assert_lt(sim.player.hp, 50.0, "player takes damage from enemy projectile")
func test_enemy_proj_removed_on_player_hit() -> void:
var sim := _shooter_sim()
sim.player.pos = Vector2.ZERO
sim.enemy_proj.add(Vector2.ZERO, Vector2.ZERO, 6.0, 5.0)
sim._check_player_hit(Sim_Const.DT)
assert_eq(sim.enemy_proj.count, 0, "enemy projectile consumed on hit")
  • Step 2: Run — expect FAIL
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_shooter_enemy.gd -gexit 2>&1 | tail -10
  • Step 3: Update sim/sim.gd

Add constants and vars:

const ENEMY_PROJ_CAP: int = 500
const SHOOTER_FIRE_INTERVAL: float = 2.0
const SHOOTER_PROJ_SPEED: float = 300.0
const SHOOTER_PROJ_RADIUS: float = 6.0
const SHOOTER_PROJ_LIFETIME: float = 3.0
const SHOOTER_PROJ_DAMAGE: float = 6.0
var enemy_proj: EntityPool
var _shooter_timers: Dictionary # enemy_index (int) → cooldown_elapsed (float)

In _init, after hash = SpatialHash.new(HASH_CELL):

enemy_proj = EntityPool.new(ENEMY_PROJ_CAP)
_shooter_timers = {}

In tick, after _move_projectiles(dt):

_move_enemy_proj(dt)
_update_shooters(dt)

Add the two new private methods:

func _move_enemy_proj(dt: float) -> void:
var i := enemy_proj.count - 1
while i >= 0:
enemy_proj.pos[i] += enemy_proj.vel[i] * dt
enemy_proj.data[i] -= dt
if enemy_proj.data[i] <= 0.0:
enemy_proj.remove_at(i)
i -= 1
func _update_shooters(dt: float) -> void:
# Rebuild the timer dict: keep existing timers for still-alive shooters,
# initialise new ones, drop dead ones automatically by only touching live indices.
var next_timers: Dictionary = {}
for i in range(enemies.count):
if enemies.type_id[i] != EnemyPool.TYPE_SHOOTER:
continue
var elapsed: float = _shooter_timers.get(i, 0.0) + dt
if elapsed >= SHOOTER_FIRE_INTERVAL:
elapsed = 0.0
var dir := (player.pos - enemies.pos[i]).normalized()
enemy_proj.add(enemies.pos[i], dir * SHOOTER_PROJ_SPEED,
SHOOTER_PROJ_RADIUS, SHOOTER_PROJ_LIFETIME)
next_timers[i] = elapsed
_shooter_timers = next_timers

Update _check_player_hit to also check enemy projectiles:

func _check_player_hit(dt: float) -> void:
var total_dps := 0.0
for i in range(enemies.count):
var reach := player.radius + enemies.radius[i]
if player.pos.distance_squared_to(enemies.pos[i]) <= reach * reach:
total_dps += enemies.contact_dmg[i]
# Enemy projectile hits
var ep_reach2 := (player.radius + SHOOTER_PROJ_RADIUS) * (player.radius + SHOOTER_PROJ_RADIUS)
var ep := enemy_proj.count - 1
while ep >= 0:
if player.pos.distance_squared_to(enemy_proj.pos[ep]) <= ep_reach2:
player.hp -= SHOOTER_PROJ_DAMAGE
enemy_proj.remove_at(ep)
ep -= 1
if total_dps > 0.0:
player.hp -= total_dps * dt
if player.hp <= 0.0:
player.hp = 0.0
game_over = true

Update state_checksum to include enemy_proj:

for i in range(enemy_proj.count):
parts.append(enemy_proj.pos[i])
parts.append(enemy_proj.vel[i])

(Add this block after the projectiles loop.)

  • Step 4: Run ALL tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15
  • Step 5: Commit
Terminal window
git add sim/sim.gd tests/test_shooter_enemy.gd
git commit -m "feat: Shooter enemy — fires projectiles at player, enemy_proj pool"

Task 9: WeaponPanel — extend to 5 weapon slots

Section titled “Task 9: WeaponPanel — extend to 5 weapon slots”

Files:

  • Modify: ui/weapon_panel.gd
  • Modify: main.gd

Interfaces:

  • Consumes: Sim (reads .weapon, .nova, .orbit, .beam, .turret, .*_element_idx, .content, .player.damage_mult).

  • Produces: WeaponPanel.update_panel(sim: Sim) (replaces old 6-param signature).

  • Step 1: Replace ui/weapon_panel.gd

class_name WeaponPanel
extends CanvasLayer
class _CooldownArc extends Node2D:
var frac: float = 0.0
var col: Color = NeonTheme.CYAN
func _draw() -> void:
if frac <= 0.0:
return
draw_arc(Vector2.ZERO, 32.0, -PI / 2.0, -PI / 2.0 + TAU * frac, 48, col, 4.0, true)
const SLOT_W: float = 160.0
const SLOT_H: float = 90.0
const SLOT_GAP: float = 16.0
const SLOT_COUNT: int = 5
var _name_labels: Array[Label] = []
var _stat_labels: Array[Label] = []
var _arcs: Array[_CooldownArc] = []
func _ready() -> void:
var vp_w: float = float(ProjectSettings.get_setting("display/window/size/viewport_width", 1152))
var vp_h: float = float(ProjectSettings.get_setting("display/window/size/viewport_height", 648))
var total_w: float = SLOT_COUNT * SLOT_W + (SLOT_COUNT - 1) * SLOT_GAP
var x0: float = vp_w / 2.0 - total_w / 2.0
var y0: float = vp_h - SLOT_H - 16.0
for i in range(SLOT_COUNT):
_build_slot(x0 + i * (SLOT_W + SLOT_GAP), y0)
func _build_slot(x: float, y: float) -> void:
var backing := ColorRect.new()
backing.position = Vector2(x, y)
backing.size = Vector2(SLOT_W, SLOT_H)
backing.color = Color(0, 0, 0, 0.55)
backing.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(backing)
var border := Panel.new()
border.position = Vector2(x, y)
border.size = Vector2(SLOT_W, SLOT_H)
border.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sb := StyleBoxFlat.new()
sb.bg_color = Color.TRANSPARENT
sb.border_width_left = 1; sb.border_width_right = 1
sb.border_width_top = 1; sb.border_width_bottom = 1
sb.border_color = NeonTheme.CYAN
sb.corner_radius_top_left = 8; sb.corner_radius_top_right = 8
sb.corner_radius_bottom_left = 8; sb.corner_radius_bottom_right = 8
border.add_theme_stylebox_override("panel", sb)
add_child(border)
var name_lbl := Label.new()
name_lbl.position = Vector2(x + 8, y + 6)
name_lbl.size = Vector2(SLOT_W - 16, 24)
name_lbl.add_theme_font_override("font", NeonTheme.title_font())
name_lbl.add_theme_font_size_override("font_size", 16)
name_lbl.add_theme_color_override("font_color", NeonTheme.TEXT)
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(name_lbl)
_name_labels.append(name_lbl)
var stat_lbl := Label.new()
stat_lbl.position = Vector2(x + 8, y + 34)
stat_lbl.size = Vector2(SLOT_W - 50, 20)
stat_lbl.add_theme_font_override("font", NeonTheme.mono_font())
stat_lbl.add_theme_font_size_override("font_size", 15)
stat_lbl.add_theme_color_override("font_color", NeonTheme.CYAN)
stat_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(stat_lbl)
_stat_labels.append(stat_lbl)
var arc := _CooldownArc.new()
arc.position = Vector2(x + SLOT_W - 38, y + SLOT_H / 2.0)
add_child(arc)
_arcs.append(arc)
func update_panel(sim: Sim) -> void:
var dm := sim.player.damage_mult
var slots: Array[Dictionary] = [
{"name": "Lightning", "frac": sim.weapon.cooldown_frac(),
"stat": "dmg %.0f" % (sim.weapon.base_damage * dm),
"el": sim.pulse_element_idx},
{"name": "Fire Nova", "frac": sim.nova.cooldown_frac(),
"stat": "dmg %.0f r%.0f" % [sim.nova.base_damage * dm, sim.nova.area],
"el": sim.nova_element_idx},
{"name": "Orbit", "frac": sim.orbit.cooldown_frac() if sim.orbit else 0.0,
"stat": "%.1f dps" % (sim.orbit.base_damage * dm * 3.0) if sim.orbit else "",
"el": sim.orbit_element_idx if sim.orbit else -1},
{"name": "Beam", "frac": sim.beam.cooldown_frac() if sim.beam else 0.0,
"stat": "dmg %.0f" % (sim.beam.base_damage * dm) if sim.beam else "",
"el": sim.beam_element_idx if sim.beam else -1},
{"name": "Turret", "frac": sim.turret.cooldown_frac() if sim.turret else 0.0,
"stat": "dmg %.0f" % (sim.turret.base_damage * dm) if sim.turret else "",
"el": -1},
]
for i in range(SLOT_COUNT):
var s: Dictionary = slots[i]
_name_labels[i].text = s["name"]
_stat_labels[i].text = s["stat"]
_arcs[i].frac = s["frac"]
_arcs[i].col = ElementPalette.color_for(sim.content, s["el"])
_arcs[i].queue_redraw()
  • Step 2: Update main.gd — simplify update_panel call

Find the current call:

weapon_panel.update_panel(
sim.weapon, sim.nova,
sim.pulse_element_idx, sim.nova_element_idx,
sim.content, sim.player.damage_mult)

Replace with:

weapon_panel.update_panel(sim)
  • Step 3: Run ALL tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15
  • Step 4: Headless boot smoke
godot --headless --path . --quit-after 300 2>&1 | grep -i "error\|SCRIPT" | head -10

Expected: no SCRIPT ERRORs.

  • Step 5: Commit
Terminal window
git add ui/weapon_panel.gd main.gd
git commit -m "feat: WeaponPanel — 5 slots for all weapons, update_panel(sim) signature"

Task 10: Bible — mark 7 entries live and re-export

Section titled “Task 10: Bible — mark 7 entries live and re-export”

Files:

  • Modify: tools/design-bible/src/seed.js
  • Regenerate: data/bible.json

Interfaces:

  • All 7 entries (orbit, beam, turret, tank, shooter, splitter, elite) gain live: true in seed.js and in the exported bible.json.

  • Step 1: Update tools/design-bible/src/seed.js

Find the weapons block and add live: true to orbit, beam, turret using the extra-object spread:

// Before:
weapon('orbit', 'Orbit Shards', 'orbital', 'cold', 0.8, 0.0, { projectile_count: 3, tags: ['orbital'] }),
weapon('beam', 'Beam', 'beam', 'light', 0.4, 0.1, { pierce: 99, tags: ['beam','pierce'] }),
// ...
weapon('turret', 'Turret', 'summon', 'kinetic', 0.7, 0.4, { tags: ['summon','trap'] }),
// After:
weapon('orbit', 'Orbit Shards', 'orbital', 'cold', 0.8, 0.0, { projectile_count: 3, tags: ['orbital'], live: true }),
weapon('beam', 'Beam', 'beam', 'light', 0.4, 0.1, { pierce: 99, tags: ['beam','pierce'], live: true }),
// ...
weapon('turret', 'Turret', 'summon', 'kinetic', 0.7, 0.4, { tags: ['summon','trap'], live: true }),

Find the enemies block and add live: true to tank, shooter, splitter, elite using the extra-object spread:

// Before:
enemy('tank', 'Tank', 'tank', 30, 40, 18, 4, { radius: 22, armor: 4 }),
enemy('shooter', 'Shooter', 'ranged', 8, 55, 10, 3),
enemy('splitter', 'Splitter', 'splitter', 10, 60, 10, 3),
enemy('elite', 'Elite', 'charger', 60, 90, 25, 12, { radius: 26, armor: 6 }),
// After:
enemy('tank', 'Tank', 'tank', 30, 40, 18, 4, { radius: 22, armor: 4, live: true }),
enemy('shooter', 'Shooter', 'ranged', 8, 55, 10, 3, { live: true }),
enemy('splitter', 'Splitter', 'splitter', 10, 60, 10, 3, { live: true }),
enemy('elite', 'Elite', 'charger', 60, 90, 25, 12, { radius: 26, armor: 6, live: true }),
  • Step 2: Run the bible node tests to confirm seed.js is still valid
cd tools/design-bible && node --test 2>&1 | tail -10

Expected: 31 tests pass.

  • Step 3: Re-export data/bible.json
Terminal window
node tools/design-bible/scripts/export-seed.mjs > data/bible.json

Verify new content is present:

Terminal window
grep '"id":"orbit"\|"id":"beam"\|"id":"turret"\|"id":"tank"\|"id":"shooter"\|"id":"splitter"\|"id":"elite"' data/bible.json | grep '"live":true'

Expected: 7 lines with "live":true.

  • Step 4: Run ALL Godot tests — expect PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -15

Expected: all tests pass (ContentLoader ignores unknown fields like live; existing loader validation is unaffected).

  • Step 5: Commit
Terminal window
cd /Users/chris/Claude/bullet-heaven
git add tools/design-bible/src/seed.js data/bible.json
git commit -m "feat: mark 7 bible entries live — orbit, beam, turret, tank, shooter, splitter, elite"

Files:

  • Modify: tests/test_determinism_checksum.gd
  • Modify: CLAUDE.md

Interfaces:

  • After all new gameplay, capture new checksums for seed 1234, 600 ticks. The determinism PROPERTY (same seed → same result) still holds; only the concrete values change.

  • Step 1: Run the property test — expect PASS

godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit 2>&1 | tail -10

Expected: test_same_seed_identical_trace passes. If it fails, there is a non-determinism bug — stop and debug before continuing.

  • Step 2: Capture new checksums

Run this script to print the new hash values:

godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit 2>&1 | grep -i "expected\|actual\|got\|checksum\|hash" | head -20

The GUT output will show the expected vs actual values from failed assertions. Record the ACTUAL values — these become the new baselines.

Alternatively, run a small GDScript directly:

godot --headless --path . --script - << 'EOF'
extends SceneTree
func _init():
var db = load("res://tests/sim_content_fixture.gd").db()
var sim = Sim.new(1234, db)
for i in range(600):
var dir = Vector2(cos(float(i)*0.05), sin(float(i)*0.03)).normalized()
sim.tick(InputState.new(dir if dir.length() > 0.0 else Vector2.ZERO))
print("snapshot hash: ", sim.snapshot_string().hash())
print("state checksum: ", sim.state_checksum())
quit()
EOF
  • Step 3: Update tests/test_determinism_checksum.gd

Replace EXPECTED_HASH and EXPECTED_CHECKSUM with the new values recorded in Step 2. The structure of the test is unchanged; only the literal values change.

  • Step 4: Uncomment checksum test if it was temporarily commented in Task 4

Restore any lines that were temporarily commented out.

  • Step 5: Run the full test suite — expect ALL PASS
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -20

Expected: all tests pass, including the updated checksum test.

  • Step 6: Update CLAUDE.md

In the ## Architecture — the load-bearing rules section, find the determinism baseline line and update it:

Current baseline (seed 1234, 600 ticks): `snapshot_string().hash() = NNNNNNNN`, `state_checksum() = NNNNNNNN`.

Replace both NNNNNNNN with the new values from Step 2.

  • Step 7: Final commit
Terminal window
git add tests/test_determinism_checksum.gd CLAUDE.md
git commit -m "chore: update determinism baseline checksums for cycle 9 content"

Spec coverage:

  • ✅ Section 1 (EnemyPool columns) → Task 2
  • ✅ Section 2 (Armor) → Task 4 _damage_enemy
  • ✅ Section 3 (Multi-type spawning) → Task 3 + Task 4 _spawn_enemies
  • ✅ Section 4 (Tank) → Task 2 data + Task 3 threshold + Task 10 live
  • ✅ Section 5 (Elite) → same as tank
  • ✅ Section 6 (Splitter) → Task 4 _sweep_dead
  • ✅ Section 7 (Shooter) → Task 8
  • ✅ Section 8 (WeaponOrbit) → Task 5
  • ✅ Section 9 (WeaponBeam) → Task 6
  • ✅ Section 10 (WeaponTurret) → Task 7
  • ✅ Section 11 (Sim wiring) → Tasks 4–8
  • ✅ Section 12 (Renderer/UI) → Task 9 (WeaponPanel); beam fx_event emitted but no renderer yet — acceptable for cycle 9
  • ✅ Section 13 (Bible live) → Task 10
  • ✅ Section 14 (Determinism baseline) → Task 11

Placeholder scan: No TBDs found. All code blocks are complete.

Type consistency: EnemyPool.TYPE_* constants used consistently across Tasks 2, 3, 4, 8. ProjPool.add 6-arg signature used in Tasks 4, 5, 6, 7. update_panel(sim: Sim) used in Tasks 9 and main.gd.