Game audio — Sound, voices, buses

Sound effects for games: a Sound is a clip decoded up front, and audio.play() mixes it at once — many at a time, with pitch, random variants and priorities — through buses with volume, echo and reverb. An app's sounds (a button tap, a chime, music) play through AudioPlayer, on every host; audio is the game engine's mixer — desktop and iOS only, a silent no-op on Android, the web and the web player.

  • play a sound effect in a game — a shot, a jump, a coin pickup (audio.play)
  • play a different footstep or impact sound each time (variants)
  • loop an engine or wind sound and change its pitch while it plays (Voice)
  • fade a sound out, or start something when a sound has finished (ended)
  • set separate volumes for music, effects and UI sounds (buses)
  • add reverb, echo or a muffled low-pass filter to sounds
  • keep important sounds from being cut off when many play at once (priority)
  • silence effects while the game is paused, lower their pitch in slow motion
  • fall back to AudioPlayer where game audio does not run
  • check in a headless test that a sound played (expect.audio)

Platforms: iOS (the full, 2d and core engine variants), desktop; not on Android, the web or in the web player — check audio.supported.

At a glance

TypeScript
const main = async () => {
  const click = await Sound.load(asset('./sfx/click.wav'))
  const steps = await Sound.load([asset('./sfx/step1.wav'), asset('./sfx/step2.wav'), asset('./sfx/step3.wav')])
  const hum = await Sound.load(asset('./sfx/hum.wav'))

  audio.play(click, { bus: 'ui' })                                       // fire and forget
  audio.play(steps, { volume: 0.6, pitch: 0.9 + Math.random() * 0.2 })  // a random variant per play

  const engine = audio.play(hum, { loop: true, fade: 0.5 })               // keep the Voice to steer it
  engine.pitch = 1.3
  engine.addEventListener('ended', () => console.log('engine off'))
  setTimeout(() => engine.stop({ fade: 1 }), 3000)

  audio.bus('sfx').reverb = 'cave'
  audio.bus('music').volume = 0.5
}
main()

Where game audio runs: audio.supported

audio.supported tells whether this host mixes game audio: true on the desktop host and on iOS once the engine has opened an audio device, false on Android, the web and the web player. Without it nothing throws: Sound.load resolves at once to a silent clip (ready === false) without fetching, play returns a Voice that is already over, every setter is a no-op. Where a sound must be heard on every host, fall back to AudioPlayer:

TypeScript
const main = async () => {
  const coinUrl = asset('./sfx/coin.wav')
  const coin = await Sound.load(coinUrl)      // a silent clip where audio.supported is false

  const playCoin = () => {                    // the game calls it on every pickup
    if (audio.supported) audio.play(coin)
    else new AudioPlayer(coinUrl).play()      // Android, the web: the platform's player
  }
  playCoin()
}
main()

An iOS app shell carries the smallest engine variant its bundle needs, and the 3d variant (picked for an app that uses 3D but no 2D) has no game audio: set "ios": { "variant": "full" } in app.json to keep it. The le.codes viewer app on iOS runs the full variant.

Note

the iOS host sets no audio-session category, so Apple's default applies until a plugin that records (audio-recorder, speech, webrtc) sets its own: the Ring/Silent switch mutes game audio, and the sound stops while the app is in the background.

Load sounds before they play: Sound.load

Load every effect once, before the level starts: Sound.load fetches the file and decodes the whole clip, so a later play starts at once and costs nothing.

TypeScript
Sound.load(src: string | string[], options?: { stereo?: boolean }): Promise<Sound>
sound.duration      // seconds (the first variant's); 0 for a silent clip
sound.channels      // 1, or 2 with { stereo: true }
sound.variants      // how many files it was loaded from
sound.urls          // the sources, as passed
sound.ready         // false without host audio, or after dispose()
sound.disposed      // true after dispose()
sound.dispose()     // free the decoded clip now; voices playing it stop at once. Idempotent

src is anything fetch takes — asset('./x.wav'), a remote URL. WAV, MP3 and OGG Vorbis; a file that does not decode rejects the promise (Sound.load: <url>: cannot decode (wav / mp3 / ogg vorbis)). Decoding runs off the main thread.

A clip is decoded to mono unless { stereo: true }: mono is what a 3D source spatializes, and what effects are anyway. Keep stereo for 2D beds and ambiences; stereo clips play on their own, smaller voice pool.

Note

a Sound holds its whole clip in memory as 32-bit float at the engine's rate — 192 KB per second of mono at 48 kHz, twice that in stereo (audio.stats.clipBytes). It lives for the app: dropping the reference frees nothing. dispose() what a finished level no longer needs. Music, voice-over and long tracks belong to AudioPlayer, the platform's media player — one file per player.

Random variants: footsteps, impacts

Load an array of files to play a different one each time: each play picks a variant at random, never the same twice in a row when there are three or more — footsteps, impacts, ricochets.

TypeScript
const main = async () => {
  const steps = await Sound.load([asset('./sfx/step1.wav'), asset('./sfx/step2.wav'), asset('./sfx/step3.wav')])

  const footstep = () =>                      // call it each time a foot lands
    audio.play(steps, { volume: 0.6, pitch: 0.9 + Math.random() * 0.2 })   // a variant, slightly detuned
  footstep()
}
main()

Play a sound effect in a game: audio.play

In a game, audio.play starts a loaded Sound at once — a shot, a jump, a coin — and returns its Voice. A button's click in an app is an AudioPlayer instead: audio is silent on Android and the web.

TypeScript
audio.play(sound: Sound, options?: {
  volume?: number     // linear gain; default 1 (above 1 amplifies)
  pitch?: number      // playback rate, 0.01 … 10: 2 = an octave up and twice as fast; default 1
  loop?: boolean      // default false
  bus?: string        // default 'sfx'
  priority?: number   // outranks lower numbers when the pool is full; default 0
  fade?: number       // fade-in, seconds; default 0
  startAt?: number    // seconds into the clip; default 0
  pan?: number        // -1 left … 1 right; default 0
}): Voice
audio.stopAll(fade?: number)   // every voice on every bus; fade in seconds, default 0
TypeScript
const main = async () => {
  const jump = await Sound.load(asset('./sfx/jump.wav'))   // load once, before the level starts

  Input.on('keydown', (e) => {
    if (e.code !== 'Space' || e.repeat) return
    audio.play(jump, { volume: 0.8 })                      // fire and forget: it plays to its end
  })
}
main()

audio.play is 2D: the same whatever the camera does, placed only by pan. For sound at a place in the world — emitters on nodes, reverb zones, the listener — use 3D audio (AudioSource, audio.playAt), on the same engine.

Too many sounds at once: the voice pool and priority

The engine mixes at most 80 mono and 20 stereo voices at once and allocates nothing per play. When the pool is full, a new sound takes the slot of a playing voice of lower or equal priority: the lowest priority first, then one-shots before loops, then the quietest, then the oldest. The displaced voice ends (ended fires, audio.stats.stolen counts it). If every playing voice has a higher priority, the new sound is refused: play returns a voice that is already over.

Give music beds, engine loops and dialogue a higher priority than bullets and footsteps:

TypeScript
const main = async () => {
  const hum = await Sound.load(asset('./sfx/engine.wav'))
  const shot = await Sound.load(asset('./sfx/shot.wav'))

  const engine = audio.play(hum, { loop: true, priority: 5 })   // outranks every shot
  for (let i = 0; i < 100; i++) audio.play(shot)                 // a burst: more than 80 mono voices
  console.log('engine', engine.playing, 'cut off', audio.stats.stolen)   // engine true, cut off 21
}
main()

Loop a sound and change it while it plays: Voice

Keep the Voice that audio.play returns to change a sound while it plays — an engine whose pitch follows the speed, wind that swells — and to stop it.

TypeScript
voice.playing        // true while it plays; false once it ended, was stopped or was stolen
voice.time           // seconds into the clip
voice.volume = 0.5   // live, same units as the options
voice.pitch = 1.2
voice.pan = -1       // 2D voices only
voice.stop(options?: { fade?: number })     // seconds; default 0 (at once)
voice.addEventListener('ended', () => { })  // once: the clip ended, stop(), or the pool took the slot
TypeScript
const main = async () => {
  const hum = await Sound.load(asset('./sfx/engine.wav'))

  class EngineSound extends System<'engineSound'> {
    speed = 0                                                    // m/s: the car's code sets it
    private voice: Voice
    onAttach() { this.voice = audio.play(hum, { loop: true, fade: 0.5 }) }   // keep the Voice
    update() { this.voice.pitch = 0.8 + Math.min(this.speed / 30, 1) }      // 0.8 idle … 1.8 at 30 m/s
    onDetach() { this.voice.stop({ fade: 0.3 }) }
  }

  const scene = new Scene().system(EngineSound)
  scene.open()
  scene.engineSound.speed = 15                                  // pitch 1.3 from the next frame
}
main()

A Voice is a ticket into the engine's pool, not a resource: dropping it frees nothing, and a one-shot nobody keeps plays to its end. Once a voice is over, playing reads false and its setters do nothing — its slot may already play another sound.

Fade out, or act when a sound ends: stop, ended

voice.stop({ fade }) ends a loop smoothly; the ended event says a voice is over — chain what comes next on it, like the level that starts when the countdown has played.

TypeScript
const main = async () => {
  const countdown = await Sound.load(asset('./sfx/countdown.wav'))
  const wind = await Sound.load(asset('./sfx/wind.wav'), { stereo: true })

  const intro = audio.play(countdown, { bus: 'ui' })
  intro.addEventListener('ended', () => {                       // add it when you play
    console.log('GO')                                           // start the level here
    const ambience = audio.play(wind, { loop: true, fade: 2 })  // fades in over 2 s
    setTimeout(() => ambience.stop({ fade: 3 }), 30000)         // fades out over 3 s
  })
}
main()
Note

ended fires on the frame after the voice is over — after stop() at once, without waiting for the fade. Add the listener when you play: one added after the voice has ended never fires. A voice that never played (no host audio, a disposed Sound, a refused play) is over from the start, and a listener added to it fires immediately — so a chain like this one still runs where audio.supported is false.

Separate volumes for music, effects and UI: audio.bus

Every voice plays through a mixer bus, so one setting turns a whole group of sounds up, down or off — the effects, the music, the UI clicks — the way a game's sound settings do.

TypeScript
audio.bus(name: string): Bus   // 'master' | 'sfx' | 'music' | 'ui' | 'voice', or your own
audio.master                   // audio.bus('master')

bus.name
bus.volume = 0.7               // linear gain; default 1
bus.muted = true               // default false
bus.lowpass = 1200             // cutoff in Hz, 20 … 20000; null = off (default)
bus.echo = { delay: 0.35, decay: 0.4, mix: 0.3 }   // null = off (default)
bus.reverb = 'hall'            // a preset, params, or null = off (default)
bus.stopAll(fade?: number)     // the voices playing on this bus; fade in seconds
TypeScript
const main = async () => {
  const shot = await Sound.load(asset('./sfx/shot.wav'))
  const settings = { music: 0.4, effects: 0.8, interface: false }   // what the settings screen saved

  audio.bus('music').volume = settings.music
  audio.bus('sfx').volume = settings.effects
  audio.bus('ui').muted = !settings.interface

  audio.play(shot)                            // on 'sfx', the default bus: at 0.8
}
main()

A voice plays on sfx unless the play (or its source) names another bus; every other bus feeds master. The five buses above exist from the start; any other name creates a bus on first use — up to 11 of your own (16 in all). Past that audio.bus() warns and returns a bus that does nothing, and voices sent to it play on sfx. A Bus is only ever obtained from audio.bus(); reading a setting returns what was set.

Note

a misspelled bus name is not an error — it quietly creates a new bus. And stopAll() on a bus stops the voices played on it, not those of the buses feeding it: audio.master.stopAll() is not audio.stopAll().

Note

an AudioPlayer is not in this mixer, except on the desktop host, where it plays through the music bus — audio.bus('music') reaches it there only. Elsewhere set the player's own volume.

Reverb, echo and muffling: bus effects

A bus can color everything played on it — reverb for a cave or a hall, an echo in a canyon, a low-pass that muffles sound under water. The insert chain runs low-pass → echo → reverb; changes are smoothed, so switching reverb at a doorway does not click.

  • lowpass — the cutoff in Hz, 20 … 20000: the lower, the more muffled.
  • echo — delay seconds between repeats (up to 2), decay the feedback 0 … 0.95 (how many repeats survive), mix the wet amount 0 … 1. Missing fields: 0.3 / 0.4 / 0.3.
  • reverb — a preset name or { roomSize, damping, width, mix, preDelay }: roomSize 0 (a closet) … 1 (a cathedral), damping high-frequency loss 0 … 1, width stereo width of the tail 0 … 1, mix the wet amount 0 … 1, preDelay seconds before the tail (up to 0.25). Missing fields are taken from room.

REVERB_PRESETS holds the six presets as params:

preset roomSize damping width mix preDelay (s)
room 0.45 0.6 0.8 0.18 0.005
bathroom 0.55 0.15 0.6 0.35 0.002
hall 0.82 0.4 1.0 0.3 0.025
cave 0.9 0.25 1.0 0.45 0.04
arena 0.95 0.5 1.0 0.3 0.06
outdoor 0.3 0.8 1.0 0.06 0
TypeScript
const main = async () => {
  const shot = await Sound.load(asset('./sfx/shot.wav'))
  const sfx = audio.bus('sfx')

  sfx.reverb = 'cave'                                  // the player walks into the cave
  sfx.echo = { delay: 0.35, decay: 0.4, mix: 0.25 }
  audio.play(shot)                                     // heard with the cave's tail and echo

  setTimeout(() => {                                   // 5 s later, under water
    sfx.reverb = { ...REVERB_PRESETS.cave, mix: 0.2 }  // a preset, drier
    sfx.echo = null                                    // off
    sfx.lowpass = 800                                  // Hz: muffled
  }, 5000)
}
main()

An AudioZone swaps a bus's reverb while the listener is inside it.

Pause and slow motion: Time.paused, Time.scale

Pausing the game silences its effects: Time.paused (or Time.scale = 0) mutes the sfx bus without freezing it — its voices run on unheard, and a one-shot can end during the pause. Every other bus keeps playing, so a pause menu keeps its ui clicks and its music. With audio.timeScalePitch = true, a Time.scale other than 1 also pitches the sfx bus (slow motion lowers every effect's tone; 0.1 … 4×); default false.

TypeScript
const main = async () => {
  const hum = await Sound.load(asset('./sfx/engine.wav'))
  const scene = new Scene()                   // the game clock reaches the mixer through the 3D engine
  scene.open()

  audio.timeScalePitch = true                 // slow motion also lowers the effects' tone
  audio.play(hum, { loop: true })             // on 'sfx'
  Input.on('keydown', (e) => {
    if (e.code === 'Escape') Time.paused = !Time.paused              // 'sfx' silent, 'ui' and 'music' play on
    if (e.code === 'KeyT') Time.scale = Time.scale === 1 ? 0.3 : 1   // bullet time: slower and lower
  })
}
main()
Note

the game clock reaches the audio engine through the 3D engine — this applies once a Scene exists. A 2D-only game (Scene2D) mutes nothing on pause: set audio.bus('sfx').muted = Time.paused yourself wherever the game pauses or resumes.

Voices and memory in use: audio.stats

audio.stats reads the engine's counters, for a debug overlay or a perf log: the voices playing, the voices cut off by a full pool, the clips in memory.

TypeScript
audio.stats: {
  voicesPlaying: number   // live voices; a voice fading out after stop() no longer counts
  voicesMono: number
  voicesStereo: number
  stolen: number          // voices displaced by a full pool since the engine started
  clips: number           // decoded clips in memory
  clipBytes: number       // their size in bytes
  peak: number            // the master output's peak over the last second or two, linear (1 = full scale)
  sampleRate: number      // the engine's mix rate, Hz
  listenerZone: number    // the reverb zone the listener is in (0 = none) — see 3D audio
  zoneBlend: number       // how far inside it, 0 … 1
  hrtfVoices: number      // voices rendered binaurally right now — see 3D audio
}

Each read asks the engine for a fresh object. All zeros without host audio.

Check in a test that a sound played: expect.audio

A headless flow (lecodes render / lecodes test without --sim or --desktop) records game audio instead of mixing it: audio.supported is true, a Sound loads at once with duration 0, and a one-shot never ends there (only stop() and audio.stopAll() end a voice), so its ended does not fire. A step asserts on it with expect.audio: played passes once any play so far used a clip whose source contains the text, playing is the number of voices not yet over. For a game that plays asset('./sfx/jump.wav') on Space:

JSON
{ "steps": [
  { "frames": 10, "expect": { "audio": { "playing": 0 } } },
  { "key": "Space", "ms": 50, "expect": { "audio": { "played": "jump" } } }
] }

expect.audio is checked headless only. The windowless server (--sim) runs the real engine without an audio device: loads, the pool and stats are real, but voices never advance, so a one-shot never ends there — and decoding takes wall-clock time while the frames run unthrottled, so give the loads a long frames step.

See also

  • Media — AudioPlayer for an app's sounds, music and long tracks
  • 3D audio — AudioSource, AudioZone, scene.audio, audio.playAt, headphones
  • Time — Time.paused / Time.scale
  • Systems — where a game's per-frame sound logic lives