Skip to content

Combat Depth, New Content & Balance — Implementation Plan

Combat Depth, New Content & Balance — Implementation Plan

Section titled “Combat Depth, New Content & Balance — 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 combat depth (player thruster, relentless enemy dash, 5-attack boss), content (5 weapon-mirroring enemies, per-enemy variation, archetype silhouettes), tutorial/mode separation, and a balance pass — to the Godot bullet-heaven game.

Architecture: Pure-logic /sim (RefCounted, no Node/Engine APIs) ticks on fixed Sim_Const.DT; render/UI read sim state. Determinism keystone: survival baseline (seed 1234, 600 ticks, blade-only) is byte-identical unless a change touches the blade-only <10s window. New content is time-gated or input-gated so most of it is determinism-neutral; per-enemy variation + tank escorts are batched into a final re-pin.

Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 (headless tests), data-driven content in data/bible.json.

  • /sim purity: every file under sim/ extends RefCounted and uses NO Node/render/Input/Engine/Time/File/JSON APIs. Loaders/renderers live outside /sim.
  • Determinism: sim ticks on Sim_Const.DT (1/60), all randomness via sim.rng (spawns/sim) — NEVER upgrade_rng for spawns. Survival baseline pinned in tests/test_determinism_checksum.gd: snapshot_string().hash() = 4152236597, state_checksum() = 1267954985.
  • Per-chunk ritual: every task is one bh-dev-chunk cycle. Its verify block (run verbatim, in order) is:
    1. Full suite: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit → exit 0.
    2. Test-count guard: bash scripts/check-test-count.sh → must NOT report fewer scripts than tests/test_*.gd files.
    3. If a class_name was added in a NEW directory: godot --headless --import first (stale-class-cache trap).
    4. Boot smoke: godot --headless --path . --quit-after 120 2>&1 | grep -i "SCRIPT ERROR" → NO output. (Note: macOS has no timeout; do not wrap.)
    5. Determinism: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit → for neutral tasks expect PASS unchanged; for the re-pin task, capture new values and update the test + CLAUDE.md.
  • Content edits: hand-edit data/bible.json (a tab-indented python3 JSON round-trip — load, mutate data.weapons/data.enemies/data.mods/data.meta_upgrades, json.dump(indent='\t', ensure_ascii=False)). Do NOT re-export from seed.js (it has drifted; re-export would drop blade/skirmisher/scatter/brute).
  • Build number: bump Sim_Const.BUILD only when a chunk is DEPLOYED via bh-deploy, and mirror it in the site changelog. Do not bump per task.
  • Invisible-entity rule: every new entity pool needs a renderer in main.gd; every new fx_events kind needs a match arm in fx/fx_manager.gd (or a dedicated renderer).
  • No em-dashes / AI-tells apply only to player-facing marketing copy, not code or in-game flavor.

Path Responsibility Tasks
sim/sim.gd sim constants, tick order, all new mechanics most
sim/player_state.gd player stats incl. dash fields 4
sim/input_state.gd add dash edge 4
sim/enemy_pool.gd new TYPE_*, BEHAVIOR_RUSH 5,6
sim/enemy_proj_pool.gd (new) enemy projectiles with per-shot damage 6
sim/stat_effects.gd thrusters in-run stat mod 4
sim/spawn_director.gd new spawn bands, tank escorts 6,11
sim/boss2_state.gd (new) 5-attack boss state machine 7,8
sim/meta_state.gd / bible Thrusters meta upgrade 4
data/bible.json new enemies, weapons tune, mods, meta 4,5,6,9,11
fx/damage_numbers.gd thresholds (in sim.gd) numbers show 1
ui/start_menu.gd, main.gd TUTORIAL card, no-pause story 2
render/archetype_renderer.gd (new) per-shape enemy silhouettes 3
render/boss2_renderer.gd (new) new boss + rockets/beam render 8
tests/test_*.gd one per task all

Task 1: Damage numbers show for ordinary hits

Section titled “Task 1: Damage numbers show for ordinary hits”

Files:

  • Modify: sim/sim.gd:109-111 (DMGNUM_* constants)
  • Test: tests/test_damage_numbers.gd (new)

Interfaces:

  • Produces: nothing new; only constant values change. _damage_enemy still emits {"kind":"dmgnum","pos","amount","big"} into fx_events.

  • Step 1: Write the failing test

extends GutTest
# A small hit (below the OLD 7.0 threshold, above the new 2.0) must emit a dmgnum.
func test_small_hit_emits_dmgnum() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var sim := Sim.new(1, content)
# spawn one armor-0 enemy with plenty of HP so it survives the hit
sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 50.0, 0.0, 0.0, 0.0, 1.0, EnemyPool.TYPE_SWARMER, -1)
sim.fx_events.clear()
sim._dmgnum_count = 0
sim._damage_enemy(0, 3.0) # 3 dmg: was below MIN(7), now above MIN(2)
var found := false
for e in sim.fx_events:
if e.get("kind", "") == "dmgnum":
found = true
assert_true(found, "a 3-damage hit should float a number now")
func test_min_threshold_value() -> void:
assert_eq(Sim.DMGNUM_MIN, 2.0)
assert_eq(Sim.DMGNUM_CAP, 28)
assert_eq(Sim.DMGNUM_BIG, 14.0)
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_damage_numbers.gd -gexit Expected: FAIL (DMGNUM_MIN is 7.0, no dmgnum for a 3-damage hit).

  • Step 3: Change the constants

In sim/sim.gd replace lines 109-111 with:

const DMGNUM_MIN: float = 2.0 # min dealt damage to float a number (ordinary hits show)
const DMGNUM_BIG: float = 14.0 # at/above this it's a "big" number (flashing, large)
const DMGNUM_CAP: int = 28 # max numbers emitted per tick
  • Step 4: Run the test, expect PASS

Run: same as Step 2. Expected: PASS.

  • Step 5: Run the per-chunk ritual (Global Constraints) — full suite, test-count, boot smoke, determinism UNCHANGED (4152236597/1267954985; fx_events are excluded from the checksum). Commit:
Terminal window
git add sim/sim.gd tests/test_damage_numbers.gd
git commit -m "feat(fx): damage numbers show for ordinary hits (DMGNUM_MIN 7->2)"

Task 2: Tutorial / Story / Survival mode separation (no story-dialogue pauses)

Section titled “Task 2: Tutorial / Story / Survival mode separation (no story-dialogue pauses)”

Files:

  • Modify: ui/start_menu.gd (add TUTORIAL card; mode_chosen carries a String)
  • Modify: main.gd (_mode field; story-dialogue freeze gated to tutorial; story start flags)
  • Test: tests/test_start_menu_modes.gd (new)

Interfaces:

  • Produces: StartMenu.signal mode_chosen(mode: String) where mode ∈ "tutorial"|"story"|"survival". main._on_mode_chosen(mode: String).

  • Step 1: Write the failing test (the menu exposes three modes)

extends GutTest
func test_menu_emits_three_modes() -> void:
var menu := StartMenu.new()
add_child_autofree(menu)
await get_tree().process_frame
# _cards is built in _ready; assert three cards with the right meta ids
assert_eq(menu._cards.size(), 3, "tutorial / story / survival")
var ids: Array = []
for c in menu._cards:
ids.append(String(c.get_meta("mode", "")))
assert_true(ids.has("tutorial"))
assert_true(ids.has("story"))
assert_true(ids.has("survival"))
  • Step 2: Run it, expect FAIL (_cards.size() is 2; no mode meta).

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_start_menu_modes.gd -gexit

  • Step 3: Update ui/start_menu.gd

Change the signal to signal mode_chosen(mode: String). In _make_card, take an id: String, card.set_meta("mode", id), and card.pressed.connect(func() -> void: _choose(id)). Replace the two _make_card("story", ...) / _make_card("survival", ...) calls with three:

box.add_child(_make_card("tutorial", "TUTORIAL", "Learn the ropes — guided, paused", Color(0.55, 0.95, 0.6)))
box.add_child(_make_card("story", "STORY", "Ascend the Spectrum — the campaign", Color(1.0, 0.6, 0.3)))
box.add_child(_make_card("survival", "SURVIVAL", "Endless waves — score attack", NeonTheme.CYAN))

Change _choose(story: bool)_choose(mode: String) emitting mode_chosen.emit(mode). In _input, the confirm path becomes _choose(String(_cards[_sel].get_meta("mode", "survival"))).

  • Step 4: Run the test, expect PASS.

  • Step 5: Wire main.gd to the three modes

Replace _story_mode: bool usage with var _mode: String = "survival". Update _on_mode_chosen(mode: String) to store _mode = mode. Add a helper func _is_story() -> bool: return _mode == "tutorial" or _mode == "story". In the run-start block (around main.gd:95-123):

var is_tutorial := _mode == "tutorial"
# fresh randomized seed for STORY (not tutorial), as today
if _mode == "story":
run_seed = randi()
...
if _is_story():
var sdata := StoryLoader.load_from_path("res://data/story.json")
if sdata != null:
# TUTORIAL: taught + fixed. STORY: always suppress tutorials + randomize.
sim.enable_story(sdata, not is_tutorial, not is_tutorial)
...

Gate the dialogue freeze (around main.gd:323-324) to tutorial only:

# Freeze ONLY in the tutorial so the player can read; the main story never pauses.
if _mode == "tutorial" and sim.story != null and dialogue_box != null and dialogue_box.is_showing():
... (existing freeze body)

Update _auto = not _is_story() (was not _story_mode) and the telemetry var mode := "story" if _is_story() else "survival" (tutorial reports as story-family for now). The tutorial_done set (main.gd:355-357) stays as-is (reaching region 2 / completing flips it).

  • Step 6: Run the per-chunk ritual. Determinism UNCHANGED (flow/render only). Boot smoke especially (headless auto-starts survival). Commit:
Terminal window
git add ui/start_menu.gd main.gd tests/test_start_menu_modes.gd
git commit -m "feat(modes): TUTORIAL/STORY/SURVIVAL menu; story drops dialogue pauses"

Task 3: Visual archetype differentiation (per-shape silhouettes)

Section titled “Task 3: Visual archetype differentiation (per-shape silhouettes)”

Files:

  • Create: render/archetype_renderer.gd (ArchetypeRenderer extends Node2D)
  • Modify: main.gd (replace the enemy SwarmRenderer use with ArchetypeRenderer; keep gems/proj on SwarmRenderer)
  • Test: tests/test_archetype_renderer.gd (new)

Interfaces:

  • Produces: ArchetypeRenderer.sync(enemies: EnemyPool, type_colors: PackedColorArray, type_to_shape: PackedInt32Array); ArchetypeRenderer.SHAPE_COUNT: int; ArchetypeRenderer.shape_for(type_id: int) -> int. Buckets enemies into one MultiMeshInstance2D per shape; total instances across buckets == enemies.count.

  • Consumes: EnemyPool.type_id, .pos, .radius, .aura_element (existing).

  • Step 1: Write the failing test (partition correctness — the headless-safe property)

extends GutTest
func test_partition_counts_match() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var r := ArchetypeRenderer.new()
add_child_autofree(r)
await get_tree().process_frame
var enemies := EnemyPool.new(64)
enemies.add(Vector2.ZERO, Vector2.ZERO, 14, 3, 0, 0, 0, 1, EnemyPool.TYPE_SWARMER, -1)
enemies.add(Vector2(50, 0), Vector2.ZERO, 22, 85, 0, 0, 0, 1, EnemyPool.TYPE_TANK, -1)
enemies.add(Vector2(80, 0), Vector2.ZERO, 14, 8, 0, 0, 0, 1, EnemyPool.TYPE_SHOOTER, -1)
var colors := PackedColorArray()
colors.resize(16); colors.fill(Color.WHITE)
var shape_lut := PackedInt32Array()
shape_lut.resize(16)
for t in range(16):
shape_lut[t] = r.shape_for(t)
r.sync(enemies, colors, shape_lut)
var total := 0
for mm in r._meshes: # one MultiMeshInstance2D per shape
total += mm.multimesh.instance_count
assert_eq(total, enemies.count, "every enemy lands in exactly one shape bucket")
func test_distinct_shapes() -> void:
var r := ArchetypeRenderer.new()
assert_ne(r.shape_for(EnemyPool.TYPE_SWARMER), r.shape_for(EnemyPool.TYPE_TANK))
assert_ne(r.shape_for(EnemyPool.TYPE_SHOOTER), r.shape_for(EnemyPool.TYPE_SPIDER))
  • Step 2: Run it, expect FAIL (class doesn’t exist).

  • Step 3: Implement render/archetype_renderer.gd

Build N MultiMeshInstance2D children, each with a distinct polygon mesh (triangle, hexagon, diamond, chevron, spiky-star, asterisk, + shapes for the five new types). shape_for(type_id) maps each EnemyPool.TYPE_* to a shape index (a static lookup; new types map to their own shapes). sync() clears per-bucket counts, then for each enemy computes its shape bucket, appends a Transform2D(Vector2(s,0),Vector2(0,s),pos) (s = radius / base) and the element-or-type colour. Use BLEND_MODE_ADD halo handling consistent with the existing SwarmRenderer (a halo bucket per shape, or reuse the existing halo pass over a circle bucket). Configure core meshes with Color.WHITE (let per-instance colour drive — the modulate-squaring gotcha).

  • Step 4: Run the test, expect PASS.

  • Step 5: Wire main.gd — replace the enemy-pool SwarmRenderer instance with ArchetypeRenderer, building the type_to_shape LUT once at run start and passing _enemy_colors()/_enemy_scales() data through sync. Keep gems + projectiles on SwarmRenderer (uniform circles).

  • Step 6: Per-chunk ritual. class_name in NEW dir? No (render/ exists), but run --import anyway if GUT can’t find ArchetypeRenderer. Determinism UNCHANGED (render-only). Then update the site legend in ~/Claude/bullet-heaven-site/index.html Enemies section to the new silhouettes (separate repo; note it, do not deploy here). Commit:

Terminal window
git add render/archetype_renderer.gd main.gd tests/test_archetype_renderer.gd
git commit -m "feat(render): per-archetype enemy silhouettes (shape per type)"

Task 4: Player thruster (dash/dodge) + upgrades; new enemy “Rusher” dash

Section titled “Task 4: Player thruster (dash/dodge) + upgrades; new enemy “Rusher” dash”

Files:

  • Modify: sim/input_state.gd (add dash)
  • Modify: sim/player_state.gd (dash fields)
  • Modify: sim/sim.gd (_update_dash, i-frame gate in _check_player_hit/_boss_*; BEHAVIOR_RUSH step)
  • Modify: sim/enemy_pool.gd (BEHAVIOR_RUSH const + RUSH phases)
  • Modify: sim/stat_effects.gd (thrusters effect)
  • Modify: data/bible.json (stat mod thrusters; meta upgrade thrusters; a rusher enemy or rush params)
  • Modify: render: input/input_router.gd (bind a dash button); ui/hud.gd (ready pip)
  • Test: tests/test_thruster.gd, tests/test_rusher.gd (new)

Interfaces:

  • Produces: InputState.dash: bool; PlayerState.dash_cd, dash_timer, dash_dir, iframe_timer, dash_cooldown_mult, dash_iframe_bonus; Sim.DASH_COOLDOWN/DASH_SPEED/DASH_TIME/DASH_IFRAMES; Sim.is_invulnerable() -> bool; EnemyPool.BEHAVIOR_RUSH=4, RUSH_AIM=0, RUSH_BURST=1.

  • Consumes: StatEffects.TABLE vocabulary (existing dispatch).

  • Step 1 (thruster): failing test

extends GutTest
func _content() -> ContentDB:
return ContentLoader.load_from_path("res://data/bible.json")
func test_dash_bursts_player_and_sets_iframes() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
var before := sim.player.pos
var input := InputState.new(Vector2.RIGHT, Vector2.ZERO, false)
input.dash = true
sim.tick(input)
assert_gt(sim.player.pos.x, before.x + 5.0, "dash moved the player notably right")
assert_true(sim.is_invulnerable(), "i-frames active right after dashing")
func test_dash_respects_cooldown() -> void:
var sim := Sim.new(1, _content())
var input := InputState.new(Vector2.RIGHT, Vector2.ZERO, false)
input.dash = true
sim.tick(input) # dash 1 fires
var x1 := sim.player.pos.x
# hold dash next tick — still on cooldown, no second burst beyond normal move
var x2_before := sim.player.pos.x
sim.tick(input)
assert_lt(sim.player.pos.x - x2_before, Sim.DASH_SPEED * Sim_Const.DT * 0.5, "no re-dash while on cooldown")
  • Step 2: Run it, expect FAIL (InputState has no dash; is_invulnerable undefined).

  • Step 3: Implement.

    • sim/input_state.gd: add var dash: bool = false and a 4th _init param dash_pressed := false.
    • sim/player_state.gd: add var dash_cd: float = 0.0, dash_timer: float = 0.0, dash_dir: Vector2 = Vector2.RIGHT, iframe_timer: float = 0.0, dash_cooldown_mult: float = 1.0, dash_iframe_bonus: float = 0.0.
    • sim/sim.gd: constants DASH_COOLDOWN=1.5, DASH_SPEED=900.0, DASH_TIME=0.16, DASH_IFRAMES=0.18. Add func _update_dash(input, dt): decrement dash_cd/iframe_timer/dash_timer; on input.dash and player.dash_cd <= 0.0 start a dash (set dash_dir from input.move_dir if non-zero else keep last, dash_timer = DASH_TIME, dash_cd = DASH_COOLDOWN * player.dash_cooldown_mult, iframe_timer = DASH_IFRAMES + player.dash_iframe_bonus); while dash_timer > 0 add player.pos += player.dash_dir * DASH_SPEED * dt. Call _update_dash(input, dt) right after player.integrate(input, dt) in tick(). Add func is_invulnerable() -> bool: return player.iframe_timer > 0.0. In _check_player_hit, wrap the contact + projectile damage in if not is_invulnerable(): (skip both when dashing). In _boss_swing_hit/_update_boss_missiles, guard _hurt_player with if not is_invulnerable():.
    • sim/stat_effects.gd: add a thrusters effect mapping to player.dash_cooldown_mult *= mag (and a small dash_iframe_bonus bump). Add the stat-mod thrusters to bible.json data.mods (kind stat, effect thrusters, magnitude 0.85).
    • render: input/input_router.gd bind a dash action (Space + JOY_BUTTON_RIGHT_SHOULDER; tvOS: a free Siri-remote button) and set input.dash in poll(). ui/hud.gd: a small “thruster ready” pip from 1.0 - player.dash_cd / (Sim.DASH_COOLDOWN * player.dash_cooldown_mult).
  • Step 4: Run the thruster test, expect PASS.

  • Step 5 (rusher): failing test

extends GutTest
func test_rusher_advances_without_long_charge() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var sim := Sim.new(1, content)
sim.player.pos = Vector2.ZERO
# spawn a rusher far to the right, behaviour RUSH
var i := sim.enemies.add(Vector2(400, 0), Vector2.ZERO, 12, 20, 0, 200, 8, 1, EnemyPool.TYPE_SWARMER, -1, EnemyPool.BEHAVIOR_RUSH)
var start_x := sim.enemies.pos[i].x
# step a few ticks; a rusher should close distance quickly with no idle pause
for _n in range(20):
sim._step_rush(i, Sim_Const.DT)
assert_lt(sim.enemies.pos[i].x, start_x - 30.0, "rusher closed distance fast, no long charge wait")
  • Step 6: Implement the rusher. sim/enemy_pool.gd: const BEHAVIOR_RUSH := 4, const RUSH_AIM := 0, const RUSH_BURST := 1. sim/sim.gd: add a BEHAVIOR_RUSH branch in _move_enemies calling _step_rush(i, dt). _step_rush: in RUSH_AIM briefly steer toward the player (re-aim ~0.12s) then latch a burst velocity into vel and switch to RUSH_BURST; in RUSH_BURST move along vel for rush_burst_s (~0.35s) then back to RUSH_AIM immediately (no recharge). Read rush_speed/rush_burst_s/rush_aim_s per-type (constants for now; bible later). Map behavior:"rush" in _build_enemy_types.

  • Step 7: Run the rusher test, expect PASS.

  • Step 8: Meta upgrade. Add to bible.json data.meta_upgrades a thrusters entry (type omitted/null = stat, effect: "thrusters", base_cost: 60, cost_growth: 1.6, max_level: 4, magnitude: 0.9). MetaState.apply_to already routes through StatEffects, so no MetaState code change.

  • Step 9: Per-chunk ritual. Determinism UNCHANGED — verify carefully: baseline has dash=false (no burst, iframe_timer stays 0 so the hit-skip never branches), and no BEHAVIOR_RUSH enemies spawn in the baseline. Expect 4152236597/1267954985. Commit:

Terminal window
git add sim/input_state.gd sim/player_state.gd sim/sim.gd sim/enemy_pool.gd sim/stat_effects.gd data/bible.json input/input_router.gd ui/hud.gd tests/test_thruster.gd tests/test_rusher.gd
git commit -m "feat(combat): player thruster (dash + i-frames + upgrades) and no-pause Rusher enemy dash"

Task 5: Enemy-projectile damage column (EnemyProjPool)

Section titled “Task 5: Enemy-projectile damage column (EnemyProjPool)”

Files:

  • Create: sim/enemy_proj_pool.gd (EnemyProjPool extends EntityPool, adds damage column)
  • Modify: sim/sim.gd (enemy_proj becomes EnemyProjPool; enemy_proj.add(...) callers pass damage; _check_player_hit uses per-shot damage)
  • Test: tests/test_enemy_proj_pool.gd (new)

Interfaces:

  • Produces: EnemyProjPool.add(p: Vector2, v: Vector2, r: float, life: float, dmg: float) -> int and var damage: PackedFloat32Array. remove_at swaps damage in lockstep.

  • Consumes: EntityPool base (pos/vel/radius/data=life).

  • Step 1: Failing test

extends GutTest
func test_damage_swaps_in_lockstep() -> void:
var p := EnemyProjPool.new(8)
p.add(Vector2.ZERO, Vector2.ZERO, 6, 3.0, 5.0)
p.add(Vector2(10, 0), Vector2.ZERO, 6, 3.0, 11.0)
p.remove_at(0) # swap-remove brings last into slot 0
assert_eq(p.count, 1)
assert_almost_eq(p.damage[0], 11.0, 0.001, "damage followed pos on swap-remove")
  • Step 2: Run it, expect FAIL (class missing).

  • Step 3: Implement sim/enemy_proj_pool.gdextends EntityPool, var damage: PackedFloat32Array, override _init(cap) to super._init(cap); damage.resize(cap), add(p,v,r,life,dmg)var i := super.add(p,v,r,life); if i != -1: damage[i] = dmg; return i, and remove_at(i) swaps damage[i] = damage[last] before super.remove_at(i).

  • Step 4: Run the test, expect PASS.

  • Step 5: Swap the pool in sim/sim.gd. Change enemy_proj = EntityPool.new(ENEMY_PROJ_CAP)EnemyProjPool.new(ENEMY_PROJ_CAP). Update EVERY enemy_proj.add(...) call (shooter, skirmisher, boss barrage, boss spiral) to pass a damage arg: shooters/skirmishers pass SHOOTER_PROJ_DAMAGE; boss barrage/spiral pass their own (keep current SHOOTER_PROJ_DAMAGE to preserve behavior). In _check_player_hit, replace _hurt_player(SHOOTER_PROJ_DAMAGE) with if not is_invulnerable(): _hurt_player(enemy_proj.damage[ep]).

  • Step 6: Per-chunk ritual. Determinism UNCHANGED — enemy_proj is empty in the blade-only baseline (no shooters in <10s), and the column doesn’t touch pos/vel. Expect 4152236597/1267954985. Commit:

Terminal window
git add sim/enemy_proj_pool.gd sim/sim.gd tests/test_enemy_proj_pool.gd
git commit -m "feat(sim): EnemyProjPool with per-shot damage column"

Task 6: Five weapon-mirroring enemy archetypes

Section titled “Task 6: Five weapon-mirroring enemy archetypes”

Files:

  • Modify: data/bible.json (5 enemies: zapper/bomber/orbiter/lancer/scatterer)
  • Modify: sim/enemy_pool.gd (TYPE_ZAPPER=9..TYPE_SCATTERER=13)
  • Modify: sim/sim.gd (_build_enemy_types resize→14, name→tid map, attack steps, boss_rockets-style AoE for bomber, beam-line for lancer, orbit shards for orbiter; render LUT)
  • Modify: sim/spawn_director.gd (spawn bands, gated ≥ ~30–60s)
  • Modify: main.gd (_build_enemy_type_colors resize to TYPE_SCATTERER+1; shapes in ArchetypeRenderer)
  • Test: tests/test_new_enemies.gd (new)

Interfaces:

  • Produces: EnemyPool.TYPE_ZAPPER/TYPE_BOMBER/TYPE_ORBITER/TYPE_LANCER/TYPE_SCATTERER; behaviour steps _step_zapper/_step_bomber/_step_orbiter/_step_lancer/_step_scatterer (or a shared _step_ranged with a per-type pattern). New fx_events kinds as needed (bomb_warn, enemy_beam) each with a renderer.

  • Consumes: EnemyProjPool (Task 5), enemies.entity_id for fire timers.

  • Step 1: Failing test (each new type spawns and at least one fires)

extends GutTest
func _sim() -> Sim:
return Sim.new(7, ContentLoader.load_from_path("res://data/bible.json"))
func test_zapper_fires_a_bolt() -> void:
var sim := _sim()
sim.player.pos = Vector2.ZERO
sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 12, 8, 0, 120, 8, 1, EnemyPool.TYPE_ZAPPER, sim.content.element_index("lightning"), EnemyPool.BEHAVIOR_RUSH)
# advance enough ticks for one fire interval
for _n in range(200):
sim.tick(InputState.new())
if sim.enemy_proj.count > 0:
break
assert_gt(sim.enemy_proj.count, 0, "zapper emitted at least one bolt")
func test_all_new_types_build() -> void:
var sim := _sim()
for tid in [EnemyPool.TYPE_ZAPPER, EnemyPool.TYPE_BOMBER, EnemyPool.TYPE_ORBITER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_SCATTERER]:
assert_false(sim._enemy_types[tid].is_empty(), "type %d has a bible entry" % tid)
  • Step 2: Run it, expect FAIL (types undefined / _enemy_types too small).

  • Step 3: Add bible entries (hand-edit data/bible.json, per Global Constraints). Each needs a unique id, valid element ref, hp/speed/contact_damage/armor/radius/xp_value/color, and behavior + any attack params (fire_interval, proj_speed, proj_damage, pellets/spread for scatterer, beam_range/beam_charge for lancer, bomb_radius/bomb_delay for bomber, orbit_shards/orbit_radius for orbiter). Speeds: zapper fast (~130), bomber slow (~40), orbiter medium (~70), lancer slow (~45), scatterer medium (~80).

  • Step 4: Add type ids + build table. sim/enemy_pool.gd: const TYPE_ZAPPER := 9TYPE_SCATTERER := 13. sim/sim.gd: _enemy_types.resize(14), assign each from content.enemy(...), extend _ENEMY_NAME_TO_TID, resize the per-type arrays accordingly. Implement each attack: zapper/scatterer fire via enemy_proj.add(...,proj_damage); bomber lobs a delayed AoE (an entry in a new bombs: Array[Dictionary] with {pos, delay, radius, dmg}, telegraph via bomb_warn fx, on expiry damage the player if inside + emit a burst); lancer charges then does a line-vs-player check during an active window (enemy_beam fx); orbiter spins shards tracked render-side (positions recomputed each tick, NOT in checksum) that damage the player on contact. Add fire timers keyed by enemies.entity_id (mirror _update_shooters).

  • Step 5: Spawn bands. sim/spawn_director.pick_type: add the new types to the ≥ ~30–60s bands only (keep the <10s and <20s bands as-is so the baseline window is untouched). Keep weights modest.

  • Step 6: Render. main.gd._build_enemy_type_colors() resize to EnemyPool.TYPE_SCATTERER + 1, add colours; add shapes for the five in ArchetypeRenderer.shape_for. Add renderers/match arms for any new fx_events kind (bomb_warn, enemy_beam) and a renderer for orbiter shards + bomber AoE ring (invisible-entity rule).

  • Step 7: Run the test, expect PASS.

  • Step 8: Per-chunk ritual. Determinism UNCHANGED (all five spawn ≥30s, none in the <10s baseline; enemy_proj per Task 5). Expect 4152236597/1267954985. Commit:

Terminal window
git add data/bible.json sim/enemy_pool.gd sim/sim.gd sim/spawn_director.gd main.gd render/ tests/test_new_enemies.gd
git commit -m "feat(enemies): five weapon-mirroring archetypes (zapper/bomber/orbiter/lancer/scatterer)"

Task 7: New boss — state machine + Cutter, Charge slam, Summon (3 of 5)

Section titled “Task 7: New boss — state machine + Cutter, Charge slam, Summon (3 of 5)”

Files:

  • Create: sim/boss2_state.gd (Boss2State extends RefCounted)
  • Modify: sim/enemy_pool.gd (TYPE_BOSS2 := 14, reuse BEHAVIOR_BOSS)
  • Modify: sim/sim.gd (boss2: Boss2State, _boss2_index, _spawn_boss2, _update_boss2, attack impls; survival boss alternation)
  • Test: tests/test_boss2.gd (new)

Interfaces:

  • Produces: Boss2State with ATTACK_CUTTER=0, ATTACK_ARTILLERY=1, ATTACK_RINGS=2, ATTACK_CHARGE=3, ATTACK_SUMMON=4, ATTACK_COUNT=5, phase, timer, attack_idx, enraged, max_hp, reset(), pick_random_attack(rng). Sim._spawn_boss2(pos, hp_mult), Sim._update_boss2(dt), Sim.boss2_render_info() -> Dictionary.

  • Consumes: EnemyProjPool, _hurt_player, is_invulnerable.

  • Step 1: Failing test (random selection over the 5, and a Cutter/Charge/Summon lands)

extends GutTest
func test_pick_random_covers_all_attacks() -> void:
var b := Boss2State.new()
var rng := SeededRng.new(123)
var seen := {}
for _n in range(400):
seen[b.pick_random_attack(rng)] = true
assert_eq(seen.size(), Boss2State.ATTACK_COUNT, "all 5 attacks reachable")
func test_summon_spawns_adds() -> void:
var sim := Sim.new(3, ContentLoader.load_from_path("res://data/bible.json"))
sim._spawn_boss2(Vector2(300, 0), 1.0)
var before := sim.enemies.count
sim.boss2.attack_idx = Boss2State.ATTACK_SUMMON
sim._boss2_fire(Boss2State.ATTACK_SUMMON, Vector2(300, 0))
assert_gt(sim.enemies.count, before, "summon added minions")
  • Step 2: Run it, expect FAIL (Boss2State missing).

  • Step 3: Implement sim/boss2_state.gd mirroring BossState (phases APPROACH/TELEGRAPH/ACTIVE/REST) but attack_idx chosen by pick_random_attack(rng) at the start of each cycle (in REST→APPROACH transition), and ATTACK_COUNT = 5. pick_random_attack(rng) -> int: return rng.randi_range(0, ATTACK_COUNT - 1).

  • Step 4: Implement boss2 in sim.gdvar boss2: Boss2State = Boss2State.new(), constants for boss2 (BOSS2_HP, telegraph/active/rest, per-attack params). _boss2_index() scans for TYPE_BOSS2. _spawn_boss2(pos, hp_mult) adds a pooled TYPE_BOSS2/BEHAVIOR_BOSS enemy and boss2.reset(). _update_boss2(dt) drives phases (random attack at each cycle). _boss2_fire(idx, bpos) implements Cutter (telegraph a rotating beam whose angular speed ramps, then a line-vs-player hit during the active window via enemy_beam fx), Charge slam (telegraph a line, then dash the boss across applying heavy path damage; guard with is_invulnerable), Summon (spawn a small cluster of swarmers/spiders near the boss). Wire _update_boss2(dt) into tick() next to _update_boss(dt).

  • Step 5: Survival alternation. In _update_boss spawn-gate, alternate which boss spawns (a _boss_spawn_count counter → even = Warden via _spawn_boss, odd = _spawn_boss2). Ensure both are mutually exclusive (don’t spawn boss2 while a boss exists, and vice-versa) by checking _boss_index() == -1 and _boss2_index() == -1.

  • Step 6: Run the test, expect PASS.

  • Step 7: Per-chunk ritual. Determinism UNCHANGED (boss2 time-gated like the Warden, ≥40s; baseline is 10s). Expect 4152236597/1267954985. (Renderer comes in Task 8 — until then boss2 is invisible; acceptable mid-chunk, fixed next task. Note it in the commit.) Commit:

Terminal window
git add sim/boss2_state.gd sim/enemy_pool.gd sim/sim.gd tests/test_boss2.gd
git commit -m "feat(boss): new 5-attack boss state machine + Cutter/Charge/Summon (render in next task)"

Task 8: New boss — Artillery rockets + shrapnel, Shockwave rings, and render

Section titled “Task 8: New boss — Artillery rockets + shrapnel, Shockwave rings, and render”

Files:

  • Modify: sim/sim.gd (boss_rockets: Array[Dictionary], _update_boss_rockets, Artillery + Rings in _boss2_fire)
  • Create: render/boss2_renderer.gd (Boss2Renderer extends Node2D)
  • Modify: main.gd (instantiate Boss2Renderer, feed boss2_render_info() + rockets)
  • Modify: fx/fx_manager.gd if new fx kinds (rocket, shrapnel, boss_beam) need arms
  • Test: tests/test_boss2_artillery.gd (new)

Interfaces:

  • Produces: boss_rockets entries {pos, target, phase, timer, ...} with phases ascend→hang→land→shrapnel; Sim._update_boss_rockets(dt); Sim.rocket_render_info() -> Array (render-read, EXCLUDED from checksum). Boss2Renderer.sync(info, rockets).

  • Step 1: Failing test (a landed rocket emits shrapnel and can hurt the player at the landing point)

extends GutTest
func test_rocket_lands_and_throws_shrapnel() -> void:
var sim := Sim.new(5, ContentLoader.load_from_path("res://data/bible.json"))
sim._spawn_boss2(Vector2(300, 0), 1.0)
sim.boss2.attack_idx = Boss2State.ATTACK_ARTILLERY
sim._boss2_fire(Boss2State.ATTACK_ARTILLERY, Vector2(300, 0))
assert_gt(sim.boss_rockets.size(), 0, "artillery launched rockets")
# fast-forward each rocket through ascend+hang+land
for _n in range(600):
sim._update_boss_rockets(Sim_Const.DT)
if sim.enemy_proj.count > 0:
break
assert_gt(sim.enemy_proj.count, 0, "a landed rocket threw shrapnel into enemy_proj")
  • Step 2: Run it, expect FAIL (boss_rockets/_update_boss_rockets missing).

  • Step 3: Implement Artillery + Rings. In _boss2_fire: Artillery appends N rockets to boss_rockets, each {pos: bpos, target: player.pos + rng offset, phase: ascend, timer, altitude} — random landing points around the player (rng.rand_unit_dir() * radius). _update_boss_rockets(dt) advances phases (ascend raises a fake altitude + timer; hang briefly; land → emit a shrapnel radial burst into enemy_proj with per-shot damage + a shrapnel fx; if the player is within the blast radius and not invulnerable, _hurt_player). Shockwave rings fires concentric enemy_proj rings (a few rings, each a ring of shots with a gap). Add _update_boss_rockets(dt) to tick().

  • Step 4: Run the test, expect PASS.

  • Step 5: Render render/boss2_renderer.gd — draw the boss body (distinct from the Warden), the rocket altitude trick (a sprite that shrinks as altitude rises, grows on descent), ground target rings during hang, the accelerating Cutter beam telegraph + active beam, the charge-slam telegraph line. Wire in main.gd (only meaningful when boss2_render_info().alive). Add match arms / renderers for any new fx kind. Audit the invisible-entity rule for boss2 now (Task 7’s boss becomes visible here).

  • Step 6: Per-chunk ritual. Determinism UNCHANGED (time-gated). Boot smoke + a manual playtest of the boss (F5) to verify all 5 attacks render. Expect 4152236597/1267954985. Commit:

Terminal window
git add sim/sim.gd render/boss2_renderer.gd main.gd fx/fx_manager.gd tests/test_boss2_artillery.gd
git commit -m "feat(boss): Artillery rockets+shrapnel, Shockwave rings, and new-boss renderer"

Task 9: Balance — armor floor, non-blade weapon damage, gold, meta cost (determinism-neutral)

Section titled “Task 9: Balance — armor floor, non-blade weapon damage, gold, meta cost (determinism-neutral)”

Files:

  • Modify: sim/sim.gd:953 (armor floor); BOSS_GOLD; per-type gold bonus in _sweep_dead
  • Modify: data/bible.json (non-blade weapon base_damage; meta cost_growth caps)
  • Test: tests/test_balance.gd (new)

Interfaces:

  • Produces: armor floor constant behavior change; Sim.BOSS_GOLD = 40; bible weapon/meta numbers.

  • Step 1: Failing test (armored enemies now take ≥25% of a chip hit; blade-vs-armor-0 unchanged)

extends GutTest
func test_armor_floor_lets_chip_through() -> void:
var sim := Sim.new(1, ContentLoader.load_from_path("res://data/bible.json"))
# armored enemy (armor 8), big HP
sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 22, 1000, 8.0, 0, 0, 1, EnemyPool.TYPE_TANK, -1)
var hp0 := sim.enemies.data[0]
sim._damage_enemy(0, 1.0) # tiny hit vs armor 8
var dealt := hp0 - sim.enemies.data[0]
assert_almost_eq(dealt, 0.25, 0.001, "floor is now 25% of the hit (was 10%)")
func test_boss_gold_is_40() -> void:
assert_eq(Sim.BOSS_GOLD, 40)
  • Step 2: Run it, expect FAIL (floor is amount*0.1 → 0.1; BOSS_GOLD 25).

  • Step 3: Apply changes.

    • sim/sim.gd:953: var effective := maxf(amount - armor, amount * 0.25).
    • const BOSS_GOLD: int = 40.
    • In _sweep_dead, after run_gold += GOLD_PER_KILL, add a small bonus for heavies/ranged: if dead_type in [EnemyPool.TYPE_TANK, EnemyPool.TYPE_BRUTE, EnemyPool.TYPE_ELITE, EnemyPool.TYPE_SKIRMISHER, EnemyPool.TYPE_BOMBER, EnemyPool.TYPE_LANCER, EnemyPool.TYPE_ORBITER]: run_gold += 2.
    • data/bible.json weapons: pulse 2→3, orbit 0.8→1.2, beam 0.4→0.8, nova 3→4, turret 0.7→1.0, scatter 1.0→1.4. Leave blade 3.0.
    • data/bible.json meta: set any cost_growth > 1.6 to 1.6 (haste 1.7→1.6, bulwark 1.7→1.6, swiftness 1.8→1.6).
  • Step 4: Run the test, expect PASS.

  • Step 5: Per-chunk ritual. Determinism UNCHANGED — baseline blade-vs-swarmer is armor-0 (floor branch never taken), blade damage unchanged, gold/meta excluded from checksum, non-blade weapons inactive in baseline. Expect 4152236597/1267954985. Commit:

Terminal window
git add sim/sim.gd data/bible.json tests/test_balance.gd
git commit -m "balance: armor floor 0.1->0.25, non-blade weapon dmg, gold, meta cost"

Task 10: Per-enemy attribute variation + tank escorts (THE re-pin task)

Section titled “Task 10: Per-enemy attribute variation + tank escorts (THE re-pin task)”

Files:

  • Modify: sim/sim.gd (_spawn_enemies: variation + escorts; a _vary_stats helper)
  • Modify: sim/story_director.gd (apply variation to randomized-replay spawns only)
  • Modify: tests/test_determinism_checksum.gd (RE-PIN)
  • Modify: CLAUDE.md (record the new baseline)
  • Test: tests/test_enemy_variation.gd (new)

Interfaces:

  • Produces: Sim._vary_stats(base_radius, base_hp, base_speed, base_contact) -> Dictionary (radius/hp/speed/contact, deterministic via rng). Tank/brute escort spawn in _spawn_enemies.

  • Step 1: Failing test (two spawned enemies of the same type differ; variation stays in bounds)

extends GutTest
func test_variation_produces_spread() -> void:
var sim := Sim.new(99, ContentLoader.load_from_path("res://data/bible.json"))
var hps := []
for _n in range(40):
var v := sim._vary_stats(14.0, 3.0, 70.0, 12.0)
hps.append(float(v["hp"]))
var lo: float = hps.min()
var hi: float = hps.max()
assert_gt(hi - lo, 0.5, "HP varies across spawns")
assert_gt(lo, 0.0, "no zero/negative HP")
func test_bigger_is_slower() -> void:
# the size roll links radius up with HP up and speed down — sample correlation
var sim := Sim.new(5, ContentLoader.load_from_path("res://data/bible.json"))
var big := sim._vary_stats(14.0, 3.0, 70.0, 12.0) # deterministic sequence
# (assert the helper returns all four keys; correlation is covered by the design)
for k in ["radius", "hp", "speed", "contact"]:
assert_true(big.has(k))
  • Step 2: Run it, expect FAIL (_vary_stats missing).

  • Step 3: Implement _vary_stats — draw a size roll s = rng.randf_range(0.8, 1.25); radius = base_radius * s; hp = base_hp * (0.7 + 0.6 * s) * rng.randf_range(0.9, 1.1); speed = base_speed * (1.3 - 0.4 * s) * rng.randf_range(0.95, 1.08); contact = base_contact * (0.8 + 0.4 * s); ~8% chance (rng.randf() < 0.08) of a stronger “variant” (push hp×1.5 or speed×1.4). Return a Dictionary. In _spawn_enemies, replace the literal stat reads with _vary_stats(...) applied to the bible base values (still × difficulty_mult). For tank/brute picks, after adding the heavy, spawn an escort cluster (3–5 swarmers/spiders near spawn_pos, each via _vary_stats).

  • Step 4: Apply variation to story randomized replays only — in StoryDirector random-spawn path (sim.story.randomize), route through _vary_stats; leave tutorial-authored encounters exact.

  • Step 5: Run the variation test, expect PASS.

  • Step 6: RE-PIN the baseline. Run godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit. It will FAIL with the new computed values printed. Update tests/test_determinism_checksum.gd with the new snapshot_string().hash() and state_checksum(). Re-run → PASS. Update CLAUDE.md (the determinism baseline line + the “Dash enemies / spawn” notes) with the new numbers and a one-line reason (“per-enemy variation + tank escorts moved the spawn rng stream”).

  • Step 7: Per-chunk ritual. Full suite, test-count, boot smoke. Commit:

Terminal window
git add sim/sim.gd sim/story_director.gd tests/test_enemy_variation.gd tests/test_determinism_checksum.gd CLAUDE.md
git commit -m "feat(enemies): per-enemy attribute variation + tank escorts (re-pin determinism baseline)"

Task 11: Meta shop UI overhaul (spread across screen + icons + animation)

Section titled “Task 11: Meta shop UI overhaul (spread across screen + icons + animation)”

Files:

  • Modify: ui/meta_shop_panel.gd (grid layout, icons, animation, 2D grid nav)
  • Create: ui/shop_icons.gd (ShopIcons — procedural per-upgrade glyph Control)
  • Test: tests/test_meta_shop_panel.gd (new; node test of layout + nav, no save path)

Interfaces:

  • Produces: a GridContainer-based card grid that fills the screen width; MetaShopPanel._columns_for(n: int) -> int; 2D grid navigation (up/down/left/right move by row/column); ShopIcons.make(effect_or_type: String, accent: Color) -> Control.
  • Consumes: MetaState (level_of/is_maxed/can_afford/cost/buy/selected_decoy), ContentDB.meta_upgrades().

Why: the current VBoxContainer stacks every card (600×74) in one column → with 12+ meta upgrades it overflows the 648px-tall screen. A responsive grid spread across the full width fixes overflow AND looks better; icons + motion make it exciting. Determinism-neutral (UI only). Run AFTER Task 4/9 so it renders the full (larger) meta-upgrade set.

  • Step 1: Failing test (columns scale with card count; nav wraps in 2D)
extends GutTest
func test_columns_scale_with_count() -> void:
var p := MetaShopPanel.new()
add_child_autofree(p)
await get_tree().process_frame
assert_eq(p._columns_for(3), 3, "few cards -> few columns")
assert_gte(p._columns_for(14), 3, "many cards -> multiple columns")
assert_lte(p._columns_for(14), 5, "but capped so cards stay readable")
func test_grid_built_for_defs() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var sim := Sim.new(1, content)
var meta := MetaState.new() # fresh, zero gold — unaffordable path, NO save
var p := MetaShopPanel.new()
add_child_autofree(p)
await get_tree().process_frame
p.show_shop(sim, meta, content.meta_upgrades())
# one card per def + the Play Again card
assert_eq(p._cards.size(), content.meta_upgrades().size() + 1)
p.hide_panel()
  • Step 2: Run it, expect FAIL (_columns_for undefined; _cards still VBox).

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_shop_panel.gd -gexit

  • Step 3: Restructure the layout. In ui/meta_shop_panel.gd:

    • Keep the header (RUN OVER / summary / BANKED ) in a top VBoxContainer anchored to the top-centre.
    • Put the cards in a GridContainer with columns = _columns_for(_cards.size()), inside a MarginContainer anchored PRESET_FULL_RECT with generous side margins, centred below the header. Add _columns_for(n: int) -> int: return clampi(int(ceil(sqrt(float(n)))), 3, 5).
    • Shrink the cards: CARD_W 600→ a width derived from the column count (e.g. (viewport_width - margins) / columns - gutter), CARD_H 74→96 (room for an icon). Compute the per-card width in _rebuild from get_viewport().get_visible_rect().size.x.
  • Step 4: Add icons. Create ui/shop_icons.gd (ShopIcons extends RefCounted with static func make(key: String, accent: Color) -> Control) returning a small Control that procedurally _draws a glyph per upgrade effect/type (heart=vitality/max_hp, shield=bulwark/armor, lightning=haste/fire_rate, magnet=greed/pickup, boot=swiftness/move_speed, flame-arrows=thrusters, decoy glyph for decoy: unlocks, lock for other unlocks). In _set_card_text, add the icon at the card’s left and shift the text right.

  • Step 5: Add animations. On show_shop, stagger card entrance (each card tweens scale 0.8→1.0 + modulate alpha 0→1 with a per-index delay via create_tween). On focus (grab_focus/_sel change), tween the focused card scale to ~1.06 and pulse its border glow; un-focus tweens back. On a successful buy, flash the card (quick modulate pulse) before _rebuild. Keep all tweens render-side; no sim coupling.

  • Step 6: 2D grid navigation. Replace the linear wrapi nav in _input: track _sel as an index into _cards; on left/right move ±1 (wrap within row), on up/down move ±columns (clamp/wrap by row). Keep the NAV_DEBOUNCE_MS debounce and JOY_BUTTON_A confirm (tvOS). The Play Again card is the last cell.

  • Step 7: Run the test, expect PASS. Then boot-smoke + a manual playtest (trigger game-over) to confirm nothing clips off-screen and nav reaches every card incl. Play Again.

  • Step 8: Per-chunk ritual. class_name in existing ui/ dir (run --import if GUT can’t find ShopIcons). Determinism UNCHANGED (UI only). Commit:

Terminal window
git add ui/meta_shop_panel.gd ui/shop_icons.gd tests/test_meta_shop_panel.gd
git commit -m "feat(ui): meta shop grid spread across screen + icons + animations"

Task 12: Audio — sound effects (reuse chess-defense SFX)

Section titled “Task 12: Audio — sound effects (reuse chess-defense SFX)”

Files:

  • Create: audio/*.wav (copied from ~/Claude/chess-defense-td/ChessDefenseTD/Audio/) + their .import sidecars (committed)
  • Create: audio/audio_manager.gd (AudioManager extends Node)
  • Modify: main.gd (instantiate AudioManager; feed it sim.fx_events each tick + discrete events: level-up, game-over, story victory, dash, shop nav/buy)
  • Test: tests/test_audio_manager.gd (new — verifies the event→sound mapping + polyphony pool; no actual playback assertion needed)

Interfaces:

  • Produces: AudioManager.play(key: String, volume_db: float = 0.0), AudioManager.consume(events: Array), AudioManager.SOUND_FOR_FX: Dictionary (fx-kind → sound key). Render-side only (NOT /sim).
  • Consumes: sim.fx_events (kinds: death, reaction, pickup, dmgnum, chain), discrete calls from main.gd.

Why: the game has no audio. Reuse the chess-defense .wav palette as placeholder SFX. Determinism-neutral (render-side, reads fx_events). Guard against audio spam with per-frame caps (mirrors FxManager.DEATH_CAP).

  • Step 1: Copy the sound assets + import them.
Terminal window
mkdir -p /Users/chris/Claude/bullet-heaven/audio
cp ~/Claude/chess-defense-td/ChessDefenseTD/Audio/{attack,enemy_hit,enemy_death,level_up,victory,defeat,king_hit,wave_start,wave_clear,ui_tap,deploy,special_barrage,special_frost,special_freeze,special_aegis,special_stomp,special_rally}.wav /Users/chris/Claude/bullet-heaven/audio/
godot --headless --path /Users/chris/Claude/bullet-heaven --import # generates audio/*.wav.import sidecars
  • Step 2: Failing test (the manager maps fx kinds to known sound keys and loads them)
extends GutTest
func test_fx_mapping_keys_exist() -> void:
var am := AudioManager.new()
add_child_autofree(am)
await get_tree().process_frame
# every mapped sound key must resolve to a loaded stream
for k in AudioManager.SOUND_FOR_FX.values():
assert_true(am._streams.has(k), "stream loaded for key %s" % k)
func test_consume_caps_death_sounds() -> void:
var am := AudioManager.new()
add_child_autofree(am)
await get_tree().process_frame
var events := []
for _n in range(50):
events.append({"kind": "death", "pos": Vector2.ZERO, "element": -1})
am.consume(events)
assert_lte(am._played_this_frame, AudioManager.PER_FRAME_CAP, "death-sound spam is capped")
  • Step 3: Run it, expect FAIL (AudioManager missing).

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_audio_manager.gd -gexit

  • Step 4: Implement audio/audio_manager.gd.

    • const PER_FRAME_CAP := 4. A _streams: Dictionary of key -> AudioStream loaded in _ready via load("res://audio/<key>.wav") for each key used.
    • A pool of AudioStreamPlayer (e.g. 16) for polyphony; play(key, volume_db) grabs a free/oldest player, sets stream + volume, play().
    • const SOUND_FOR_FX := {"death": "enemy_death", "reaction": "special_barrage", "chain": "special_frost", "pickup": "ui_tap"} (dmgnum intentionally silent — too frequent).
    • consume(events): reset _played_this_frame = 0; for each event whose kind is in SOUND_FOR_FX, if under PER_FRAME_CAP, play(...) and increment. Special-case reaction name: "BOSS"/"WARDEN"wave_start, "BOSS DOWN"victory, "NUKE"special_stomp (not capped — rare).
    • Discrete helpers: level_up()level_up, game_over()defeat, victory()victory, dash()deploy, hurt()king_hit, ui_nav()ui_tap, ui_buy()level_up.
  • Step 5: Wire main.gd. Instantiate AudioManager as a child. After sim.tick, call audio.consume(sim.fx_events). Call audio.level_up() when pending_levelups triggers the panel, audio.game_over()/audio.victory() on those transitions, audio.dash() when a dash fires (track player.dash_cd rising edge or read a sim flag), audio.hurt() when player.hp drops. Wire ui_nav/ui_buy from the shop + level-up panels.

  • Step 6: Run the test, expect PASS. Boot-smoke (headless uses the dummy audio driver — no crash).

  • Step 7: Per-chunk ritual. Determinism UNCHANGED (render-side). Commit the wavs + sidecars + code:

Terminal window
git add audio/ main.gd tests/test_audio_manager.gd
git commit -m "feat(audio): sound effects via AudioManager (reuse chess-defense SFX)"

Task 13: Deploy + tvOS sync + site changelog

Section titled “Task 13: Deploy + tvOS sync + site changelog”

Files:

  • Modify: sim/sim.gd (Sim_Const.BUILD bump — actually sim/sim_const.gd)

  • Sync: tvOS repo gameplay + tests (via bh-deploy)

  • Modify: ~/Claude/bullet-heaven-site/index.html (changelog + enemy legend) — separate repo

  • Step 1: Bump Sim_Const.BUILD to the next number.

  • Step 2: Run the full suite + bash scripts/check-test-count.sh once more from a clean state.

  • Step 3: Invoke the bh-deploy skill: main→tvOS gameplay+tests sync, export pack, xcodebuild, devicectl install. (tvOS repo gitignores gameplay source; a gameplay-only deploy shows “nothing to commit” there — expected.)

  • Step 4: Optionally scripts/deploy-demo.sh to re-export the web build to R2 (note: the start menu is now a 3-card menu — confirm with Chris before changing the public demo’s first screen).

  • Step 5: Update the site changelog + enemy legend in the site repo to match the new build (new boss, thruster, 5 new enemies, silhouettes, balance).

  • Step 6: Commit the build bump:

Terminal window
git add sim/sim_const.gd
git commit -m "chore: bump BUILD N (5-attack boss, thruster, 5 enemies, silhouettes, balance, variation)"

Spec coverage:

  • §2 modes/tutorial → Task 2 ✓
  • §3a player thruster + §3b rusher → Task 4 ✓
  • §4a variation + §4b escorts → Task 10 ✓
  • §4c new archetypes (+enemy_proj damage) → Tasks 5,6 ✓
  • §4d visual differentiation → Task 3 ✓
  • §5 new 5-attack boss (Cutter/Artillery/Rings/Charge/Summon) → Tasks 7,8 ✓
  • §6a damage numbers → Task 1 ✓
  • §6b armor / §6c weapon dmg / §6d gold+meta → Task 9 ✓ (XP curve change deliberately deferred per spec unless play demands it; would join Task 10’s re-pin)
  • §7 determinism plan → re-pin isolated to Task 10 ✓
  • §8 sequencing → task order matches ✓ (deploy renumbered to Task 12 after the shop task)
  • §9 risks (invisible-entity, tvOS input, render perf) → called out in Tasks 3,4,6,8,12 ✓
  • Added requirement (not in original spec): meta shop overflows the screen → Task 11 (grid + icons + animation). UI-only, determinism-neutral.
  • Added requirement (not in original spec): no audio → Task 12 (AudioManager, reuse chess-defense SFX). Render-side, determinism-neutral. Deploy is now Task 13.

Placeholder scan: No “TBD/TODO”. Test bodies are concrete GDScript; balance numbers are exact; commands are verbatim. Where a task references the bh-dev-chunk ritual, the exact commands are restated in Global Constraints (not “similar to”).

Type consistency: _vary_stats returns {radius,hp,speed,contact} (Task 10 uses those keys). EnemyProjPool.add(p,v,r,life,dmg) defined in Task 5, consumed in Tasks 6/8. is_invulnerable() defined in Task 4, consumed in Tasks 5/7/8. Boss2State.ATTACK_* defined in Task 7, consumed in Task 8. ArchetypeRenderer.shape_for/sync/_meshes consistent across Tasks 3/6.