Sounds
All audio files live in assets/sounds/, organized into categorized subdirectories.
They are mono 22050 Hz .qoa files synthesized by tools/gen_sounds.py
(see Generated Sounds); run make sounds to
regenerate them. QOA (“Quite OK Audio”) is a lossy format raylib reads natively:
it stores 16-bit samples in 3.2 bits each, a fifth of a WAV’s size, and decodes
with a few integer operations per sample. tools/qoa.py writes it byte for byte
as raylib’s own encoder (qoa.h) does. Regeneration skips files that still
match, and make docs-drift runs tools/gen_sounds.py --check; see
Generated Sounds for what “match” allows.
raylib handles decoded Sound samples and streamed Music;
the project-owned SoundEffect and MusicTrack types manage their lifetimes.
WAV files still load, so a level may name a .wav sound of its own.
Sound Reference
The tables include runtime sounds and bundled theme tracks. A bundled track may be available for level authors without being selected by a current campaign or lab.
Player — assets/sounds/player/
| File | Type | GameState Field | Description |
|---|---|---|---|
player_jump.qoa |
SoundEffect |
gs->assets.audio.jump |
Rising square-wave blip; played when a jump starts, including buffered/coyote jumps and climb dismounts |
player_hit.qoa |
SoundEffect |
gs->assets.audio.hit |
Noisy descending thud; played when the player takes damage |
Collectibles — assets/sounds/collectibles/
| File | Type | GameState Field | Description |
|---|---|---|---|
coin.qoa |
SoundEffect |
gs->assets.audio.coin |
Two-tone chime (B5 then E6); played when the player collects a coin |
Entities — assets/sounds/entities/
| File | Type | GameState Field | Description |
|---|---|---|---|
bird.qoa |
SoundEffect |
gs->assets.audio.flap |
Two soft band-passed noise puffs; played for each bird wing flap |
spider.qoa |
SoundEffect |
gs->assets.audio.spider_attack |
Hiss, rising zip and leg clicks; played when a jumping spider leaps at a gap |
fish.qoa |
SoundEffect |
gs->assets.audio.dive |
Filtered noise splash plus rising bubbles; played when the player falls into a water gap |
Hazards — assets/sounds/hazards/
| File | Type | GameState Field | Description |
|---|---|---|---|
axe_trap.qoa |
SoundEffect |
gs->assets.audio.axe |
Filter-swept noise whoosh; played for axe trap swing |
Surfaces — assets/sounds/surfaces/
| File | Type | GameState Field | Description |
|---|---|---|---|
bouncepad.qoa |
SoundEffect |
gs->assets.audio.spring |
Sine “boing” with fading vibrato; played when the player lands on a bouncepad |
Screens — assets/sounds/screens/
| File | Type | GameState Field | Description |
|---|---|---|---|
confirm_ui.qoa |
SoundEffect |
menu->snd_confirm |
Two quick square blips (E5, B5); played on menu confirmation. Held from the session’s asset cache, so only the first menu of a session reads it |
Levels — assets/sounds/levels/
| File | Type | GameState Field | Description |
|---|---|---|---|
water.qoa |
MusicTrack |
gs->world.music |
6 s seamless loop: filtered-noise wash and surf with slow swells and bubbles; water-themed levels |
lava.qoa |
MusicTrack |
gs->world.music |
6 s seamless loop: low brown-noise rumble, sizzle, slow bubbles and crackles; lava-themed levels |
winds.qoa |
MusicTrack |
gs->world.music |
8 s seamless loop: band-limited noise with drifting band and gusts; available, no current level selects it |
No Reserve Sounds
There is no assets/sounds/unused/ directory: every sound is generated and
loaded. tools/gen_sounds.py --check fails if a QOA or WAV file appears that the
generator does not produce, so unlicensed media cannot slip back into releases.
Audio Configuration
AppSession opens raylib’s audio device through audio_open:
InitAudioDevice();
if (!IsAudioDeviceReady()) { /* fail startup and clean up */ }
raylib/miniaudio negotiates the device format and sample rate. Game device-open failure is fatal; missing optional sound files warn and leave empty slots. The editor does not open an audio device. CI can use a virtual sink; the explicit Memory test build uses miniaudio’s null backend and does not prove audible output.
Browsers keep Web Audio suspended until the player interacts with the page.
raylib’s miniaudio resumes it on a click or tap only, so web/audio-unlock.js
(linked into both Web pages with --pre-js) also resumes it on the first key
or mouse press, for players who start from the keyboard. Escape does not count
as an interaction, so it cannot start sound.
Sound Effects vs. Music
| Aspect | SoundEffect (effects) |
MusicTrack (background music) |
|---|---|---|
| Project API | sound_load, sound_play |
asset_cache_music_acquire (calls music_load), music_play, music_update |
| Loading | Fully decoded into RAM | Decoded on demand from the backing file/resource |
| Best for | Short, triggered sounds | Long looping tracks |
| Simultaneous | One shared pool of eight independent raylib sound aliases | One active stream |
| Volume | Sample/user volume multiplied by each voice’s distance volume | Authored level volume multiplied by user volume/mute |
Sound effects use raylib LoadSound and are fully decoded into memory. Music is selected
by each level’s music_path and loaded through LoadMusicStream. Native decoding can
read incrementally from disk; WebAssembly preloads these files into its
virtual filesystem, so streaming playback does not remove their download or
backing-storage cost (QOA keeps it small: all twelve sounds take about 206 KiB,
down from 1 MiB as WAV). See Asset Inventory.
Adding a New Sound Effect
- Add a recipe and a
SOUNDSrow totools/gen_sounds.py, then runmake soundsto writeassets/sounds/<category>/<name>.qoa. - Add a
SoundEffect *<name>field toAudioResourcesingame.h. - Add a row to the
s_optional_chunkstable insrc/core/game_resources.c:
{ CHUNK_FIELD(<name>), "assets/sounds/<category>/<name>.qoa", "<name>.qoa" },
game_resources_load loads every row through sound_load, printing a
warning and leaving the slot NULL when a file is missing (non-fatal).
AppSession calls it once, when the first game opens, and lends the
loaded sounds to every game after that, so Replay does not load them
again. Only a sound (or optional sprite) whose slot is still NULL is
tried again when the next game opens (game_resources_reload_missing),
so a file put back during the session is picked up by the next Play. A
level that needs a missing sprite does not open, and the menu names the
file (Missing file: spike.png). game_resources_unload walks the same table in reverse with
FREE_CHUNK when the session ends, so no separate free call is needed.
- Play it wherever the event occurs:
sound_play(gs->assets.audio.<name>, 128);
sound_play accepts an empty optional slot. Volume remains in the existing
0–128 authored units and is normalized at the raylib boundary. Voices have
independent playback/volume; aliases are stopped and unloaded before their sample.
If all eight voices are busy, a new effect is dropped rather than interrupting
one already playing. sound_set_volume updates a sample’s multiplier and its
live aliases; the per-play multiplier remains independent.
A sound with a source somewhere in the level plays with sound_play_at(sound, volume, pan) instead. The axe trap’s whoosh, the bird’s (and faster bird’s)
wing flap and the jumping spider’s attack fade with the player’s distance
(sound_volume_for_distance) and pan toward the side of the screen their
source is drawn on: sound_pan_for_screen_x(source_x, cam_x, GAME_W) maps the
screen’s left edge to -SOUND_PAN_MAX and its right edge to SOUND_PAN_MAX
(0.75), so an edge sound still reaches the other ear a little. raylib 6 pans
from -1 (left) through 0 (centre) to 1 (right); sound_play is
sound_play_at at 0. The pan only reads the camera and the source, so it
never changes a run.
sound_play_ex(sound, volume, pan, pitch) also sets the voice’s pitch (1 as
recorded, 2 an octave up). The coin chime uses it: each quick pickup in a
streak plays a semitone higher (see Coin).
The pinned dependency includes three documented audio fixes: alias converter
cache release, WAV decoder release, and miniaudio’s zero-allocation passthrough
initialization. They are applied on every platform by tools/build_raylib.py;
see vendor/raylib/README.md and the Build System.
Adding a New Music Track
Ambient tracks are short loops: the generator crossfades each recording’s tail
into its head so MusicTrack can repeat it without a click. raylib’s QOA stream
(qoaplay) loops at the exact sample: after the last sample it rewinds and
decodes the first frame again, with the encoder’s starting predictor state.
tests/gen_sounds_test.py checks that seam on the decoded samples, which differ
slightly from the generator’s because QOA is lossy.
// Acquire from the session's asset cache (streaming, not fully decoded
// into RAM). The file is opened the first time; a Replay gets the same
// track back.
gs->world.music = asset_cache_music_acquire(gs->cache, "assets/sounds/levels/new_track.qoa");
if (!gs->world.music) { /* handle error */ }
// Start (loop forever)
music_play(gs->world.music);
// Volume (0-128)
music_set_volume(64); // 50%
// AppSession pumps this once per frame, including overlay frames
music_update();
// Stop and let go. The cache stops the track and frees it later, once
// no level uses it (never call music_unload on an acquired track).
asset_cache_music_release(gs->cache, gs->world.music);
gs->world.music = NULL;
The normal session plays authored music_volume scaled by the player’s saved
volume/mute preferences (game_music_volume), from the moment a level starts its
music. Prefer that path to a hard-coded global music volume.
Media licenses are documented separately in Asset Provenance.