What changed, and why
The game played in silence. Mechanically it is a form — type a message, click send, watch numbers move — yet the mechanics are dramatic: a D20 deciding fate, a timeline rewriting itself, a last-stand wager. This change gives those beats a voice: short, tactile cues (a wooden die's thunk, a wax-seal stamp as a change lands, a quill flourish, a heartbeat under the stake) that punctuate the meaningful moments and stay quiet the rest of the time.
The feature is four small layers plus the UI that mounts them. Each has one job, and they depend in one direction: the engine reads the preference, the wiring drives the engine, the page mounts the wiring. The store underneath knows nothing about sound.
| Layer | File | Role |
|---|---|---|
| Preference | stores/audio.ts | muted / volume, persisted like the dark toggle |
| Synthesis | utils/sfx.ts | every cue, generated from oscillators + noise at play time |
| Engine | composables/useSoundscape.ts | one AudioContext, a volume bus, the gesture unlock |
| Wiring | composables/useGameSoundscape.ts | watches store transitions, fires the matching cue |
| UI | pages/play.vue · components/FigurePicker.vue | the header mute control + the gesture-driven cues |
The preference: a mute that persists
The only thing the soundscape remembers is whether it is muted and how loud. That lives in a tiny Pinia store, modeled exactly on the existing coaching store: a single localStorage key, read once on the client via hydrate(), with the absence of localStorage serving as the SSR guard.
The one judgment call is the default. With nothing saved, the game boots on at a gentle level — booting silent would undersell the feature — **except under prefers-reduced-motion, where it boots muted.** A player who has asked for less motion has signalled a low-sensory preference; starting silent (and trivially un-muted) is the conservative reading. Their first touch of the control overrides it for good.
The reduced-motion probe, the effectiveVolume getter (mute = 0), and the idempotent hydrate().
stores/audio.ts · 123 lines
⋯ 58 lines hidden (lines 1–58)
⋯ 14 lines hidden (lines 68–81)
⋯ 19 lines hidden (lines 105–123)
The cues: synthesized, not sampled
Every cue is a small function that builds a node graph on the audio context and schedules it. Two primitives carry almost all of them — tone (one oscillator with an attack/decay envelope and an optional pitch glide) and noise (a filtered white-noise burst). A wooden thunk is a triangle wave dropping fast in pitch plus a noise click; a wax stamp is a low press-thud, a lowpassed squish, and a tiny lift; a whoosh is band-passed noise sweeping down.
The cue set is a typed registry keyed by CueName, so the wiring can only ask for a cue that exists. The one stateful piece is the dice rattle: a looping, tremolo'd noise bed with an explicit stop(), since a one-shot can't model "rattle until the die lands."
The tone primitive; the registry opening (dispatch, the land); the signature wax-seal stamp; the rattle loop.
utils/sfx.ts · 329 lines
⋯ 65 lines hidden (lines 1–65)
⋯ 51 lines hidden (lines 98–148)
⋯ 56 lines hidden (lines 165–220)
⋯ 52 lines hidden (lines 228–279)
⋯ 24 lines hidden (lines 306–329)
The engine: one context, unlocked on a gesture
The engine owns the lifecycle the cues don't: a single AudioContext, a master gain the volume rides on, and the autoplay unlock. The context is created lazily — never at mount — because browsers refuse to start audio until a real user gesture. A one-time window listener (capture phase) creates and resumes the context on the first pointer or key event, then retires.
Everything is guarded for the places audio can't run. No window, no AudioContext, or a thrown constructor all resolve to the same outcome: the engine stays inert and silent instead of throwing. play() is a no-op when muted — it doesn't even create a context — so "muted" is genuinely silent, not just zero gain.
Lazy context + master bus; the first-gesture unlock listener; play() guarding mute and missing Web Audio.
composables/useSoundscape.ts · 185 lines
⋯ 32 lines hidden (lines 1–32)
⋯ 73 lines hidden (lines 53–125)
⋯ 25 lines hidden (lines 161–185)
The wiring: cues from state, never scattered play()
This is the bridge, and the part a reviewer should scrutinize. It watches the store's transitions and fires the matching cue. Because the store sequences its writes through the staged turn reveal, watching those writes gives the beats for free, already paced — the die lands, then the gauge swings, then the change seals onto the Spine.
The throw-to-land watch mirrors the dice component exactly: the rising loading edge is the dispatch and the rattle; the falling edge lands the die, but only for a genuinely new roll. A refunded send also flips loading off, and the index dedupe is what stops it from replaying the previous land.
The latest-roll read + the throw/land watch with its dedupe; the in-run guard on progress and momentum.
composables/useGameSoundscape.ts · 144 lines
⋯ 60 lines hidden (lines 1–60)
⋯ 2 lines hidden (lines 99–100)
⋯ 31 lines hidden (lines 114–144)
The UI: a header control and one mount
The page wires it together in one place. useGameSoundscape() is called once in setup — that single call registers every watcher and returns the engine handle for the few cues a UI action drives directly (the stake swell, the figure-pick tick). The preference is hydrated in the existing client onMounted, beside coaching's.
The control is a mute toggle in the header, next to the dark-mode button and built the same way. The click is itself a gesture, so un-muting unlocks the context right there and chirps a tick to confirm sound is live. The accessible name reflects state, aria-pressed tracks it, and the icon is aria-hidden.
The header mute button (decorative svg, named button); the one mount; toggleMute unlocking on its own gesture.
pages/play.vue · 612 lines
⋯ 35 lines hidden (lines 1–35)
⋯ 247 lines hidden (lines 57–303)
⋯ 161 lines hidden (lines 306–466)
⋯ 136 lines hidden (lines 477–612)
The figure-pick tick is the only other component touch: a single quiet cue at the chokepoint every deliberate selection already passes through.
One papery tick in choose() — the one place every figure selection lands.
components/FigurePicker.vue · 541 lines
⋯ 371 lines hidden (lines 1–371)
⋯ 164 lines hidden (lines 378–541)
Proving the triggers without sound
The trigger logic is the thing worth testing, and it is tested with audio mocked. A hand-rolled FakeAudioContext records the nodes a cue creates and how they connect — enough to assert that a cue scheduled a graph, that a muted play creates nothing at all, and that the master bus carries the chosen volume. It starts suspended, like a real browser context, so the unlock path is exercised too.
The AudioContext double: it tracks every node created and every connection.
tests/unit/support/fake-audio.ts · 139 lines
⋯ 91 lines hidden (lines 1–91)
The wiring is tested by running it in a bare effectScope with a real Pinia store and a spy engine — no DOM, no audio. Drive a store transition, flush a tick, assert the right cue fired. The two load-bearing cases: a new roll lands with its thunk, tiered sting, and grade note; and a reset never sounds like a loss.
Inject a spy engine into an effect scope; assert event → cue; assert a reset stays silent.