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.
Global Constraints
Section titled “Global Constraints”/simpurity: every file undersim/extends RefCountedand 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 viasim.rng(spawns/sim) — NEVERupgrade_rngfor spawns. Survival baseline pinned intests/test_determinism_checksum.gd:snapshot_string().hash() = 4152236597,state_checksum() = 1267954985. - Per-chunk ritual: every task is one
bh-dev-chunkcycle. Its verify block (run verbatim, in order) is:- Full suite:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit→ exit 0. - Test-count guard:
bash scripts/check-test-count.sh→ must NOT report fewer scripts thantests/test_*.gdfiles. - If a
class_namewas added in a NEW directory:godot --headless --importfirst (stale-class-cache trap). - Boot smoke:
godot --headless --path . --quit-after 120 2>&1 | grep -i "SCRIPT ERROR"→ NO output. (Note: macOS has notimeout; do not wrap.) - 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.
- Full suite:
- Content edits: hand-edit
data/bible.json(a tab-indentedpython3JSON round-trip — load, mutatedata.weapons/data.enemies/data.mods/data.meta_upgrades,json.dump(indent='\t', ensure_ascii=False)). Do NOT re-export fromseed.js(it has drifted; re-export would drop blade/skirmisher/scatter/brute). - Build number: bump
Sim_Const.BUILDonly when a chunk is DEPLOYED viabh-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 newfx_eventskind needs amatcharm infx/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.
File Structure
Section titled “File Structure”| 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_enemystill emits{"kind":"dmgnum","pos","amount","big"}intofx_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:
git add sim/sim.gd tests/test_damage_numbers.gdgit 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_chosencarries a String) - Modify:
main.gd(_modefield; 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; nomodemeta).
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.gdto 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 todayif _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:
git add ui/start_menu.gd main.gd tests/test_start_menu_modes.gdgit 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 enemySwarmRendereruse withArchetypeRenderer; keep gems/proj onSwarmRenderer) - 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 oneMultiMeshInstance2Dper 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-poolSwarmRendererinstance withArchetypeRenderer, building thetype_to_shapeLUT once at run start and passing_enemy_colors()/_enemy_scales()data throughsync. Keep gems + projectiles onSwarmRenderer(uniform circles). -
Step 6: Per-chunk ritual.
class_namein NEW dir? No (render/exists), but run--importanyway if GUT can’t findArchetypeRenderer. Determinism UNCHANGED (render-only). Then update the site legend in~/Claude/bullet-heaven-site/index.htmlEnemies section to the new silhouettes (separate repo; note it, do not deploy here). Commit:
git add render/archetype_renderer.gd main.gd tests/test_archetype_renderer.gdgit 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(adddash) - Modify:
sim/player_state.gd(dash fields) - Modify:
sim/sim.gd(_update_dash, i-frame gate in_check_player_hit/_boss_*;BEHAVIOR_RUSHstep) - Modify:
sim/enemy_pool.gd(BEHAVIOR_RUSHconst + RUSH phases) - Modify:
sim/stat_effects.gd(thrusterseffect) - Modify:
data/bible.json(stat modthrusters; meta upgradethrusters; 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.TABLEvocabulary (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 (
InputStatehas nodash;is_invulnerableundefined). -
Step 3: Implement.
sim/input_state.gd: addvar dash: bool = falseand a 4th_initparamdash_pressed := false.sim/player_state.gd: addvar 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: constantsDASH_COOLDOWN=1.5,DASH_SPEED=900.0,DASH_TIME=0.16,DASH_IFRAMES=0.18. Addfunc _update_dash(input, dt): decrementdash_cd/iframe_timer/dash_timer; oninput.dash and player.dash_cd <= 0.0start a dash (setdash_dirfrominput.move_dirif 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); whiledash_timer > 0addplayer.pos += player.dash_dir * DASH_SPEED * dt. Call_update_dash(input, dt)right afterplayer.integrate(input, dt)intick(). Addfunc is_invulnerable() -> bool: return player.iframe_timer > 0.0. In_check_player_hit, wrap the contact + projectile damage inif not is_invulnerable():(skip both when dashing). In_boss_swing_hit/_update_boss_missiles, guard_hurt_playerwithif not is_invulnerable():.sim/stat_effects.gd: add athrusterseffect mapping toplayer.dash_cooldown_mult *= mag(and a smalldash_iframe_bonusbump). Add the stat-modthrusterstobible.jsondata.mods(kindstat, effectthrusters, magnitude0.85).- render:
input/input_router.gdbind adashaction (Space +JOY_BUTTON_RIGHT_SHOULDER; tvOS: a free Siri-remote button) and setinput.dashinpoll().ui/hud.gd: a small “thruster ready” pip from1.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 aBEHAVIOR_RUSHbranch in_move_enemiescalling_step_rush(i, dt)._step_rush: inRUSH_AIMbriefly steer toward the player (re-aim ~0.12s) then latch a burst velocity intoveland switch toRUSH_BURST; inRUSH_BURSTmove alongvelforrush_burst_s(~0.35s) then back toRUSH_AIMimmediately (no recharge). Readrush_speed/rush_burst_s/rush_aim_sper-type (constants for now; bible later). Mapbehavior:"rush"in_build_enemy_types. -
Step 7: Run the rusher test, expect PASS.
-
Step 8: Meta upgrade. Add to
bible.jsondata.meta_upgradesathrustersentry (typeomitted/null = stat,effect: "thrusters",base_cost: 60,cost_growth: 1.6,max_level: 4,magnitude: 0.9).MetaState.apply_toalready routes throughStatEffects, so no MetaState code change. -
Step 9: Per-chunk ritual. Determinism UNCHANGED — verify carefully: baseline has
dash=false(no burst,iframe_timerstays 0 so the hit-skip never branches), and noBEHAVIOR_RUSHenemies spawn in the baseline. Expect4152236597/1267954985. Commit:
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.gdgit 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, addsdamagecolumn) - Modify:
sim/sim.gd(enemy_projbecomesEnemyProjPool;enemy_proj.add(...)callers pass damage;_check_player_hituses 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) -> intandvar damage: PackedFloat32Array.remove_atswapsdamagein lockstep. -
Consumes:
EntityPoolbase (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.gd—extends EntityPool,var damage: PackedFloat32Array, override_init(cap)tosuper._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, andremove_at(i)swapsdamage[i] = damage[last]beforesuper.remove_at(i). -
Step 4: Run the test, expect PASS.
-
Step 5: Swap the pool in
sim/sim.gd. Changeenemy_proj = EntityPool.new(ENEMY_PROJ_CAP)→EnemyProjPool.new(ENEMY_PROJ_CAP). Update EVERYenemy_proj.add(...)call (shooter, skirmisher, boss barrage, boss spiral) to pass a damage arg: shooters/skirmishers passSHOOTER_PROJ_DAMAGE; boss barrage/spiral pass their own (keep currentSHOOTER_PROJ_DAMAGEto preserve behavior). In_check_player_hit, replace_hurt_player(SHOOTER_PROJ_DAMAGE)withif not is_invulnerable(): _hurt_player(enemy_proj.damage[ep]). -
Step 6: Per-chunk ritual. Determinism UNCHANGED —
enemy_projis empty in the blade-only baseline (no shooters in <10s), and the column doesn’t touch pos/vel. Expect4152236597/1267954985. Commit:
git add sim/enemy_proj_pool.gd sim/sim.gd tests/test_enemy_proj_pool.gdgit 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_typesresize→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_colorsresize toTYPE_SCATTERER+1; shapes inArchetypeRenderer) - 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_rangedwith a per-type pattern). Newfx_eventskinds as needed (bomb_warn,enemy_beam) each with a renderer. -
Consumes:
EnemyProjPool(Task 5),enemies.entity_idfor 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_typestoo small). -
Step 3: Add bible entries (hand-edit
data/bible.json, per Global Constraints). Each needs a uniqueid, validelementref,hp/speed/contact_damage/armor/radius/xp_value/color, andbehavior+ any attack params (fire_interval,proj_speed,proj_damage,pellets/spreadfor scatterer,beam_range/beam_chargefor lancer,bomb_radius/bomb_delayfor bomber,orbit_shards/orbit_radiusfor 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 := 9…TYPE_SCATTERER := 13.sim/sim.gd:_enemy_types.resize(14), assign each fromcontent.enemy(...), extend_ENEMY_NAME_TO_TID, resize the per-type arrays accordingly. Implement each attack: zapper/scatterer fire viaenemy_proj.add(...,proj_damage); bomber lobs a delayed AoE (an entry in a newbombs: Array[Dictionary]with{pos, delay, radius, dmg}, telegraph viabomb_warnfx, on expiry damage the player if inside + emit a burst); lancer charges then does a line-vs-player check during an active window (enemy_beamfx); orbiter spins shards tracked render-side (positions recomputed each tick, NOT in checksum) that damage the player on contact. Add fire timers keyed byenemies.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 toEnemyPool.TYPE_SCATTERER + 1, add colours; add shapes for the five inArchetypeRenderer.shape_for. Add renderers/matcharms for any newfx_eventskind (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_projper Task 5). Expect4152236597/1267954985. Commit:
git add data/bible.json sim/enemy_pool.gd sim/sim.gd sim/spawn_director.gd main.gd render/ tests/test_new_enemies.gdgit 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, reuseBEHAVIOR_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:
Boss2StatewithATTACK_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 (
Boss2Statemissing). -
Step 3: Implement
sim/boss2_state.gdmirroringBossState(phases APPROACH/TELEGRAPH/ACTIVE/REST) butattack_idxchosen bypick_random_attack(rng)at the start of each cycle (in REST→APPROACH transition), andATTACK_COUNT = 5.pick_random_attack(rng) -> int: return rng.randi_range(0, ATTACK_COUNT - 1). -
Step 4: Implement boss2 in
sim.gd—var boss2: Boss2State = Boss2State.new(), constants for boss2 (BOSS2_HP, telegraph/active/rest, per-attack params)._boss2_index()scans forTYPE_BOSS2._spawn_boss2(pos, hp_mult)adds a pooledTYPE_BOSS2/BEHAVIOR_BOSSenemy andboss2.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 viaenemy_beamfx), Charge slam (telegraph a line, then dash the boss across applying heavy path damage; guard withis_invulnerable), Summon (spawn a small cluster of swarmers/spiders near the boss). Wire_update_boss2(dt)intotick()next to_update_boss(dt). -
Step 5: Survival alternation. In
_update_bossspawn-gate, alternate which boss spawns (a_boss_spawn_countcounter → 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:
git add sim/boss2_state.gd sim/enemy_pool.gd sim/sim.gd tests/test_boss2.gdgit 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(instantiateBoss2Renderer, feedboss2_render_info()+ rockets) - Modify:
fx/fx_manager.gdif new fx kinds (rocket,shrapnel,boss_beam) need arms - Test:
tests/test_boss2_artillery.gd(new)
Interfaces:
-
Produces:
boss_rocketsentries{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_rocketsmissing). -
Step 3: Implement Artillery + Rings. In
_boss2_fire: Artillery appends N rockets toboss_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 fakealtitude+ timer; hang briefly; land → emit ashrapnelradial burst intoenemy_projwith per-shot damage + ashrapnelfx; if the player is within the blast radius and not invulnerable,_hurt_player). Shockwave rings fires concentricenemy_projrings (a few rings, each a ring of shots with a gap). Add_update_boss_rockets(dt)totick(). -
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 asaltituderises, grows on descent), ground target rings during hang, the accelerating Cutter beam telegraph + active beam, the charge-slam telegraph line. Wire inmain.gd(only meaningful whenboss2_render_info().alive). Addmatcharms / 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:
git add sim/sim.gd render/boss2_renderer.gd main.gd fx/fx_manager.gd tests/test_boss2_artillery.gdgit 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 weaponbase_damage; metacost_growthcaps) - 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_GOLD25). -
Step 3: Apply changes.
sim/sim.gd:953:var effective := maxf(amount - armor, amount * 0.25).const BOSS_GOLD: int = 40.- In
_sweep_dead, afterrun_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.jsonweapons: 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.jsonmeta: set anycost_growth > 1.6to 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:
git add sim/sim.gd data/bible.json tests/test_balance.gdgit 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_statshelper) - 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 viarng). 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_statsmissing). -
Step 3: Implement
_vary_stats— draw a size rolls = 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 nearspawn_pos, each via_vary_stats). -
Step 4: Apply variation to story randomized replays only — in
StoryDirectorrandom-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. Updatetests/test_determinism_checksum.gdwith the newsnapshot_string().hash()andstate_checksum(). Re-run → PASS. UpdateCLAUDE.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:
git add sim/sim.gd sim/story_director.gd tests/test_enemy_variation.gd tests/test_determinism_checksum.gd CLAUDE.mdgit 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 glyphControl) - 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_forundefined;_cardsstill 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 topVBoxContaineranchored to the top-centre. - Put the cards in a
GridContainerwithcolumns = _columns_for(_cards.size()), inside aMarginContaineranchoredPRESET_FULL_RECTwith 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_W600→ a width derived from the column count (e.g.(viewport_width - margins) / columns - gutter),CARD_H74→96 (room for an icon). Compute the per-card width in_rebuildfromget_viewport().get_visible_rect().size.x.
- Keep the header (
-
Step 4: Add icons. Create
ui/shop_icons.gd(ShopIcons extends RefCountedwithstatic func make(key: String, accent: Color) -> Control) returning a smallControlthat 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 fordecoy: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 viacreate_tween). On focus (grab_focus/_selchange), 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
wrapinav in_input: track_selas an index into_cards; on left/right move ±1 (wrap within row), on up/down move ±columns (clamp/wrap by row). Keep theNAV_DEBOUNCE_MSdebounce andJOY_BUTTON_Aconfirm (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_namein existingui/dir (run--importif GUT can’t findShopIcons). Determinism UNCHANGED (UI only). Commit:
git add ui/meta_shop_panel.gd ui/shop_icons.gd tests/test_meta_shop_panel.gdgit 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.importsidecars (committed) - Create:
audio/audio_manager.gd(AudioManager extends Node) - Modify:
main.gd(instantiateAudioManager; feed itsim.fx_eventseach 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 frommain.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.
mkdir -p /Users/chris/Claude/bullet-heaven/audiocp ~/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 (
AudioManagermissing).
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: Dictionaryofkey -> AudioStreamloaded in_readyviaload("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 whosekindis inSOUND_FOR_FX, if underPER_FRAME_CAP,play(...)and increment. Special-case reactionname:"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. InstantiateAudioManageras a child. Aftersim.tick, callaudio.consume(sim.fx_events). Callaudio.level_up()whenpending_levelupstriggers the panel,audio.game_over()/audio.victory()on those transitions,audio.dash()when a dash fires (trackplayer.dash_cdrising edge or read a sim flag),audio.hurt()whenplayer.hpdrops. Wireui_nav/ui_buyfrom 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:
git add audio/ main.gd tests/test_audio_manager.gdgit 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.BUILDbump — actuallysim/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.BUILDto the next number. -
Step 2: Run the full suite +
bash scripts/check-test-count.shonce more from a clean state. -
Step 3: Invoke the
bh-deployskill: 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.shto 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:
git add sim/sim_const.gdgit commit -m "chore: bump BUILD N (5-attack boss, thruster, 5 enemies, silhouettes, balance, variation)"Self-Review
Section titled “Self-Review”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.