Guide

Guide Audio Spatial audio

Spatial audio

Place listener and sources in space for directional sound behavior.

Intermediate ~3 min read

What you'll learn

  • place a listener and sources in space
  • tune directional falloff

Before you start

Spatial audio

Spatial audio in ExoJS is 2D: a single shared listener and any number of sound sources, each with a world-space position. The engine maps the 2D coordinates to the Web Audio API’s 3D panner behind the scenes — you work in the same pixel coordinates your sprites and containers use, and the audio system pans and attenuates accordingly.

The listener

The AudioListener is a singleton at app.audio.listener. Set its position directly or point it at a target that updates every frame:

// Static listener — audio doesn't move
app.audio.listener.position.set(400, 300);

// Dynamic listener — audio follows a scene node
app.audio.listener.target = this.player;

// Dynamic listener — audio follows a plain object (useful with views)
this.camera = { x: 0, y: 0 };
app.audio.listener.target = this.camera;

When target is set, the listener reads the target’s x and y each frame automatically. Use target for cameras, player characters, or any moving viewpoint. Set position directly when the listener is fixed.

Valid target types are:

  • A SceneNode (Sprite, Container, etc.) — reads from its global transform
  • A View — reads from its center
  • A plain { x, y } object — reads the properties directly
  • null — stops auto-tracking

Sound sources

A Sound becomes spatial the moment you assign a position:

import { Sound } from '@codexo/exojs';

const pickup = loader.get(Sound, 'pickup-sfx');
pickup.position = { x: 320, y: 180 };
pickup.play();

The audio pans left when the source is left of the listener, right when to the right. Volume attenuates with distance according to the configured distance model.

Setting position to null makes the sound non-spatial again — it plays at full volume in both channels regardless of listener position.

Distance models

Three attenuation curves control how volume drops with distance:

ModelBehavior
'linear'Full volume at refDistance, linear falloff to silence at maxDistance. Simplest to reason about.
'inverse'Full volume at refDistance, inverse-proportional falloff beyond. Natural-sounding drop-off.
'exponential'Full volume at refDistance, exponential falloff beyond. Sharpest near-field drop-off.

Configure per-sound via constructor options or live setters:

import { Sound } from '@codexo/exojs';

const ambient = new Sound(audioBuffer, {
    distanceModel: 'exponential',
    refDistance: 100,      // pixels — full volume within this radius
    maxDistance: 800,      // pixels — silence beyond this (linear only)
    rolloffFactor: 1.5,    // steepness multiplier (all models)
});
ambient.position = { x: 0, y: 0 };
ambient.setLoop(true).play();

refDistance (default 50) is the radius around the listener where the sound plays at full volume. maxDistance (default 1000) only applies to the linear model. rolloffFactor (default 1) scales the steepness — higher values make the sound drop off faster.

All four properties are live setters on Sound:

sound.distanceModel = 'inverse';
sound.refDistance = 80;
sound.rolloffFactor = 2;

Practical use

The most common pattern is to set the listener’s target to the camera or player once in init, then assign positions to spatial sounds when they play:

init(loader) {
    this.pickupSfx = loader.get(Sound, 'pickup-sfx');
    this.pickupSfx.setVolume(0.6);

    app.audio.listener.target = this.player;
}

update(delta) {
    // ... game logic ...

    if (pickupCollected) {
        this.pickupSfx.position = { x: item.x, y: item.y };
        this.pickupSfx.play();
    }
}

For ambient sounds that should follow the listener’s general area but not overlap, set a large refDistance:

const waterfall = loader.get(Sound, 'waterfall');
waterfall.distanceModel = 'inverse';
waterfall.refDistance = 300;
waterfall.position = { x: 600, y: 200 };
waterfall.setLoop(true).play();

Sounds per frame

Spatial updates happen automatically — the AudioManager runs listener._tick() each frame, which reads the target position and writes to the Web Audio PannerNode for every spatial Sound. You do not need to call any per-frame update on spatial sources. Assign a position, call play(), and the engine handles the rest.

Browser constraints

The Web Audio PannerNode maps to 3D space. ExoJS sets Z=0 for both listener and sources and uses forward=-Z with up=+Y, which produces correct left/right panning for 2D scenes. The elevation axis (up/down) is unused — all audio is co-planar with the listener.

ExoJS uses the panner’s equalpower model for 2D spatialisation. If you need deterministic non-spatial left/right balance, keep sounds non-spatial (position = null) and use bus-level pan instead.

Examples

Listener and Source Pointer Audio Open in Playground View source
import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

// Spatial parameters tuned to the canvas so attenuation is visible across the
// wide 1280px canvas. These mirror the Web Audio `linear` model (see
// DistanceModel in src/audio/Sound.ts) so the on-screen readout matches what
// you hear.
const REF_DISTANCE = 50;
const MAX_DISTANCE = 560;
const ROLLOFF = 1;
const SOURCE_RADIUS = 24;

function linearAttenuation(distance: number): number {
    if (distance <= REF_DISTANCE) return 1;
    const t = (distance - REF_DISTANCE) / (MAX_DISTANCE - REF_DISTANCE);
    return Math.max(0, 1 - ROLLOFF * t);
}

class ListenerAndSourceScene extends Scene {
    private sound!: Sound;
    private dragging = false;
    private listener!: { x: number; y: number };
    private graphics!: Graphics;
    private label!: Text;
    private tapPrompt!: Text;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Sound, { source: 'audio/impact-light.ogg' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.sound = new Sound(loader.get(Sound, 'source').audioBuffer, {
            distanceModel: 'linear',
            refDistance: REF_DISTANCE,
            maxDistance: MAX_DISTANCE,
            rolloffFactor: ROLLOFF,
        });
        this.listener = { x: width / 2, y: height / 2 };
        this.sound.position = { x: width / 2 + 220, y: height / 2 };
        app.audio.listener.target = this.listener;

        this.graphics = new Graphics();
        this.label = new Text('', { fillColor: Color.white, fontSize: 17 });
        this.label.setPosition(20, 20);

        // Shown while the browser still blocks audio (`app.audio.locked`); the
        // first click or keypress unlocks it and the queued loop starts.
        this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' })
            .setAnchor(0.5, 0.5)
            .setPosition(width / 2, height - 48);

        this.hud = mountControls({
            title: 'Listener and Source',
            controls: [{ keys: 'Drag', action: 'move the red source around the listener' }],
            status: 'Click or press any key to start…',
            hint: 'The green dot is the listener. Drag the red source — volume falls off with distance.',
        });

        this.app.input.onPointerDown.add(pointer => {
            const source = this.sound.position;
            if (!source) return;
            const dx = pointer.x - source.x;
            const dy = pointer.y - source.y;
            // Generous grab radius so the source is easy to pick up.
            if (dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) this.dragging = true;
        });
        this.app.input.onPointerMove.add(pointer => {
            if (!this.dragging) return;
            this.sound.position = { x: pointer.x, y: pointer.y };
        });
        this.app.input.onPointerUp.add(() => {
            this.dragging = false;
        });

        // Core defers playback until the AudioContext unlocks on the first
        // gesture, then starts automatically — just call play().
        this.sound.setLoop(true).setVolume(1).play();
        this.hud.setStatus('Drag the red source to move it');
    }

    override draw(context): void {
        const source = this.sound.position ?? { x: 0, y: 0 };
        const dx = source.x - this.listener.x;
        const dy = source.y - this.listener.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        const volume = linearAttenuation(dist);
        // Horizontal offset maps to stereo pan (left of listener = left ear).
        const pan = Math.max(-1, Math.min(1, dx / MAX_DISTANCE));
        const panText = pan < -0.05 ? `L ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `R ${pan.toFixed(2)}` : 'center';
        this.label.text = `distance: ${dist.toFixed(0)} px   volume: ${(volume * 100).toFixed(0)}%   pan: ${panText}`;

        context.backend.clear();
        this.graphics.clear();

        // Reference + max distance rings around the listener.
        this.graphics.fillColor = new Color(50, 60, 60);
        this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE);
        this.graphics.fillColor = new Color(0, 0, 0);
        this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE - 2);

        // Listener.
        this.graphics.fillColor = new Color(120, 255, 160);
        this.graphics.drawCircle(this.listener.x, this.listener.y, 14);

        // Source — brightness tracks attenuation so volume reads visually too.
        const glow = Math.floor(80 + volume * 175);
        this.graphics.fillColor = new Color(glow, Math.floor(80 + volume * 60), Math.floor(80 + volume * 60));
        this.graphics.drawCircle(source.x, source.y, SOURCE_RADIUS);

        context.render(this.graphics);
        context.render(this.label);

        if (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new ListenerAndSourceScene());

A draggable sound source and a fixed listener — drag the source to hear pan and attenuation change in real time.

Falloff Curves Pointer Audio Open in Playground View source
import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

type FalloffModel = 'linear' | 'inverse' | 'exponential';

interface FalloffModelDef {
    model: FalloffModel;
    color: Color;
}

// Horizontal placement (0..1 of canvas width) for each source; absolute pixel
// positions are resolved against the canvas in init().
const MODELS: Array<FalloffModelDef & { tx: number }> = [
    { model: 'linear', tx: 0.25, color: new Color(255, 140, 140) },
    { model: 'inverse', tx: 0.5, color: new Color(140, 200, 255) },
    { model: 'exponential', tx: 0.75, color: new Color(200, 255, 140) },
];

const REF_DISTANCE = 60;
const MAX_DISTANCE = 460;
const ROLLOFF = 1;

function attenuation(model: FalloffModel, d: number): number {
    if (d <= REF_DISTANCE) return 1;
    if (model === 'linear') {
        return Math.max(0, 1 - ROLLOFF * ((d - REF_DISTANCE) / (MAX_DISTANCE - REF_DISTANCE)));
    }
    if (model === 'inverse') {
        return REF_DISTANCE / (REF_DISTANCE + ROLLOFF * (d - REF_DISTANCE));
    }
    return Math.pow(d / REF_DISTANCE, -ROLLOFF);
}

interface FalloffSource extends FalloffModelDef {
    x: number;
    y: number;
}

class FalloffCurvesScene extends Scene {
    private listener!: { x: number; y: number };
    private sources!: FalloffSource[];
    private sounds!: Sound[];
    private graphics!: Graphics;
    private labels!: Text[];
    private tapPrompt!: Text;
    // Canvas-relative plot geometry computed in init().
    private plot = { x: 0, y: 0, w: 0, h: 0 };
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Sound, { source: 'audio/impact-light.ogg' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        // Sources spread across the lower half; the listener starts centred.
        const sourceY = height * 0.72;
        this.sources = MODELS.map(({ model, color, tx }) => ({ model, color, x: width * tx, y: sourceY }));
        this.plot = { x: width * 0.06, y: height * 0.16, w: width * 0.88, h: height * 0.18 };

        this.listener = { x: width / 2, y: height / 2 };
        app.audio.listener.target = this.listener;

        this.sounds = this.sources.map(({ model, x, y }) => {
            const sound = new Sound(loader.get(Sound, 'source').audioBuffer, {
                distanceModel: model,
                refDistance: REF_DISTANCE,
                maxDistance: MAX_DISTANCE,
                rolloffFactor: ROLLOFF,
            });
            sound.position = { x, y };
            return sound;
        });

        this.graphics = new Graphics();
        this.labels = this.sources.map(({ model, x, y }) => {
            const label = new Text(model, { fillColor: Color.white, fontSize: 16, align: 'center' });
            label.setAnchor(0.5, 0).setPosition(x, y + 30);
            return label;
        });

        // Shown while the browser still blocks audio (`app.audio.locked`); the
        // first click or keypress unlocks it and the queued loops start.
        this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' })
            .setAnchor(0.5, 0.5)
            .setPosition(width / 2, height - 24);

        this.hud = mountControls({
            title: 'Falloff Curves',
            controls: [{ keys: 'Move', action: 'relocate the listener' }],
            status: 'Click or press any key to start…',
            hint: 'Each source uses a different distance model — move the listener to compare attenuation.',
        });

        this.app.input.onPointerMove.add(pointer => {
            this.listener.x = pointer.x;
            this.listener.y = pointer.y;
        });

        // Core defers playback until the AudioContext unlocks on the first
        // gesture, then starts automatically — just call play().
        for (const sound of this.sounds) sound.setLoop(true).setVolume(0.5).play();
        this.hud.setStatus('Move the pointer to relocate the listener');
    }

    override draw(context): void {
        context.backend.clear();
        this.graphics.clear();

        // Falloff-curve plots in the upper canvas area.
        const { x: plotX, y: plotY, w: plotW, h: plotH } = this.plot;
        this.graphics.fillColor = new Color(40, 40, 50);
        this.graphics.drawRectangle(plotX, plotY, plotW, plotH);
        for (const { model, color } of this.sources) {
            this.graphics.fillColor = color;
            for (let i = 0; i < plotW; i += 2) {
                const d = (i / plotW) * MAX_DISTANCE * 1.2;
                const v = attenuation(model, d);
                this.graphics.drawRectangle(plotX + i, plotY + plotH - v * plotH, 2, 2);
            }
        }

        // Listener marker.
        this.graphics.fillColor = new Color(120, 255, 160);
        this.graphics.drawCircle(this.listener.x, this.listener.y, 10);

        // Source markers + live attenuation readouts.
        for (let i = 0; i < this.sources.length; i++) {
            const { x, y, color, model } = this.sources[i];
            const dx = x - this.listener.x;
            const dy = y - this.listener.y;
            const d = Math.sqrt(dx * dx + dy * dy);
            const v = attenuation(model, d);

            this.graphics.fillColor = color;
            this.graphics.drawCircle(x, y, 18);
            this.graphics.fillColor = new Color(255, 255, 255, Math.floor(v * 255));
            this.graphics.drawCircle(x, y, 6);

            this.labels[i].text = `${model}\nvol ${v.toFixed(2)}`;
        }

        context.render(this.graphics);
        for (const label of this.labels) context.render(label);

        if (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new FalloffCurvesScene());

Three sound sources with different distance models, with live attenuation readouts as you move the listener.

Where to go next

The next chapter, Audio effects, covers the audio filter system — how to shape sound with compressors, EQs, reverb, delay, and worklet-based effects like pitch shifting and ducking.