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
AudioPlayerwhere 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
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:
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.
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.
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. Idempotentsrc 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.
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.
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.
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 0const 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:
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.
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 slotconst 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.
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()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.
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 secondsconst 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.
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().
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—delayseconds between repeats (up to 2),decaythe feedback 0 … 0.95 (how many repeats survive),mixthe wet amount 0 … 1. Missing fields: 0.3 / 0.4 / 0.3.reverb— a preset name or{ roomSize, damping, width, mix, preDelay }:roomSize0 (a closet) … 1 (a cathedral),dampinghigh-frequency loss 0 … 1,widthstereo width of the tail 0 … 1,mixthe wet amount 0 … 1,preDelayseconds before the tail (up to 0.25). Missing fields are taken fromroom.
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 |
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.
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()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.
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:
{ "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.