Sound Phase 1 — Foundation & Fixes Implementation Plan
Sound Phase 1 — Foundation & Fixes Implementation Plan
Section titled “Sound Phase 1 — Foundation & Fixes 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: Fix the audio bugs and structural gaps flagged by the 2026-07-01 sound audit (audio/SOUND_MAP.md) before any new content sounds are added — a real bus layout, the two double-sounded events, and two of the six dead .wav assets wired to events that already exist.
Architecture: All changes are render-side (audio/audio_manager.gd, a Godot Node) or pure-data /sim fx_event emission (sim/sim.gd) — no rendering, no new systems. AudioManager’s play() gains a bus parameter so callers can route to SFX/UI explicitly; the three fixes are one-line-scale changes to existing fx_events.append(...) call sites.
Tech Stack: Godot 4.6.3 GDScript, GUT 9.6.0 (addons/gut/) for tests.
Global Constraints
Section titled “Global Constraints”- Every task’s tests run via:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit(exit 0 = pass).-gtest=<file>does NOT isolate a single file in this project (still runs the full ~172-script suite) — just run the full suite, it’s ~4s. - After every task, run
scripts/check-test-count.sh— it fails loud if GUT silently dropped a script (stale class cache / parse error in a test file). - This work is entirely render-side or pure-fx_events-only in
/sim— the determinism baseline (snapshot_string().hash()/state_checksum(), pinned intests/test_determinism_checksum.gd+test_determinism_crystals.gd) must stay byte-identical.fx_eventsis explicitly excluded from both hashes, so this is expected to be a no-op confirmation, not a real risk — still re-run the full suite (which includes both determinism tests) after every task to prove it. - Test files follow this project’s existing GUT conventions:
extends GutTest, construct a sim withSim.new(seed, SimContentFixture.db()), construct anAudioManagerwithAudioManager.new()+add_child_autofree(am)+await get_tree().process_frame. audio/SOUND_MAP.mdis updated in place (not appended to) as the very last task, once every fix in this phase is verified working.- Renaming existing
.wavfiles (deploy.wav→dash-sounding name,king_hit.wav→hurt-sounding name) is explicitly OUT of scope for this phase — the spec (docs/superpowers/specs/2026-07-01-sound-effects-audit-plan-design.md) flagged it as cosmetic-only, and renaming committed binary assets for zero gameplay change isn’t worth the churn. Do not do it as part of any task below.
Task 1: Audio bus layout — Master → {SFX, Music, UI}
Section titled “Task 1: Audio bus layout — Master → {SFX, Music, UI}”Files:
- Create:
audio/bus_layout.tres - Modify:
project.godot(add[audio]section) - Modify:
audio/audio_manager.gd:39-42(pool creation),audio/audio_manager.gd:45-53(play()),audio/audio_manager.gd:96-100(ui_nav()/ui_buy()) - Test:
tests/test_audio_manager.gd
Interfaces:
-
Produces:
AudioManager.play(key: String, volume_db: float = 0.0, bus: String = "SFX") -> void— every existing call site (level_up(),game_over(),victory(),dash(),hurt(), theconsume()internals) keeps working unchanged becausebusdefaults to"SFX".ui_nav()/ui_buy()pass"UI"explicitly. -
Step 1: Write the failing bus-existence test
Add to tests/test_audio_manager.gd:
func test_audio_buses_exist() -> void: assert_ne(AudioServer.get_bus_index("SFX"), -1, "SFX bus exists") assert_ne(AudioServer.get_bus_index("Music"), -1, "Music bus exists") assert_ne(AudioServer.get_bus_index("UI"), -1, "UI bus exists")
func test_pool_players_default_to_sfx_bus() -> void: var am := AudioManager.new() add_child_autofree(am) await get_tree().process_frame for p in am._pool: assert_eq(p.bus, "SFX", "pool players default to the SFX bus")
func test_ui_helpers_play_on_ui_bus() -> void: var am := AudioManager.new() add_child_autofree(am) await get_tree().process_frame am.ui_nav() var used: AudioStreamPlayer = am._pool[(am._pool_cursor - 1 + AudioManager.POOL_SIZE) % AudioManager.POOL_SIZE] assert_eq(used.bus, "UI", "ui_nav plays on the UI bus")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: test_audio_buses_exist FAILs (SFX/Music/UI bus indices are all -1 — only Master exists today); test_ui_helpers_play_on_ui_bus FAILs (used.bus is "Master", not "UI"); test_pool_players_default_to_sfx_bus FAILs (p.bus is "Master").
- Step 3: Create the bus layout resource
Create audio/bus_layout.tres:
[gd_resource type="AudioBusLayout" format=3]
[resource]bus/0/name = "Master"bus/0/solo = falsebus/0/mute = falsebus/0/bypass_fx = falsebus/0/volume_db = 0.0bus/1/name = "SFX"bus/1/solo = falsebus/1/mute = falsebus/1/bypass_fx = falsebus/1/volume_db = 0.0bus/1/send = "Master"bus/2/name = "Music"bus/2/solo = falsebus/2/mute = falsebus/2/bypass_fx = falsebus/2/volume_db = 0.0bus/2/send = "Master"bus/3/name = "UI"bus/3/solo = falsebus/3/mute = falsebus/3/bypass_fx = falsebus/3/volume_db = 0.0bus/3/send = "Master"- Step 4: Point the project at the new bus layout
In project.godot, add a new section after [physics] (before [rendering]):
[audio]
buses/default_bus_layout="res://audio/bus_layout.tres"- Step 5: Add the
busparameter toplay()and route the pool + UI helpers
In audio/audio_manager.gd, change the pool-creation loop (currently :38-42):
# Build polyphony pool. for _i in range(POOL_SIZE): var p := AudioStreamPlayer.new() p.bus = "SFX" add_child(p) _pool.append(p)Change play() (currently :45-53):
## Play a sound by key on the given bus. No-ops silently on missing keys.func play(key: String, volume_db: float = 0.0, bus: String = "SFX") -> void: if not _streams.has(key): return # Grab the next idle player (round-robin, oldest-wins if all busy). var player: AudioStreamPlayer = _pool[_pool_cursor] _pool_cursor = (_pool_cursor + 1) % POOL_SIZE player.bus = bus player.stream = _streams[key] player.volume_db = volume_db player.play()Change ui_nav()/ui_buy() (currently :96-100):
func ui_nav() -> void: play("ui_tap", 0.0, "UI")
func ui_buy() -> void: play("level_up", 0.0, "UI")- Step 6: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: PASS. If test_audio_buses_exist still fails, the [audio] section or bus_layout.tres property names are the likely culprit — check the boot log (godot --headless --path . --quit-after 5) for a resource-parse error.
- Step 7: Boot smoke + test-count guard
Run: godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
Expected: no output (no script errors on boot with the new project setting).
Run: scripts/check-test-count.sh
Expected: exit 0.
- Step 8: Commit
git add audio/bus_layout.tres project.godot audio/audio_manager.gd tests/test_audio_manager.gdgit commit -m "feat(audio): add SFX/Music/UI bus layout, route play() through it"Task 2: Fix the weapon-pickup double-sound
Section titled “Task 2: Fix the weapon-pickup double-sound”Files:
- Modify:
sim/sim.gd:1043-1052(_collect_weapon_pickups) - Test:
tests/test_fx_events.gd
Interfaces:
-
Consumes: nothing new (uses existing
Sim.drop_weapon_pickup,Sim._collect_weapon_pickups,Sim.fx_events). -
Produces:
_collect_weapon_pickups()now emits exactly onefx_eventsentry per collected pickup (the namedreaction), not two. -
Step 1: Write the failing test
Add to tests/test_fx_events.gd:
func test_weapon_pickup_emits_exactly_one_fx_event() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.player.pos = Vector2(50, 50) sim.drop_weapon_pickup("blade", Vector2(50, 50)) sim.fx_events.clear() sim._collect_weapon_pickups() assert_eq(sim.fx_events.size(), 1, "a weapon pickup should emit one fx event, not a generic pickup plus a named reaction") assert_eq(sim.fx_events[0]["kind"], "reaction", "the single event is the named reaction so a distinct sound can be wired to it later")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: FAIL — sim.fx_events.size() is 2 (both pickup and reaction fire).
- Step 3: Remove the redundant generic pickup emit
In sim/sim.gd, change _collect_weapon_pickups() (currently :1043-1052):
func _collect_weapon_pickups() -> void: var i := weapon_pickups.size() - 1 while i >= 0: var pk: Dictionary = weapon_pickups[i] if player.pos.distance_to(pk["pos"]) <= WEAPON_PICKUP_RADIUS + player.radius: grant_weapon(String(pk["weapon"])) fx_events.append({"kind": "reaction", "pos": pk["pos"], "element": int(pk["element_idx"]), "name": String(pk["weapon"]).to_upper()}) weapon_pickups.remove_at(i) i -= 1(The only change is deleting the fx_events.append({"kind": "pickup", ...}) line.)
- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: PASS (full suite, including tests/test_story_walls.gd’s existing weapon-pickup tests and the determinism tests — all unaffected since fx_events isn’t hashed).
- Step 5: Test-count guard
Run: scripts/check-test-count.sh
Expected: exit 0.
- Step 6: Commit
git add sim/sim.gd tests/test_fx_events.gdgit commit -m "fix(sim): stop double-sounding weapon pickups"Task 3: Fix the NUKE powerup double-sound
Section titled “Task 3: Fix the NUKE powerup double-sound”Files:
- Modify:
sim/sim.gd:4004-4017(_apply_powerup) - Test:
tests/test_skirmisher.gd
Interfaces:
-
Consumes:
Sim.POWERUP_NUKE(existing const,sim/sim.gd:249),Sim.POWERUP_LIFE(existing const used by other powerup tests in this file). -
Produces:
_apply_powerup(POWERUP_NUKE, pos)now emits exactly onefx_eventsentry (the namedNUKEreaction), not two. -
Step 1: Write the failing test
Add to tests/test_skirmisher.gd:
func test_powerup_nuke_emits_single_fx_event() -> void: var sim := _sim() sim.player.pos = Vector2.ZERO sim.powerups.append({"pos": Vector2.ZERO, "kind": Sim.POWERUP_NUKE, "life": Sim.POWERUP_LIFE}) sim.fx_events.clear() sim._collect_powerups() assert_eq(sim.fx_events.size(), 1, "NUKE should emit one fx event, not a named reaction plus a generic pickup") assert_eq(sim.fx_events[0]["name"], "NUKE")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: FAIL — sim.fx_events.size() is 2 (both the named NUKE reaction and the trailing generic pickup fire).
- Step 3: Skip the trailing generic pickup emit for NUKE
In sim/sim.gd, change the end of _apply_powerup() (currently :4004-4017):
func _apply_powerup(kind: int, pos: Vector2) -> void: match kind: POWERUP_SLOW: _enemy_slow_timer = POWERUP_SLOW_DURATION POWERUP_FREEZE: _enemy_freeze_timer = POWERUP_FREEZE_DURATION POWERUP_NUKE: for j in range(enemies.count): if enemies.type_id[j] != EnemyPool.TYPE_BOSS and enemies.type_id[j] != EnemyPool.TYPE_BOSS2 and enemies.type_id[j] != EnemyPool.TYPE_FUNZO and enemies.type_id[j] != EnemyPool.TYPE_GRAVITON and enemies.type_id[j] != EnemyPool.TYPE_EYE: # bosses resist the nuke _damage_enemy(j, POWERUP_NUKE_DAMAGE) fx_events.append({"kind": "reaction", "pos": player.pos, "element": -1, "name": "NUKE"}) POWERUP_HEAL: player.hp = minf(player.hp + POWERUP_HEAL_AMOUNT, player.max_hp) if kind != POWERUP_NUKE: fx_events.append({"kind": "pickup", "pos": pos, "element": -1})(The only change is wrapping the trailing fx_events.append({"kind": "pickup", ...}) in if kind != POWERUP_NUKE:.)
- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: PASS (including the existing test_powerup_heal_restores_hp/test_powerup_nuke_damages_enemies/test_powerup_slow_and_freeze_set_timers in the same file — none of them assert on fx_events, so they’re unaffected).
- Step 5: Test-count guard
Run: scripts/check-test-count.sh
Expected: exit 0.
- Step 6: Commit
git add sim/sim.gd tests/test_skirmisher.gdgit commit -m "fix(sim): stop double-sounding the NUKE powerup"Task 4: Wire FREEZE and DRONE to their matching unused sound assets
Section titled “Task 4: Wire FREEZE and DRONE to their matching unused sound assets”Files:
- Modify:
sim/sim.gd:4004-4017(_apply_powerup— add a namedFREEZEreaction) - Modify:
audio/audio_manager.gd:24-30(_ready()stream keys),audio/audio_manager.gd:57-77(consume()) - Test:
tests/test_skirmisher.gd,tests/test_audio_manager.gd
Interfaces:
-
Produces:
_apply_powerup(POWERUP_FREEZE, pos)now emits a namedreactionfx event with"name": "FREEZE"(mirroring the existingNUKEpattern atsim/sim.gd:2098forDRONE, which already exists and needs no sim.gd change).AudioManager.consume()special-casesrname == "FREEZE"→special_freezeandrname == "DRONE"→special_rally, both loaded in_ready(). -
Step 1: Write the failing tests
Add to tests/test_skirmisher.gd:
func test_powerup_freeze_emits_named_reaction() -> void: var sim := _sim() sim.player.pos = Vector2.ZERO sim.powerups.append({"pos": Vector2.ZERO, "kind": Sim.POWERUP_FREEZE, "life": Sim.POWERUP_LIFE}) sim.fx_events.clear() sim._collect_powerups() assert_eq(sim.fx_events.size(), 1, "FREEZE should emit exactly one fx event, its own named reaction") assert_eq(sim.fx_events[0]["name"], "FREEZE")Add to tests/test_audio_manager.gd:
func test_freeze_reaction_plays_special_freeze() -> void: var am := AudioManager.new() add_child_autofree(am) await get_tree().process_frame am.consume([{"kind": "reaction", "name": "FREEZE"}]) var used: AudioStreamPlayer = am._pool[(am._pool_cursor - 1 + AudioManager.POOL_SIZE) % AudioManager.POOL_SIZE] assert_eq(used.stream, am._streams["special_freeze"], "FREEZE reaction plays its own dedicated sound")
func test_drone_reaction_plays_special_rally() -> void: var am := AudioManager.new() add_child_autofree(am) await get_tree().process_frame am.consume([{"kind": "reaction", "name": "DRONE"}]) var used: AudioStreamPlayer = am._pool[(am._pool_cursor - 1 + AudioManager.POOL_SIZE) % AudioManager.POOL_SIZE] assert_eq(used.stream, am._streams["special_rally"], "DRONE reaction plays its own dedicated sound")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: test_powerup_freeze_emits_named_reaction FAILs (fx_events is empty — FREEZE emits nothing today, per _apply_powerup’s current POWERUP_FREEZE branch). test_freeze_reaction_plays_special_freeze/test_drone_reaction_plays_special_rally FAIL: am._streams["special_freeze"]/am._streams["special_rally"] are null (not loaded yet, key absent from the dict), while used.stream is the special_barrage stream (today’s unnamed-reaction fallback that FREEZE/DRONE currently fall through to) — the two don’t match.
- Step 3: Emit the named FREEZE reaction
In sim/sim.gd, change the POWERUP_FREEZE branch of _apply_powerup():
POWERUP_FREEZE: _enemy_freeze_timer = POWERUP_FREEZE_DURATION fx_events.append({"kind": "reaction", "pos": pos, "element": -1, "name": "FREEZE"})Also extend the trailing generic-pickup guard added in Task 3 to exclude FREEZE too (FREEZE now has its own dedicated event, so it shouldn’t also fire the generic pickup sound):
if kind != POWERUP_NUKE and kind != POWERUP_FREEZE: fx_events.append({"kind": "pickup", "pos": pos, "element": -1})- Step 4: Load the two previously-dead assets and special-case their reactions
In audio/audio_manager.gd, add "special_freeze" and "special_rally" to the keys array in _ready() (currently :26-30):
var keys := [ "enemy_death", "special_barrage", "special_frost", "ui_tap", "level_up", "defeat", "victory", "deploy", "king_hit", "wave_start", "special_stomp", "special_freeze", "special_rally", ]In consume() (currently :57-77), add two more named-reaction branches alongside the existing NUKE one:
elif rname == "NUKE": play("special_stomp") continue elif rname == "FREEZE": play("special_freeze") continue elif rname == "DRONE": play("special_rally") continue- Step 5: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: PASS.
- Step 6: Test-count guard
Run: scripts/check-test-count.sh
Expected: exit 0.
- Step 7: Commit
git add sim/sim.gd audio/audio_manager.gd tests/test_skirmisher.gd tests/test_audio_manager.gdgit commit -m "feat(audio): wire FREEZE and DRONE reactions to their own sounds"Task 5: Update the sound audit + final verification
Section titled “Task 5: Update the sound audit + final verification”Files:
- Modify:
audio/SOUND_MAP.md
Interfaces:
-
Consumes: nothing (documentation-only task).
-
Produces: an audit document whose status column matches the code exactly, ready as the starting point for Phase 2.
-
Step 1: Update the four changed rows
In audio/SOUND_MAP.md:
-
Player table, “Weapon pickup (story mode)” row: change
DOUBLE→GENERIC, and the Notes cell toFixed 2026-07-01 — no longer double-sounds; still shares special_barrage.wav with other named reactions (Phase 3 target). -
Player table, “Powerup: NUKE” row: change
DOUBLE→DONE, and the Notes cell toFixed 2026-07-01 — no longer double-sounds. -
Player table, “Powerup: FREEZE” row: change
MISWIRED→DONE, and the Notes cell toFixed 2026-07-01 — now plays special_freeze.wav via a named FREEZE reaction. -
Player table, “Decoy/drone deploy” row: change
GENERIC→DONE, and the Notes cell toFixed 2026-07-01 — now plays special_rally.wav instead of the generic reaction sound. -
Step 2: Update the Mixing/Infra table
-
“Audio bus layout” row: change
SILENT→DONE, Notes toMaster → {SFX, Music, UI} added 2026-07-01 (audio/bus_layout.tres); AudioManager.play() takes a bus param, defaults to SFX. -
“Dead/unused
.wavassets” row: update the file list toattack.wav,enemy_hit.wav,special_aegis.wav,wave_clear.wav(4 remaining —special_freeze.wav/special_rally.wavare now wired), Notes tospecial_freeze/special_rally wired 2026-07-01; remaining 4 are candidates for Phase 2 (weapon fire) and Phase 3 (boss/enemy telegraphs). -
Step 3: Recompute and update the snapshot line
Change the snapshot line at the top of the file to:
**Snapshot (2026-07-01, post-Phase-1):** 11 DONE · 23 GENERIC · 37 SILENT · 1 MISWIRED · 0 DOUBLE — out of 72 tracked events across Weapons/Enemies/Bosses/Player/UI/Music. Mixing/Infra items are tracked separately (not counted here) since they're infrastructure, not per-event sound. Recompute this line by hand whenever a row's status changes.(1 MISWIRED remains: the Warden’s missile-impact-reuses-enemy_death.wav row in the Bosses table — untouched by this phase, a Phase 3 target.)
- Step 4: Full verification pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: PASS, full suite.
Run: scripts/check-test-count.sh
Expected: exit 0.
Run: godot --headless --path . --quit-after 60 2>&1 | grep "SCRIPT ERROR"
Expected: no output.
- Step 5: Commit
git add audio/SOUND_MAP.mdgit commit -m "docs(audio): update sound map after Phase 1 foundation fixes"