Sound and the radio
Every sound the game plays is a sound event of the sound bank, the way FMOD and Wwise work: the code asks for weapon.pistol.shot and never names a file, and the bank says how each event is mixed. A file is found by the event's name, so replacing a placeholder is dropping a file in a folder. The radio is a feature of its own on top: stations made of folders of audio files, of addresses or of a live stream, playing all the time on their own clock, in the car and, through earbuds, on foot.
Swapping a sound
- Open the sound board: the main menu's Galleries → Sounds and the radio, or
?sound-board(npm run dev:web, thenhttp://localhost:5173/?sound-board). It lists every sound, what it plays now (its placeholder, or its file) and where a file goes. - Drop the file there: the event's dots are folders under
apps/client/audio/sfx/, soweapon.pistol.shotisapps/client/audio/sfx/weapon/pistol/shot.ogg. Any ofogg,opus,webm,mp3,m4a,aac,wav,flac(what browsers decode); Ogg or WebM Opus for effects, WAV for a loop that must be seamless (an MP3 adds a gap at its ends). - For variations, number them:
shot.1.ogg,shot.2.ogg, … play at random, never the same one twice running. A plainshot.oggand numbered ones together is an error. - The dev server reloads; the board shows the file in green and the game plays it. A build ships it (Vite copies it into the build under a name of its content).
Nothing else changes: no code, no manifest. A misspelt file (sfx/weapon/pistols/shot.ogg) would play nothing, so the bank's unit test (apps/client/src/audio/bank.test.ts, in npm test) fails on any file that matches no sound, and the board lists it in red.
The bank
apps/client/audio/sounds.json declares every event and how it is mixed:
"weapon.pistol.shot": { "bus": "sfx", "volume": 0.7, "pitch": [0.95, 1.05], "voices": 8, "range": 90, "placeholder": "synth:pistol" }| Field | Means | Default |
|---|---|---|
bus | The mixer's bus: sfx, vehicle, world and ui follow the Effects volume, music the Music one | (required) |
volume | A factor on its loudness, 0 to 2 | 1 |
pitch | [lowest, highest]: its rate picked in that range each time it plays, for variety | none |
loop | It loops (the engine, the rain) rather than plays once | false |
voices | How many of it at once; the oldest stops | 8, a loop 1 |
range | How far it carries (m): it fades to nothing there and pans with where it is | none (no fade) |
placeholder | What plays while it has no file: synth:<name>, a sound synthesized in code | (required) |
The event ids are TypeScript's too (SoundId, from the JSON): a misspelt id in the code fails tsc. A new sound is a line in sounds.json (a placeholder from audio/placeholders.ts, or a new generator in renderer/audio-synth.ts) and a play('its.id') where it happens. The sounds the game plays today:
| Sounds | When |
|---|---|
vehicle.engine, vehicle.tyre.scrub, vehicle.siren, vehicle.ice-cream.jingle (loops) | at the wheel, drifting, a siren heard, an ice-cream van near |
vehicle.knock | a light piece the player's car knocks loose, on the frame the prediction knocks it |
vehicle.horn, vehicle.carjack | G; a carjack heard |
weapon.fists.swing, weapon.pistol.shot, weapon.shotgun.shot, weapon.rifle.shot, weapon.rocket.launch, weapon.grenade.throw, weapon.tranquilizer.shot, weapon.hit | a shot (the player's at the trigger, everyone else's where they are), a hit (a crash of the player's car, and of whoever it runs down, on the frame the prediction feels it; the server's word on the same blow heard no more) |
world.explosion, world.thunder, world.fire (loop), weather.rain, weather.wind (loops) | a blast, the storm, a burning car, the weather |
animal.dog.bark | a dog barking |
music.theme (loop) | M, quiet while the radio plays |
radio.tune, ui.menu.move, ui.menu.select, ui.menu.back, ui.menu.start, ui.pause | tuning the radio; the menus' cues, the pause |
Footsteps, doors and the phone's tones have no event yet: a line each in sounds.json when they get one.
The code: apps/client/src/audio/ (its README): bank.ts (the events, the file found by name, what is wrong), sound-player.ts (Howler: variations, pitch, voices, buses, loops, held while the game is paused), placeholders.ts; renderer/audio.ts feeds the game's sounds every frame.
The radio
As in GTA: getting into a car turns its radio on (the settings' Radio on when getting in a car), on whatever that car played last, or, for a car never sat in, a station of its own (a stolen car has something on already; the first car of a session plays the station picked last time). Getting out silences it, unless Keep listening on foot (earbuds) is on: then it plays on, the keys tune it on foot, and a car got into takes the station. A two-wheeler or a bicycle has no radio (as on foot). It is the player's alone: passengers each hear their own, and nothing of it goes to the server.
Every station plays all the time, whether anyone listens: since the first of January 2026 each has played its items one after the other, round and round, each round in its order (a shuffle of the round's own, the same for every player). Tuning in lands in the middle of whatever is on, and a station tuned away from and back to has played on meanwhile (audio/radio-timeline.ts). An item that will not play is skipped; "No signal" when none will.
| Control | Does |
|---|---|
B (held) | The radio wheel: the stations round a circle, Off last; point the mouse at one and let go to tune it (. and , move along it too); no shot while it is out |
. · , | The next station · the one before, Off between the last and the first |
T → Radio | The phone's Radio app: the stations as numbered buttons and Off; a player with no phone of their own (free roam) takes out a phone with only the radio on it |
The keys are rebindable (Controls). Each tuning shows the banner under the clock: the station in its colour and what it plays. The settings (Sound): the radio's volume, Radio on when getting in a car, Keep listening on foot (earbuds). The music of M is quiet while the radio plays.
Adding a station
A station is a folder under apps/client/audio/radio/, named with a number (the order) and an id: 06-late-night-jazz/. In it, a station.json:
{
"name": "Late Night Jazz",
"colour": "#c9a0ff",
"tagline": "Smooth, like a refund.",
"shuffle": true,
"titles": { "02-blue-bridge.mp3": "Blue Bridge (live)" }
}and what it plays, one of:
- Its folder's audio files, in the order of their names (or shuffled with
"shuffle": true), each titled from its name (03-the-bridge-has-feelings.mp3: "The bridge has feelings") unlesstitlesnames it. "urls": a list of addresses ("https://example.com/ep1.mp3", or{ "url": "…", "title": "…" }), played in order or shuffled like files; a relative address is the page's (radio-samples/…is served fromapps/client/public/). Podcast episodes, files on a server."stream": one live web radio's address ("https://radio.example/live.mp3"): no titles, no clock, it plays as it comes.
An optional logo.png (or .jpg, .webp, .svg) in the folder is drawn on the wheel. Every address plays in an <audio> element, which needs no CORS; an http:// stream on a page served over https:// is blocked by the browser (mixed content). The stations' unit test (audio/radio-stations.test.ts) and the sound board say what is wrong with a station; a station with a fault is left out (and said in the console).
The sample stations
Five stations of five placeholder items each, in the game's satire: Riverbend FM (pop), Neon Drive 88.8 (synthwave), Smart City Talk (news satire), Zenful Wellness Radio (wellness talk) and Riverbend Podcasts (a station of urls). Each item says it is a placeholder in its first seconds. npm run audio:placeholders made them (apps/tools/src/audio-placeholders.ts: synthesized music, the operating system's text-to-speech for the voices, MP3 through Blender's FFmpeg); it writes only the files that are missing (--force all of them), so real files put in their place stay.