Skip to main content

Audio

The engine's audio (src/engine/audio/) is a mixer on react-native-audio-api (the Web Audio API on React Native). Use one mixer per game screen for every sound: effects placed in the world (louder when near, panned to their side), random pitch so repeats do not sound copied, a few voices per sound, music that ducks under loud effects and crossfades between tracks, and volume settings saved on the device.

For a flat game with a handful of short sounds, the 2D layer's useOneShots (on expo-audio) is lighter (The 2D layer).

Smallest working example​

Music on its bus, a positional pop, and a thud that ducks the music, as the Lantern Tag example does:

import React, { useEffect } from 'react';
import { View } from 'react-native';
import { AudioListener, EngineCanvas, useAudioMixer, type GameScreenProps } from '@/engine';

const SOUNDS = {
music: require('../../assets/audio/music-synthwave.m4a'),
lantern: require('../../assets/sfx/pop.wav'),
tagged: require('../../assets/audio/bump.m4a'),
};

export const Screen = (_props: GameScreenProps) => {
const mixer = useAudioMixer(SOUNDS);

useEffect(() => {
mixer?.playMusic('music', { volume: 0.55, fade: 2 });
}, [mixer]);

const onLantern = (x: number, z: number) => mixer?.play('lantern', { at: { x, y: 1, z } });
const onTagged = () => mixer?.play('tagged', { duck: true, pitchJitter: 0.08 });

return (
<View style={{ flex: 1 }}>
<EngineCanvas>
<AudioListener mixer={mixer} />
{/* the scene; call onLantern / onTagged from gameplay */}
</EngineCanvas>
</View>
);
};

useAudioMixer returns null while the sounds decode (and if audio cannot start), so call it with mixer?.. It closes the mixer when the screen unmounts, so nothing keeps playing behind the games list. <AudioListener> inside the canvas makes the camera the listener every frame.

Define the sounds object outside the component (or memoise it). The mixer is recreated when the set of names and assets changes.

useAudioMixer(sounds)​

ArgumentTypeWhat it is
soundsRecord<string, number>Names to require()d audio files (.wav, .m4a)

Returns AudioMixer | null. In development it adds an audio line to the debug overlay (sounds loaded, voices playing, music track, ducked, muted).

AudioMixer​

MethodWhat it does
play(name, options?)Play a sound. Returns a Voice, or null when the sound is not loaded or the mixer is closed
playMusic(name, { fade?, volume?, loop? })Start a track on the music bus, crossfading from the current one. fade 1.2 s, volume 1, loop true by default. Playing the same track again does nothing
stopMusic(fade = 0.8)Fade the music out and stop it
setListener({ x, y, z, yaw })Where the ears are; moves every positional sound. <AudioListener> calls it for you
getSettings()The current AudioSettings
setSettings(change)Change and save settings: { muted: true }, { music: 0.4 }
load(sounds)Decode more sounds later (a level's music). Files that fail are skipped with a warning
has(name)Whether a sound is decoded
duration(name)Seconds, or 0 when not loaded
dispose()Stop everything and close the audio context. useAudioMixer calls it on unmount

Read-only state for telemetry: voiceCount, loadedCount, musicTrack, ducked, played.

AudioMixer.create(sounds) is the async factory behind the hook, for code outside React.

PlayOptions​

OptionTypeDefaultWhat it does
bus'music', 'sfx', 'ui''sfx'Which volume it obeys
volumenumber10 to 1; more boosts
at{ x, y?, z }nonePlace the sound in the world: louder near the listener, panned to its side
falloff{ near?, far?, rolloff? }near 2, far 40, rolloff 1Full volume inside near metres, silent beyond far
pitchnumber1Playback rate (2 is an octave up)
pitchJitternumber0.04 for effects, 0 for musicRandom pitch, a fraction (0.06 is plus or minus 6 percent)
volumeJitternumber0.1 for effects, 0 for musicRandom volume, a fraction
loopbooleanfalseLoop it (an engine, a fire, ambience)
maxVoicesnumber4Voices of this sound at once; a new one past the limit stops the oldest
duckbooleanfalseLower the music while it plays (explosions, a big hit). Ignored on loops

Voice​

MemberWhat it does
stop(fade?)Fade out over fade seconds (default 0.05) and stop
setPosition(at)Move a looping positional sound
endedtrue once it stopped

AudioSettings​

{ muted, master, music, sfx, ui }, defaults false, 1, 0.7, 1, 0.8. They are saved per game on the device. The player's game settings from the save module (sound, music, volume, see Saves) gate the buses on top: sound off silences sfx and ui, music off silences music, and volume scales the master.

The mixing maths​

The pure functions behind the mixer are exported for your own sound code and tests:

ExportWhat it does
attenuation(distance, falloff?)Volume 0 to 1 for a distance: 1 inside near, inverse distance after, eased to 0 at far
panFor(listener, at)Stereo pan -1 (left) to 1 (right) of a point heard by the listener
jitter(base, amount, rand?)base moved by up to plus or minus amount (a fraction)
semitones(n)Semitones to a playback rate (12 is 2)
VoicePoolAt most max voices; admit(voice, now) returns the oldest voice to stop when full
DuckerMusic ducking state: duck(start, duration), ducked(now), gainAt(now)

Types: Listener is { x, y, z, yaw } with yaw 0 facing +z; Falloff is { near?, far?, rolloff? }.

RaceAudio​

The racing module's sound: two engine loops crossfaded and pitched by speed, a tyre squeal when cornering or braking hard, one-shots for accelerating, braking, barrier bumps and rivals passing, and a music bed. It plays the template's files in assets/audio/ through expo-audio. You only need it if you build your own racing game outside defineRaceGame:

import { RaceAudio } from '@/engine';

const audio = new RaceAudio();

export async function startRace() {
await audio.start();
}

/** Call once per simulation frame. */
export function raceFrame(speedRatio: number, throttle: number, brake: number, steer: number, bump: number, dt: number) {
audio.update({ speedRatio, throttle, brake, steer: Math.abs(steer), bump, racing: true, dt });
}

/** A rival went past the player. */
export function rivalPassed(relativeSpeed: number) {
audio.passBy(relativeSpeed);
}

export function endRace() {
audio.dispose();
}

Other members: setMuted(muted), muted, musicVolume (0.45) and stats (one-shot counts for telemetry).

Gotchas​

  • Off screen in the Moonspell app's feed, the mixer suspends its audio context and RaceAudio pauses its loops, so nothing plays from a card that is not shown. Outside the app the game is always active.
  • Keep footsteps and other frequent sounds short, soft and limited: maxVoices: 2 and some pitchJitter.
  • Trim and level generated audio before you ship it. The template's sound-design skill has an ffmpeg recipe (silenceremove and loudnorm). Players download every asset with the game, so keep the total small.
  • Every sound goes through require(). Never build a path or URL to an audio file by hand: the web build names assets by address.
  • Browsers hold audio back until the page gets a user gesture (their autoplay policy): a game's mixer starts suspended and is silent until then. In a browser the mixer resumes on the first tap, click or key, and RaceAudio starts its loops then (not while the app keeps the game off screen). Phones start sound without a gesture, as before.