Skip to content

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.

  • Determinism baseline must stay byte-identical: snapshot_string().hash() = 2730172591, state_checksum() = 4075578713 (pinned in tests/test_determinism_checksum.gd + test_determinism_crystals.gd). Every change in this plan is render-side (AudioManager, FxManager, main.gd) or a plain fx_events.append(...) at an existing call site — fx_events is excluded from both hashes by design, so this must require zero re-pinning.
  • /sim stays pure logic: the two fx_events.append(...) additions (Turret, Scatter) are the only /sim changes 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_name file requires godot --headless --path . --import before the next boot-check or test run — not applicable here (no new class_name files), but re-run --import anyway after copying new .wav assets 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 with timeout (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 -gexit then bash 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 commit commands 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 in docs/superpowers/specs/2026-07-01-sound-phase2-weapon-fire-design.md §2.

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 tracked audio/ 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.gdSOUND_FOR_FX gains 6 entries (bolt, slash, nova, beam, turret_fire, scatter_fire); _ready()’s keys array gains the 7 new stream names; new _orbit_player: AudioStreamPlayer member + set_orbit_active(active: bool) -> void method.
  • sim/weapon_turret.gd_fire() gains one fx_events.append(...) call.
  • sim/weapon_scatter.gdupdate() gains one fx_events.append(...) call after firing.
  • fx/fx_manager.gdconsume() gains two new match arms (turret_fire, scatter_fire) using the existing _spawn() primitive.
  • main.gd — one new line calling audio.set_orbit_active(...) right after the existing audio.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 kinds bolt/slash/nova/beam already emitted by sim/weapon_pulse.gd:59, sim/weapon_melee.gd:58, sim/weapon_nova.gd:23, sim/weapon_beam.gd:51.

  • Produces: SOUND_FOR_FX entries mapping bolt"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

Terminal window
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.wav
cp "$SRC/melee_swish.wav" audio/melee_swish.wav
cp "$SRC/nova_whoosh.wav" audio/nova_whoosh.wav
cp "$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
Terminal window
godot --headless --path . --import
godot --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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
bash scripts/check-test-count.sh

Expected: 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
Terminal window
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.gd
git 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 a fx_events array reference works for a unit test without a full Sim, matching how sim/weapon_turret.gd accesses 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 new FxManager.consume match arm for "turret_fire".

  • Step 1: Copy the asset

Terminal window
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
Terminal window
godot --headless --path . --import
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"

Expected: empty grep.

  • Step 7: Full suite + count guard
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
bash scripts/check-test-count.sh

Expected: 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
Terminal window
git add audio/turret_impact.wav sim/weapon_turret.gd audio/audio_manager.gd fx/fx_manager.gd tests/test_sound_phase2_weapons.gd
git 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._spawn as 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 new FxManager.consume match arm for "scatter_fire".

  • Step 1: Copy the asset

Terminal window
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
Terminal window
godot --headless --path . --import
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"

Expected: empty grep.

  • Step 7: Full suite + count guard
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
bash scripts/check-test-count.sh

Expected: 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
Terminal window
git add audio/scatter_explosion.wav sim/weapon_scatter.gd audio/audio_manager.gd fx/fx_manager.gd tests/test_sound_phase2_weapons.gd
git commit -m "feat(audio): Scatter fire gets a sound + muzzle spark (Sound Phase 2)"

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.gd checks sim.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

Terminal window
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.wav
godot --headless --path . --import

The --import run generates audio/orbit_ambience.wav.import with default (non-looping) params. Edit it to enable a whole-file forward loop:

Terminal window
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)
PYEOF
godot --headless --path . --import

edit/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 AudioManager
extends 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 := 4
const 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 -> AudioStream
var _pool: Array[AudioStreamPlayer] = [] # polyphony pool
var _pool_cursor: int = 0 # next-to-use slot (round-robin)
var _played_this_frame: int = 0 # reset each consume() call
var _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
Terminal window
godot --headless --path . --import
godot --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
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
bash scripts/check-test-count.sh

Expected: 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
Terminal window
git add audio/orbit_ambience.wav audio/orbit_ambience.wav.import audio/audio_manager.gd main.gd tests/test_sound_phase2_weapons.gd
git 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
Terminal window
godot --headless --path . --import
godot --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 -gexit
bash scripts/check-test-count.sh

Expected: 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
Terminal window
git add audio/SOUND_MAP.md
git commit -m "docs(audio): mark all 6 weapons DONE in SOUND_MAP (Sound Phase 2 complete)"

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 via SOUND_FOR_FX → Task 1. ✓
  • §3 architecture: new turret_fire/scatter_fire kinds, 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.gd per-frame toggle, baked WAV loop points) → Task 4. ✓
  • §3 known accepted beam kind 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.gd follows the existing test_audio_manager.gd idioms (same _am()/add_child_autofree pattern). ✓
  • §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.