Skip to content

API Overview

3d-audio exposes five exports: two on the server, three on the client.

Side Export Purpose
Server play3d Play a sound for one player or everyone
Server stop Stop a sound you started
Client play3d Play a sound only this player hears
Client stop Stop a client-local sound
Client setDimension Tell the resource the routing bucket changed

Two things to get right

Almost every integration bug comes from one of these two.

1. Sound IDs are split into two namespaces by sign

Sign Owner Who allocates it
Positive (1, 2, 3, …) Server The resource. play3d returns it to you.
Negative (-1, -2, -1001, …) Client You. You pass it in to the client play3d.

So the server export hands you an ID, and the client export expects one. Never pass a positive ID to the client play3d — it collides with the server's namespace and the two sounds will fight over the same slot.

2. The two play3d signatures are not the same

They look alike and are not:

-- SERVER: first arg is the TARGET player, returns the sound id
local id = exports['3d-audio']:play3d(target, soundFile, x, y, z, range, volume, loop,
                                      loopDurationSeconds, dimension, entityNetId)

-- CLIENT: first arg is the SOUND ID you chose, returns nothing
exports['3d-audio']:play3d(soundId, soundFile, x, y, z, range, volume, loop,
                           dimension, entityNetId)

Differences: the client version has no target (it is always the local player) and no loopDurationSeconds (use a timer in your own code), and its first argument means something completely different.

stop is asymmetric for the same reason:

exports['3d-audio']:stop(target, soundId)  -- server, two arguments
exports['3d-audio']:stop(soundId)          -- client, one argument

-1 means different things on each side

On the server, target = -1 means "all clients". On the client, the first argument is the sound ID, so -1 there is a perfectly valid sound ID. The examples in these docs use IDs like -1001 on the client to keep the two visually distinct.

Server or client?

Use the server export when the sound is part of world state that multiple players should hear: a car alarm, an explosion, a siren, a speaker in a club. The server owns the registry, so the sound survives players coming and going and is re-sent to players who enter its routing bucket later.

Use the client export when the sound belongs to one player's own experience and nobody else should hear it: UI feedback, a sound only the person interacting with something should get. No network traffic, no server state.

Calling exports

local soundId = exports['3d-audio']:play3d(-1, 'alarm/vehiclealarm.mp3',
    100.0, 200.0, 30.0, 80.0, 0.7, false)
const soundId: number = exports['3d-audio'].play3d(-1, 'alarm/vehiclealarm.mp3',
    100.0, 200.0, 30.0, 80.0, 0.7, false)
// esbuild shadows FiveM's `exports` with the CommonJS one, so reach for it
// through globalThis if your bundler does the same.
const fx = (globalThis as any).exports;

let soundId: number | null = null;
try {
    soundId = fx['3d-audio'].play3d(-1, 'alarm/vehiclealarm.mp3',
        100.0, 200.0, 30.0, 80.0, 0.7, false);
} catch (e) {
    console.warn('[my-resource] 3d-audio unavailable:', e);
}

Degrade gracefully

Wrap calls in pcall/try if your resource should keep working when 3d-audio is not running. Nothing here is required for your resource to start, so there is no reason for a missing audio resource to break it.

If you want FiveM to enforce the ordering instead, add dependency '3d-audio' to your own fxmanifest.lua.

Events

The net events and NUI messages are documented in Events & NUI Messages, but they are internal. Depend on the exports, not on the events — the exports are the stable surface.