Sound Phase 2 — Weapon Fire Sounds Implementation Plan
Sound Phase 2 — Weapon Fire Sounds Implementation Plan
Section titled “Sound Phase 2 — Weapon Fire Sounds 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: Give every one of the 6 base weapons (Pulse, Melee/Blade, Nova, Beam, Turret, Scatter) a distinct fire sound, and add the missing firing-moment fx_events for Turret and Scatter (visual spark + sound — they currently only show their traveling projectile, not the moment of firing).
Architecture: Four base weapons (Pulse/Melee/Nova/Beam) already emit an fx_events kind every time they fire (bolt/slash/nova/beam) but AudioManager.SOUND_FOR_FX has no entry for them, so they’re silent — wiring is a one-line dictionary addition each. Turret and Scatter emit no fx kind at all at the firing moment (their projectiles are already visible via the shared projectile pool/renderer, so this is NOT the “invisible entity” bug in full — just the firing instant itself), so each needs one fx_events.append(...) call added at its existing fire call site, one new AudioManager.SOUND_FOR_FX entry, and one new FxManager.consume match arm using the existing generic _spawn() spark primitive (same pattern as the dmgnum/death cases). Orbit is architecturally different — it fires continuously with no per-shot event, so it gets a dedicated always-on/off looping AudioStreamPlayer toggled once per frame by main.gd based on sim.active_weapon_ids.has("orbit"), using a WAV with baked-in loop points (Godot’s AudioStreamWAV loops natively once edit/loop_mode is set in its .import file — no custom looping code needed).
Tech Stack: Godot 4.6 / typed GDScript, GUT 9.6.0 for tests.
Global Constraints
Section titled “Global Constraints”- Determinism baseline must stay byte-identical:
snapshot_string().hash() = 2730172591,state_checksum() = 4075578713(pinned intests/test_determinism_checksum.gd+test_determinism_crystals.gd). Every change in this plan is render-side (AudioManager,FxManager,main.gd) or a plainfx_events.append(...)at an existing call site —fx_eventsis excluded from both hashes by design, so this must require zero re-pinning. /simstays pure logic: the twofx_events.append(...)additions (Turret, Scatter) are the only/simchanges in this plan, and they follow the exact same shape as every other weapon’s existing fx emission (sim/weapon_pulse.gd:59,sim/weapon_nova.gd:23, etc.) — no Node/render/Input/Engine/Time APIs introduced.- Every new
class_namefile requiresgodot --headless --path . --importbefore the next boot-check or test run — not applicable here (no newclass_namefiles), but re-run--importanyway after copying new.wavassets so Godot picks them up. - Boot-check after every task:
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"must be empty. Never wrap withtimeout(not on macOS PATH). - Full suite + count guard after every task:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitthenbash scripts/check-test-count.sh— trust the script count, not just “all passed”. - Commit only when the user asks — hold off on running the
git commitcommands shown in each task until the user confirms, consistent with this session’s established pattern. - Source audio files are staged at
/private/tmp/claude-501/-Users-chris-Claude-bullet-heaven/197c7a17-e8a6-46cf-a1f5-5bd75f8a89dc/scratchpad/audition/(7 files:pulse_zap.wav,melee_swish.wav,nova_whoosh.wav,beam_zap.wav,turret_impact.wav,scatter_explosion.wav,orbit_ambience.wav). If this path no longer exists when a task runs, the source pack is still at/Users/chris/Downloads/99Sounds Sci-Fi Sound Effects/and the exact ffmpeg trim commands are indocs/superpowers/specs/2026-07-01-sound-phase2-weapon-fire-design.md§2.
File Structure
Section titled “File Structure”New files:
audio/pulse_zap.wav,audio/melee_swish.wav,audio/nova_whoosh.wav,audio/beam_zap.wav,audio/turret_impact.wav,audio/scatter_explosion.wav,audio/orbit_ambience.wav— the 7 sourced/trimmed assets, copied into the trackedaudio/directory.tests/test_sound_phase2_weapons.gd— new test file covering all weapon-fire sound wiring (one-shots + Orbit loop toggle + FxManager visual coverage assertion).
Modified files:
audio/audio_manager.gd—SOUND_FOR_FXgains 6 entries (bolt,slash,nova,beam,turret_fire,scatter_fire);_ready()’skeysarray gains the 7 new stream names; new_orbit_player: AudioStreamPlayermember +set_orbit_active(active: bool) -> voidmethod.sim/weapon_turret.gd—_fire()gains onefx_events.append(...)call.sim/weapon_scatter.gd—update()gains onefx_events.append(...)call after firing.fx/fx_manager.gd—consume()gains two new match arms (turret_fire,scatter_fire) using the existing_spawn()primitive.main.gd— one new line callingaudio.set_orbit_active(...)right after the existingaudio.consume(sim.fx_events)call.
Task 1: Stage assets + wire the 4 already-emitted, currently-unmapped fx kinds
Section titled “Task 1: Stage assets + wire the 4 already-emitted, currently-unmapped fx kinds”Files:
- Create:
audio/pulse_zap.wav,audio/melee_swish.wav,audio/nova_whoosh.wav,audio/beam_zap.wav(copied from the staged sources) - Modify:
audio/audio_manager.gd - Test:
tests/test_sound_phase2_weapons.gd(new)
Interfaces:
-
Consumes:
AudioManager.SOUND_FOR_FX: Dictionary(existing,audio/audio_manager.gd:11),AudioManager._streams: Dictionary(existing, populated in_ready()), the existing fx kindsbolt/slash/nova/beamalready emitted bysim/weapon_pulse.gd:59,sim/weapon_melee.gd:58,sim/weapon_nova.gd:23,sim/weapon_beam.gd:51. -
Produces:
SOUND_FOR_FXentries mappingbolt→"pulse_zap",slash→"melee_swish",nova→"nova_whoosh",beam→"beam_zap"— later tasks (2, 3) add more entries to the same dictionary, so preserve this exact key naming. -
Step 1: Copy the 4 one-shot weapon audio assets into the tracked
audio/directory
SRC="/private/tmp/claude-501/-Users-chris-Claude-bullet-heaven/197c7a17-e8a6-46cf-a1f5-5bd75f8a89dc/scratchpad/audition"cp "$SRC/pulse_zap.wav" audio/pulse_zap.wavcp "$SRC/melee_swish.wav" audio/melee_swish.wavcp "$SRC/nova_whoosh.wav" audio/nova_whoosh.wavcp "$SRC/beam_zap.wav" audio/beam_zap.wav- Step 2: Write the failing test
Create tests/test_sound_phase2_weapons.gd:
extends GutTest
# Phase 2 of the sound-effects overhaul: every weapon fire event gets a distinct sound.# See docs/superpowers/specs/2026-07-01-sound-phase2-weapon-fire-design.md.
func _am() -> AudioManager: var am := AudioManager.new() add_child_autofree(am) return am
func test_bolt_kind_maps_to_pulse_zap() -> void: assert_eq(AudioManager.SOUND_FOR_FX.get("bolt", ""), "pulse_zap", "Pulse fire (bolt) plays pulse_zap")
func test_slash_kind_maps_to_melee_swish() -> void: assert_eq(AudioManager.SOUND_FOR_FX.get("slash", ""), "melee_swish", "Melee swing (slash) plays melee_swish")
func test_nova_kind_maps_to_nova_whoosh() -> void: assert_eq(AudioManager.SOUND_FOR_FX.get("nova", ""), "nova_whoosh", "Nova pulse plays nova_whoosh")
func test_beam_kind_maps_to_beam_zap() -> void: assert_eq(AudioManager.SOUND_FOR_FX.get("beam", ""), "beam_zap", "Beam fire plays beam_zap")
func test_new_weapon_streams_load() -> void: var am := _am() await get_tree().process_frame for k in ["pulse_zap", "melee_swish", "nova_whoosh", "beam_zap"]: assert_true(am._streams.has(k), "stream loaded for key %s" % k)
func test_bolt_fires_distinct_sound_from_beam() -> void: # Regression guard: bolt and beam must not accidentally collapse onto the same file. assert_ne(AudioManager.SOUND_FOR_FX["bolt"], AudioManager.SOUND_FOR_FX["beam"], "Pulse and Beam must sound different from each other")- Step 3: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: FAIL — SOUND_FOR_FX has no bolt/slash/nova/beam keys yet, so .get(...) returns the default "" and the equality assertions fail.
- Step 4: Write minimal implementation
In audio/audio_manager.gd, update SOUND_FOR_FX and the _ready() keys array:
## Maps fx_event `kind` -> sound key. `dmgnum` intentionally omitted (too frequent).const SOUND_FOR_FX: Dictionary = { "death": "enemy_death", "reaction": "special_barrage", "chain": "special_frost", "pickup": "ui_tap", "bolt": "pulse_zap", "slash": "melee_swish", "nova": "nova_whoosh", "beam": "beam_zap",}func _ready() -> void: # Load every stream referenced by SOUND_FOR_FX plus discrete helper sounds. 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", "pulse_zap", "melee_swish", "nova_whoosh", "beam_zap", ] for k in keys: var path := "res://audio/%s.wav" % k var s: AudioStream = load(path) if s != null: _streams[k] = s
# Build polyphony pool. for _i in range(POOL_SIZE): var p := AudioStreamPlayer.new() p.bus = "SFX" add_child(p) _pool.append(p)- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: PASS (all 6 tests in the file so far)
- Step 6: Boot-check + import
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"Expected: --import shows the 4 new .wav files processed; the grep is empty.
- Step 7: Full suite + count guard
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: all green; script count = test_*.gd file count (one more script than before this task).
- Step 8: Determinism check
Confirm tests/test_determinism_checksum.gd and test_determinism_crystals.gd still assert 2730172591 / 4075578713 (already covered by Step 7’s full run — just note in this task’s own tracking that these two files were in the green set).
- Step 9: Commit
git add audio/pulse_zap.wav audio/melee_swish.wav audio/nova_whoosh.wav audio/beam_zap.wav audio/audio_manager.gd tests/test_sound_phase2_weapons.gdgit commit -m "feat(audio): wire Pulse/Melee/Nova/Beam fire sounds (Sound Phase 2)"Task 2: Turret firing sound + visual spark
Section titled “Task 2: Turret firing sound + visual spark”Files:
- Create:
audio/turret_impact.wav(copied from the staged source) - Modify:
sim/weapon_turret.gd,audio/audio_manager.gd,fx/fx_manager.gd - Test:
tests/test_sound_phase2_weapons.gd(append)
Interfaces:
-
Consumes:
Sim.fx_events: Array[Dictionary](existing,/sim-pure — any RefCounted with afx_eventsarray reference works for a unit test without a fullSim, matching howsim/weapon_turret.gdaccesses it:sim.fx_events.append(...)),FxManager._spawn(pos: Vector2, color: Color, life: float, s0: float, s1: float) -> void(existing,fx/fx_manager.gd:470). -
Produces: a new fx kind
"turret_fire"with payload{"kind": "turret_fire", "pos": Vector2};AudioManager.SOUND_FOR_FX["turret_fire"] = "turret_impact"; a newFxManager.consumematch arm for"turret_fire". -
Step 1: Copy the asset
SRC="/private/tmp/claude-501/-Users-chris-Claude-bullet-heaven/197c7a17-e8a6-46cf-a1f5-5bd75f8a89dc/scratchpad/audition"cp "$SRC/turret_impact.wav" audio/turret_impact.wav- Step 2: Write the failing test
Append to tests/test_sound_phase2_weapons.gd:
func test_turret_fire_emits_fx_event() -> void: var w := WeaponTurret.new({"base_damage": 5.0}) var sim := Sim.new(1, SimContentFixture.db()) # Force an immediate deploy + fire by pre-setting the internal timers via apply_mod # is not possible (private vars) — instead call _fire directly, which is what # update() calls once a turret is deployed and its fire_timer elapses. w._fire(sim, Vector2(10, 20)) assert_eq(sim.fx_events.size(), 1, "one fx_event emitted per turret shot") assert_eq(sim.fx_events[0]["kind"], "turret_fire", "kind is turret_fire") assert_eq(sim.fx_events[0]["pos"], Vector2(10, 20), "fx_event carries the firing turret's position")
func test_turret_fire_kind_maps_to_turret_impact() -> void: assert_eq(AudioManager.SOUND_FOR_FX.get("turret_fire", ""), "turret_impact", "Turret fire plays turret_impact")- Step 3: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: FAIL — _fire doesn’t append to fx_events yet, and the SOUND_FOR_FX key doesn’t exist.
- Step 4: Write minimal implementation
In sim/weapon_turret.gd, modify _fire:
func _fire(sim: Sim, from_pos: Vector2) -> void: # Find nearest enemy to THIS turret position var best := -1 var best_d2 := INF for i in range(sim.enemies.count): var d2 := from_pos.distance_squared_to(sim.enemies.pos[i]) if d2 < best_d2: best_d2 = d2 best = i if best == -1: return var dir := (sim.enemies.pos[best] - from_pos).normalized() var dmg := base_damage * damage_mult sim.projectiles.add_proj(from_pos, dir * PROJ_SPEED, PROJ_RADIUS, PROJ_LIFETIME, dmg, -1, sim.mods.projectile_pierce, sim.mods.projectile_split) sim.fx_events.append({"kind": "turret_fire", "pos": from_pos})In audio/audio_manager.gd, add to SOUND_FOR_FX:
const SOUND_FOR_FX: Dictionary = { "death": "enemy_death", "reaction": "special_barrage", "chain": "special_frost", "pickup": "ui_tap", "bolt": "pulse_zap", "slash": "melee_swish", "nova": "nova_whoosh", "beam": "beam_zap", "turret_fire": "turret_impact",}And add "turret_impact" to the _ready() keys array (append after "beam_zap").
In fx/fx_manager.gd, add a new match arm inside consume(), in the “essential weapon feedback” section (right after the "nova" case, before the "reaction" case) since a turret firing is weapon feedback, not disposable juice:
"turret_fire": _spawn(ev["pos"], Color(1.0, 0.85, 0.4), 0.12, 0.6, 0.15)- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: PASS (all 8 tests in the file so far)
- Step 6: Boot-check + import
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"Expected: empty grep.
- Step 7: Full suite + count guard
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: all green.
- Step 8: Determinism check
sim/weapon_turret.gd’s _fire only appends to fx_events (excluded from both hashes) — no other state changed. Confirm the full-suite run in Step 7 shows test_determinism_checksum.gd and test_determinism_crystals.gd passing at the pinned values.
- Step 9: Commit
git add audio/turret_impact.wav sim/weapon_turret.gd audio/audio_manager.gd fx/fx_manager.gd tests/test_sound_phase2_weapons.gdgit commit -m "feat(audio): Turret fire gets a sound + muzzle spark (Sound Phase 2)"Task 3: Scatter firing sound + visual spark
Section titled “Task 3: Scatter firing sound + visual spark”Files:
- Create:
audio/scatter_explosion.wav(copied from the staged source) - Modify:
sim/weapon_scatter.gd,audio/audio_manager.gd,fx/fx_manager.gd - Test:
tests/test_sound_phase2_weapons.gd(append)
Interfaces:
-
Consumes: same
Sim.fx_events/FxManager._spawnas Task 2. -
Produces: fx kind
"scatter_fire"with payload{"kind": "scatter_fire", "pos": Vector2, "dir": float};AudioManager.SOUND_FOR_FX["scatter_fire"] = "scatter_explosion"; a newFxManager.consumematch arm for"scatter_fire". -
Step 1: Copy the asset
SRC="/private/tmp/claude-501/-Users-chris-Claude-bullet-heaven/197c7a17-e8a6-46cf-a1f5-5bd75f8a89dc/scratchpad/audition"cp "$SRC/scatter_explosion.wav" audio/scatter_explosion.wav- Step 2: Write the failing test
Append to tests/test_sound_phase2_weapons.gd:
func test_scatter_fire_emits_fx_event() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.spawn_story_enemy("swarmer", Vector2(100, 0)) # a target so _nearest_enemy_index succeeds var w := WeaponScatter.new(sim.content.weapon("scatter")) w.update(sim, 1.0) # cooldown starts at 0.0, so this tick always fires var scatter_events := sim.fx_events.filter(func(e): return e["kind"] == "scatter_fire") assert_eq(scatter_events.size(), 1, "one fx_event emitted per scatter shot") assert_eq(scatter_events[0]["pos"], sim.player.pos, "fx_event fires from the player's position")
func test_scatter_fire_kind_maps_to_scatter_explosion() -> void: assert_eq(AudioManager.SOUND_FOR_FX.get("scatter_fire", ""), "scatter_explosion", "Scatter fire plays scatter_explosion")- Step 3: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: FAIL — update() doesn’t emit scatter_fire yet, and the SOUND_FOR_FX key doesn’t exist.
- Step 4: Write minimal implementation
In sim/weapon_scatter.gd, modify the end of update() (after the pellet-spawn loop, still inside the function):
func update(sim: Sim, dt: float) -> void: _timer -= dt if _timer > 0.0: return var target := _nearest_enemy_index(sim) if target == -1: return _timer = cooldown / sim.effective_fire_rate() var base := (sim.enemies.pos[target] - sim.player.pos).angle() var dmg := base_damage * damage_mult var n := maxi(pellets, 1) var span := deg_to_rad(spread_deg) for k in range(n): # spread pellets evenly across the fan, centred on the aim direction var t: float = 0.0 if n == 1 else (float(k) / float(n - 1) - 0.5) var a := base + t * span var dir := Vector2(cos(a), sin(a)) sim.projectiles.add_proj(sim.player.pos, dir * proj_speed, proj_radius, proj_lifetime, dmg, sim.scatter_element_idx, sim.mods.projectile_pierce, sim.mods.projectile_split) sim.fx_events.append({"kind": "scatter_fire", "pos": sim.player.pos, "dir": base})(Only the final sim.fx_events.append(...) line is new — the rest of the function is unchanged, shown in full so the append’s placement, after the pellet loop, is unambiguous.)
In audio/audio_manager.gd, add to SOUND_FOR_FX:
const SOUND_FOR_FX: Dictionary = { "death": "enemy_death", "reaction": "special_barrage", "chain": "special_frost", "pickup": "ui_tap", "bolt": "pulse_zap", "slash": "melee_swish", "nova": "nova_whoosh", "beam": "beam_zap", "turret_fire": "turret_impact", "scatter_fire": "scatter_explosion",}And add "scatter_explosion" to the _ready() keys array (append after "turret_impact").
In fx/fx_manager.gd, add a new match arm inside consume(), right after the "turret_fire" case added in Task 2:
"scatter_fire": _spawn(ev["pos"], Color(1.0, 0.5, 0.3), 0.14, 0.7, 0.2)- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: PASS (all 10 tests in the file so far)
- Step 6: Boot-check + import
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"Expected: empty grep.
- Step 7: Full suite + count guard
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: all green.
- Step 8: Determinism check
Same reasoning as Task 2 Step 8 — fx_events is excluded from both hashes, no other state touched. Confirm the pinned values held in Step 7’s run.
- Step 9: Commit
git add audio/scatter_explosion.wav sim/weapon_scatter.gd audio/audio_manager.gd fx/fx_manager.gd tests/test_sound_phase2_weapons.gdgit commit -m "feat(audio): Scatter fire gets a sound + muzzle spark (Sound Phase 2)"Task 4: Orbit continuous drone hum
Section titled “Task 4: Orbit continuous drone hum”Files:
- Create:
audio/orbit_ambience.wav(copied from the staged source, then loop-configured) - Modify:
audio/audio_manager.gd,main.gd - Test:
tests/test_sound_phase2_weapons.gd(append)
Interfaces:
-
Consumes:
Sim.active_weapon_ids: Array[String](existing,sim/sim.gd:617) —main.gdcheckssim.active_weapon_ids.has("orbit")once per frame. -
Produces:
AudioManager.set_orbit_active(active: bool) -> void— idempotent (calling it repeatedly with the same value does not restart the loop);AudioManager._orbit_player: AudioStreamPlayer. -
Step 1: Copy the asset and set its loop points
SRC="/private/tmp/claude-501/-Users-chris-Claude-bullet-heaven/197c7a17-e8a6-46cf-a1f5-5bd75f8a89dc/scratchpad/audition"cp "$SRC/orbit_ambience.wav" audio/orbit_ambience.wavgodot --headless --path . --importThe --import run generates audio/orbit_ambience.wav.import with default (non-looping) params. Edit it to enable a whole-file forward loop:
python3 - <<'PYEOF'path = "audio/orbit_ambience.wav.import"with open(path) as f: content = f.read()content = content.replace("edit/loop_mode=0", "edit/loop_mode=1")with open(path, "w") as f: f.write(content)PYEOFgodot --headless --path . --importedit/loop_mode=1 is Godot’s “Forward” loop mode; edit/loop_begin=0/edit/loop_end=-1 (already the defaults) mean the loop spans the whole file, which is fine since the source segment was extracted from a stable, non-fading region of the original ambience (see the design spec §2) — there’s no separate fade-in/out inside this file to create a seam.
- Step 2: Write the failing test
Append to tests/test_sound_phase2_weapons.gd:
func test_orbit_ambience_stream_loads() -> void: var am := _am() await get_tree().process_frame assert_true(am._streams.has("orbit_ambience"), "orbit_ambience stream loaded")
func test_set_orbit_active_true_starts_the_loop_player() -> void: var am := _am() await get_tree().process_frame am.set_orbit_active(true) assert_true(am._orbit_player.playing, "orbit loop player is playing once active")
func test_set_orbit_active_false_stops_the_loop_player() -> void: var am := _am() await get_tree().process_frame am.set_orbit_active(true) am.set_orbit_active(false) assert_false(am._orbit_player.playing, "orbit loop player stops once inactive")
func test_set_orbit_active_true_twice_does_not_restart() -> void: var am := _am() await get_tree().process_frame am.set_orbit_active(true) var pos_before: float = am._orbit_player.get_playback_position() am.set_orbit_active(true) # called again while already active var pos_after: float = am._orbit_player.get_playback_position() assert_gte(pos_after, pos_before, "calling set_orbit_active(true) again does not restart playback from 0")- Step 3: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: FAIL — orbit_ambience isn’t in the load list yet, and set_orbit_active/_orbit_player don’t exist (Parse Error on the whole test file until they do).
- Step 4: Write minimal implementation
In audio/audio_manager.gd:
class_name AudioManagerextends Node## Centralised render-side sound manager.## Reads fx_events each tick (consume) + exposes discrete one-shot helpers.## Per-frame cap (PER_FRAME_CAP) prevents audio spam on mass-death events.## Headless / CI: AudioStreamPlayer + load() work without crashing on the dummy driver.
const PER_FRAME_CAP := 4const POOL_SIZE := 16
## Maps fx_event `kind` -> sound key. `dmgnum` intentionally omitted (too frequent).const SOUND_FOR_FX: Dictionary = { "death": "enemy_death", "reaction": "special_barrage", "chain": "special_frost", "pickup": "ui_tap", "bolt": "pulse_zap", "slash": "melee_swish", "nova": "nova_whoosh", "beam": "beam_zap", "turret_fire": "turret_impact", "scatter_fire": "scatter_explosion",}
var _streams: Dictionary = {} # key -> AudioStreamvar _pool: Array[AudioStreamPlayer] = [] # polyphony poolvar _pool_cursor: int = 0 # next-to-use slot (round-robin)var _played_this_frame: int = 0 # reset each consume() callvar _orbit_player: AudioStreamPlayer # dedicated looping player for Orbit's continuous drone
func _ready() -> void: # Load every stream referenced by SOUND_FOR_FX plus discrete helper sounds. 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", "pulse_zap", "melee_swish", "nova_whoosh", "beam_zap", "turret_impact", "scatter_explosion", "orbit_ambience", ] for k in keys: var path := "res://audio/%s.wav" % k var s: AudioStream = load(path) if s != null: _streams[k] = s
# Build polyphony pool. for _i in range(POOL_SIZE): var p := AudioStreamPlayer.new() p.bus = "SFX" add_child(p) _pool.append(p)
# Orbit's continuous drone: a separate always-idle-until-toggled player, not part # of the one-shot pool (its stream loops natively via baked-in WAV loop points). _orbit_player = AudioStreamPlayer.new() _orbit_player.bus = "SFX" if _streams.has("orbit_ambience"): _orbit_player.stream = _streams["orbit_ambience"] add_child(_orbit_player)
## Starts/stops Orbit's looping drone. Idempotent: calling with the same state twice## is a no-op (does not restart playback from 0 while already active).func set_orbit_active(active: bool) -> void: if active and not _orbit_player.playing: _orbit_player.play() elif not active and _orbit_player.playing: _orbit_player.stop()In main.gd, right after the existing audio.consume(sim.fx_events) line:
damage_numbers.consume(sim.fx_events) # once per tick, so a number isn't spawned twice audio.consume(sim.fx_events) audio.set_orbit_active(sim.active_weapon_ids.has("orbit"))- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_sound_phase2_weapons.gd -gexit
Expected: PASS (all 14 tests in the file)
- Step 6: Boot-check + import
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"Expected: empty grep. Boot smoke exercises a real survival run, which starts with only the blade active — set_orbit_active(false) fires every tick and stays a no-op, confirming the toggle doesn’t error when Orbit was never equipped.
- Step 7: Full suite + count guard
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: all green.
- Step 8: Determinism check
main.gd’s new line only reads sim.active_weapon_ids (does not mutate sim state) and drives a render-side audio player — no /sim state changed. Confirm the pinned values held in Step 7’s run.
- Step 9: Commit
git add audio/orbit_ambience.wav audio/orbit_ambience.wav.import audio/audio_manager.gd main.gd tests/test_sound_phase2_weapons.gdgit commit -m "feat(audio): Orbit gets a continuous looping drone hum (Sound Phase 2)"Task 5: Update the living audit doc + final verification
Section titled “Task 5: Update the living audit doc + final verification”Files:
- Modify:
audio/SOUND_MAP.md
Interfaces: None — documentation + verification only.
- Step 1: Update
audio/SOUND_MAP.md’s Weapons section
Change every row in the Weapons table from SILENT to DONE, filling in the Sound file column:
| Weapon (→ evolution) | Fire event | Sound file | Status | Sound type needed | Notes ||---|---|---|---|---|---|| Pulse → Tesla Coil | `bolt` (`weapon_pulse.gd:59`) | `pulse_zap.wav` | DONE | zap-cast | Evolution should layer an extra crackle, not replace the base sound — not yet built, tracked as a future nice-to-have || Nova → Supernova | `nova` (`weapon_nova.gd:23`) | `nova_whoosh.wav` | DONE | whoosh-cast | || Orbit → Event Horizon | continuous, no discrete event (`AudioManager.set_orbit_active`, toggled from `main.gd`) | `orbit_ambience.wav` | DONE | drone-ambient | Looping player, not the one-shot fx_events pool || Beam → Lance | `beam` (`weapon_beam.gd:51`) | `beam_zap.wav` | DONE | zap-cast | Shares the `beam` fx kind with the Lancer enemy/Eye boss telegraph (Phase 3 will differentiate) || Turret → Garrison | `turret_fire` (`weapon_turret.gd`, new) | `turret_impact.wav` | DONE | impact | || Scatter → Fusillade | `scatter_fire` (`weapon_scatter.gd`, new) | `scatter_explosion.wav` | DONE | explosion | || Melee/Blade → Tempest | `slash` (`weapon_melee.gd:58`) | `melee_swish.wav` | DONE | whoosh-cast | |Update the snapshot line near the top of the file (recompute the DONE/GENERIC/SILENT/MISWIRED/DOUBLE counts by hand — this phase moves 6 weapons from SILENT to DONE; Turret and Scatter’s fire events are newly-existing rows, not previously-counted SILENT rows, so add them to the total tracked-event count too).
- Step 2: Full ritual, one more time, on the whole feature together
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: all green, SCRIPT ERROR grep empty, test count matches tests/test_*.gd file count exactly.
- Step 3: Determinism, one more time
Confirm tests/test_determinism_checksum.gd and test_determinism_crystals.gd both still report 2730172591 / 4075578713 — zero movement across the whole feature (every change was render-side or fx_events-only).
- Step 4: Note what’s NOT covered by this plan
This plan does not include: bumping Sim_Const.BUILD, committing a docs(claude.md) catch-up entry, syncing to the tvOS repo, or building/installing to any device. Those follow the same bh-deploy ritual used throughout this session (sync the changed files — sim/weapon_turret.gd, sim/weapon_scatter.gd, audio/audio_manager.gd, fx/fx_manager.gd, main.gd, the 7 new .wav+.import files, plus the whole tests/ dir — verify independently there, export/build/install) and should only happen once the user explicitly asks for the commit + deploy, matching every prior chunk this session.
- Step 5: Commit
git add audio/SOUND_MAP.mdgit commit -m "docs(audio): mark all 6 weapons DONE in SOUND_MAP (Sound Phase 2 complete)"Plan self-review
Section titled “Plan self-review”Spec coverage:
- §2 asset sourcing/mapping table → Tasks 1-4 each copy the exact file named for their weapon(s). ✓
- §3 architecture: one-shot kinds (
bolt/slash/nova/beam) wired viaSOUND_FOR_FX→ Task 1. ✓ - §3 architecture: new
turret_fire/scatter_firekinds, each needing an fx_events emission + audio mapping + visual case → Tasks 2-3. ✓ - §3 architecture: Orbit’s separate continuous/looping mechanism (
set_orbit_active,main.gdper-frame toggle, baked WAV loop points) → Task 4. ✓ - §3 known accepted
beamkind overlap with enemy telegraphs → noted in Task 5’s SOUND_MAP update, not re-litigated as a bug to fix here. ✓ - §3 evolutions layer-not-replace → explicitly called out as NOT built in this phase (Task 5’s SOUND_MAP note), matching the spec’s “future nice-to-have” framing. ✓
- §4 testing → every new fx kind has a
SOUND_FOR_FX-mapping test; Orbit’s idempotent start/stop is tested;tests/test_sound_phase2_weapons.gdfollows the existingtest_audio_manager.gdidioms (same_am()/add_child_autofreepattern). ✓ - §5 determinism → each task’s Step 8 confirms the reasoning; Task 5 does the final whole-feature re-check. ✓
- §6 out of scope → Task 5 Step 4 restates it verbatim so the plan’s own boundary is explicit at hand-off.
Placeholder scan: no TBD/TODO; every step has complete, runnable code; Task 3’s “similar to Task 2” risk was avoided by writing out weapon_scatter.gd’s full update() body rather than just the diff line, since a reader may work tasks out of order.
Type consistency: AudioManager.set_orbit_active(active: bool) -> void and _orbit_player: AudioStreamPlayer are declared once (Task 4) and used identically in that task’s tests — no other task references them. SOUND_FOR_FX‘s dictionary is shown in full (not diffed) in Tasks 2, 3, and 4 so each task’s code block is copy-pasteable on its own without needing to reconstruct prior tasks’ edits by hand.