Skip to content

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.

  • 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 in tests/test_determinism_checksum.gd + test_determinism_crystals.gd) must stay byte-identical. fx_events is 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 with Sim.new(seed, SimContentFixture.db()), construct an AudioManager with AudioManager.new() + add_child_autofree(am) + await get_tree().process_frame.
  • audio/SOUND_MAP.md is updated in place (not appended to) as the very last task, once every fix in this phase is verified working.
  • Renaming existing .wav files (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(), the consume() internals) keeps working unchanged because bus defaults 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 = false
bus/0/mute = false
bus/0/bypass_fx = false
bus/0/volume_db = 0.0
bus/1/name = "SFX"
bus/1/solo = false
bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = "Master"
bus/2/name = "Music"
bus/2/solo = false
bus/2/mute = false
bus/2/bypass_fx = false
bus/2/volume_db = 0.0
bus/2/send = "Master"
bus/3/name = "UI"
bus/3/solo = false
bus/3/mute = false
bus/3/bypass_fx = false
bus/3/volume_db = 0.0
bus/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 bus parameter to play() 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
Terminal window
git add audio/bus_layout.tres project.godot audio/audio_manager.gd tests/test_audio_manager.gd
git 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 one fx_events entry per collected pickup (the named reaction), 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
Terminal window
git add sim/sim.gd tests/test_fx_events.gd
git commit -m "fix(sim): stop double-sounding weapon pickups"

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 one fx_events entry (the named NUKE reaction), 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
Terminal window
git add sim/sim.gd tests/test_skirmisher.gd
git 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 named FREEZE reaction)
  • 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 named reaction fx event with "name": "FREEZE" (mirroring the existing NUKE pattern at sim/sim.gd:2098 for DRONE, which already exists and needs no sim.gd change). AudioManager.consume() special-cases rname == "FREEZE"special_freeze and rname == "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
Terminal window
git add sim/sim.gd audio/audio_manager.gd tests/test_skirmisher.gd tests/test_audio_manager.gd
git 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 DOUBLEGENERIC, and the Notes cell to Fixed 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 DOUBLEDONE, and the Notes cell to Fixed 2026-07-01 — no longer double-sounds.

  • Player table, “Powerup: FREEZE” row: change MISWIREDDONE, and the Notes cell to Fixed 2026-07-01 — now plays special_freeze.wav via a named FREEZE reaction.

  • Player table, “Decoy/drone deploy” row: change GENERICDONE, and the Notes cell to Fixed 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 SILENTDONE, Notes to Master → {SFX, Music, UI} added 2026-07-01 (audio/bus_layout.tres); AudioManager.play() takes a bus param, defaults to SFX.

  • “Dead/unused .wav assets” row: update the file list to attack.wav, enemy_hit.wav, special_aegis.wav, wave_clear.wav (4 remaining — special_freeze.wav/special_rally.wav are now wired), Notes to special_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
Terminal window
git add audio/SOUND_MAP.md
git commit -m "docs(audio): update sound map after Phase 1 foundation fixes"