Super Mango manualPlay

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

  1. Add a recipe and a SOUNDS row to tools/gen_sounds.py, then run make sounds to write assets/sounds/<category>/<name>.qoa.
  2. Add a SoundEffect *<name> field to AudioResources in game.h.
  3. Add a row to the s_optional_chunks table in src/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.

  1. 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.