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.
Global Constraints
Section titled “Global Constraints”- All
/simfiles extendRefCounted. No Node, Input, Engine, Time, OS, or RandomNumberGenerator APIs. Nopush_errorin/sim(use silent no-op instead). /uiand/renderfiles may extend Node. They may READ sim state but MUST NOT mutate it.- GUT 9.6 treats any un-handled
push_erroras a test failure. Consume expected errors withassert_push_error_count(n)orassert_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.jsonis the committed content contract.data/bible.jsonmust be re-exported vianode tools/design-bible/scripts/export-seed.mjs > data/bible.jsonwheneverseed.jschanges.- 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 EntityPoolwithadd(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 -10Expected: FAIL / script error (ProjPool undefined).
- Step 3: Create
sim/proj_pool.gd
class_name ProjPoolextends EntityPool
var damage: PackedFloat32Arrayvar 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 -10Expected: 4/4 passed.
- Step 5: Commit
git add sim/proj_pool.gd tests/test_proj_pool.gdgit 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:
EnemyPoolwith TYPE_* int constants (0–4), new columnsarmor,speed,contact_dmg,xp_val(PackedFloat32Array),type_id(PackedInt32Array); updatedadd(p, v, r, d, armor_v, speed_v, contact_v, xp_v, tid)with defaults;remove_atswaps 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 -10Expected: new tests fail (columns/constants don’t exist yet).
- Step 3: Replace
sim/enemy_pool.gdwith updated version
class_name EnemyPoolextends EntityPool
const TYPE_SWARMER := 0const TYPE_TANK := 1const TYPE_SHOOTER := 2const TYPE_SPLITTER := 3const TYPE_ELITE := 4
# Aura columns (existing)var aura_element: PackedInt32Arrayvar stacks: PackedInt32Arrayvar aura_remaining: PackedFloat32Array
# Per-enemy stat columns (new)var armor: PackedFloat32Arrayvar speed: PackedFloat32Arrayvar contact_dmg: PackedFloat32Arrayvar xp_val: PackedFloat32Arrayvar 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 -15Expected: all tests pass (old add callers with 4 args still work via defaults).
- Step 5: Commit
git add sim/enemy_pool.gd tests/test_enemy_pool.gdgit 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) -> intreturning anEnemyPool.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 -10Expected: new tests fail (method doesn’t exist yet).
- Step 3: Add
pick_typetosim/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 -10Expected: all spawn_director tests pass.
- Step 5: Commit
git add sim/spawn_director.gd tests/test_spawn_director.gdgit 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),EnemyPoolwith TYPE_* and per-enemy columns (Task 2),SpawnDirector.pick_type(Task 3). -
Produces:
Sim.projectiles: ProjPool,Sim._enemy_types: Array[Dictionary](internal),Sim._damage_enemywith armor reduction. RemovesSim.proj_damageandSim.enemy_radiusas public fields. -
Step 1: Update
sim/weapon_pulse.gdto 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(wasEntityPool)- Remove
proj_damage,enemy_radius,_enemy_hp,_enemy_speed,_contact_dps,_gem_xp - Add
var _enemy_types: Array[Dictionary] = [] _init: build_enemy_types, changeprojectiles = ProjPool.new(...)_spawn_enemies: callspawner.pick_type, look up per-enemy stats_move_enemies: useenemies.speed[i]_resolve_collisions: use per-enemy radius + per-projectile damage/element_damage_enemy: apply armor_sweep_dead: handle splitter, useenemies.xp_val[i]_check_player_hit: per-enemy radius + per-enemy contact_dmgstate_checksum: unchanged structure (still works because enemy columns stay deterministic)
class_name Simextends RefCounted
const ENEMY_CAP: int = 6000const PROJ_CAP: int = 4000const GEM_CAP: int = 4000const HASH_CELL: float = 64.0const SPAWN_RING: float = 1100.0const GEM_RADIUS: float = 8.0const REACTION_BURST_RADIUS: float = 120.0const GENERIC_REACTION_RADIUS: float = 70.0const GENERIC_REACTION_MAGNITUDE: float = 8.0const MAX_ENEMY_RADIUS: float = 26.0 # elite radius — used for broad-phase proj collision
var rng: SeededRngvar upgrade_rng: SeededRngvar player: PlayerStatevar enemies: EnemyPoolvar projectiles: ProjPoolvar gems: EntityPoolvar hash: SpatialHashvar run_time: float = 0.0var kills: int = 0var game_over: bool = falsevar spawner: SpawnDirectorvar weapon: WeaponPulsevar nova: WeaponNovavar nova_element_idx: intvar pulse_element_idx: intvar _spawn_accum: float = 0.0var pending_levelups: int = 0
var fx_events: Array[Dictionary] = []var mods: ModStatevar content: ContentDBvar _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 => deadtests/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 -15Expected: 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 -15Expected: all tests pass (excluding temporarily-commented checksum test if needed).
- Step 7: Commit
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.gdgit 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(readsplayer.pos,player.damage_mult,hash,enemies,content,mods; calls_damage_enemy,_reaction_burst),Elemental.apply. -
Produces:
class_name WeaponOrbit extends RefCountedwithupdate(sim: Sim, dt: float)andcooldown_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 WeaponOrbitextends RefCounted
const ORBIT_RADIUS: float = 120.0const SHARD_HIT_RADIUS: float = 18.0const ORBIT_SPEED_DEG: float = 90.0const SHARD_COUNT: int = 3
var base_damage: floatvar _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: WeaponOrbitvar orbit_element_idx: intIn _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
git add sim/weapon_orbit.gd tests/test_weapon_orbit.gd sim/sim.gdgit commit -m "feat: WeaponOrbit — 3 cold orbital shards, continuous DPS"Task 6: WeaponBeam — pierce laser
Section titled “Task 6: WeaponBeam — pierce laser”Files:
- Create:
sim/weapon_beam.gd - Create:
tests/test_weapon_beam.gd - Modify:
sim/sim.gd(wire beam)
Interfaces:
-
Produces:
class_name WeaponBeam extends RefCountedwithupdate(sim, dt)andcooldown_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 WeaponBeamextends RefCounted
const BEAM_WIDTH: float = 20.0const BEAM_RANGE: float = 900.0
var base_damage: floatvar cooldown: floatvar _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: WeaponBeamvar beam_element_idx: intIn _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
git add sim/weapon_beam.gd tests/test_weapon_beam.gd sim/sim.gdgit commit -m "feat: WeaponBeam — pierce laser, O(n) scan, light element"Task 7: WeaponTurret — kinetic summon
Section titled “Task 7: WeaponTurret — kinetic summon”Files:
- Create:
sim/weapon_turret.gd - Create:
tests/test_weapon_turret.gd - Modify:
sim/sim.gd(wire turret)
Interfaces:
-
Produces:
class_name WeaponTurret extends RefCountedwithupdate(sim, dt)andcooldown_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 WeaponTurretextends RefCounted
const DEPLOY_COOLDOWN: float = 6.0const TURRET_LIFETIME: float = 8.0const MAX_TURRETS: int = 2const FIRE_COOLDOWN: float = 0.4 # matches bible cooldown_sconst PROJ_SPEED: float = 500.0const PROJ_RADIUS: float = 6.0const PROJ_LIFETIME: float = 1.5
var base_damage: floatvar _deploy_timer: float = DEPLOY_COOLDOWN # ready to deploy immediatelyvar _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: WeaponTurretIn _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
git add sim/weapon_turret.gd tests/test_weapon_turret.gd sim/sim.gdgit 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(addenemy_proj: EntityPool,_shooter_timers: Dictionary,_update_shooters,_move_enemy_proj, update_check_player_hit, updatestate_checksum) - Create:
tests/test_shooter_enemy.gd
Interfaces:
-
Produces:
Sim.enemy_proj: EntityPool._check_player_hitnow 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 = 500const SHOOTER_FIRE_INTERVAL: float = 2.0const SHOOTER_PROJ_SPEED: float = 300.0const SHOOTER_PROJ_RADIUS: float = 6.0const SHOOTER_PROJ_LIFETIME: float = 3.0const SHOOTER_PROJ_DAMAGE: float = 6.0var enemy_proj: EntityPoolvar _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_timersUpdate _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 = trueUpdate 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
git add sim/sim.gd tests/test_shooter_enemy.gdgit 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 WeaponPanelextends 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.0const SLOT_H: float = 90.0const SLOT_GAP: float = 16.0const 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— simplifyupdate_panelcall
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 -10Expected: no SCRIPT ERRORs.
- Step 5: Commit
git add ui/weapon_panel.gd main.gdgit 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: truein 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 -10Expected: 31 tests pass.
- Step 3: Re-export
data/bible.json
node tools/design-bible/scripts/export-seed.mjs > data/bible.jsonVerify new content is present:
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 -15Expected: all tests pass (ContentLoader ignores unknown fields like live; existing loader validation is unaffected).
- Step 5: Commit
cd /Users/chris/Claude/bullet-heavengit add tools/design-bible/src/seed.js data/bible.jsongit commit -m "feat: mark 7 bible entries live — orbit, beam, turret, tank, shooter, splitter, elite"Task 11: Determinism baseline update
Section titled “Task 11: Determinism baseline update”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 -10Expected: 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 -20The 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 SceneTreefunc _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 -20Expected: 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
git add tests/test_determinism_checksum.gd CLAUDE.mdgit commit -m "chore: update determinism baseline checksums for cycle 9 content"Self-review
Section titled “Self-review”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.