Guidance / Manual
Multiplayer (Netcode)
Multiplayer support lets you post a sound on one player’s machine and have every player hear it — without your audio code knowing which netcode stack the project uses, and without changing anything about how local audio works. The API surface lives in API_REFERENCE.md → Netcode (Multiplayer).
Applies to: SFXSystem.Netcode (Phases 0–7). Verified backends: FishNet v4 (Phases 0–7 on one PC and on two PCs over a real network) · Netcode for GameObjects (NGO) 2.x (light testing only so far, not the full check FishNet had; expect rough edges). Planned: Mirror · NGO 1.x back-compat · position quantization.
17.1 The one thing to understand first
No audio ever crosses the network. When you fire a networked sound, all that travels is a tiny ~36-byte descriptor — “play event #X at position Y (optionally attached to object Z)”. Each machine then plays that event locally through the normal audio engine. You are syncing intent, not sound. That’s why it’s cheap, and why every local feature (occlusion, reverb, propagation, voices) keeps working untouched. Even the musical sync (§17.6) sends only timing (tempo + a phase anchor), never samples.
In one sentence: Use PostNetworkedEvent instead of PostEvent for sounds everyone should hear; everything else stays exactly the same.
The audio system never references a netcode package. You install one stack (NGO, FishNet, …) and the matching adapter lights up automatically. A backend you didn’t install never compiles, so it can never break your build.
17.2 How it works
PostNetworkedEvent("door_open", pos)
│
├─ plays LOCALLY right now ──────────────► you hear it instantly (local echo)
│
└─ Broadcast(descriptor) ─► [adapter RPC] ─► server ─► other clients
│
PostEvent(id, pos) on each
- Local echo: the instigator plays the sound immediately (zero network latency); only other peers get it over the wire. No double-play.
- Authority: server-authoritative relay — the client sends to the server, the server validates and relays to everyone else. (The adapter implements this; the core doesn’t care.)
- Identity on the wire: each
AudioEventhas a stable ID (a build-deterministic number derived from the asset’s GUID). Both ends resolve the same ID to the same event. Events must have a captured ID to be networkable — see §17.3 step 2. - Seclusion: delete the whole
Netcodefolder and your single-player game compiles and behaves identically. Networking is purely additive. - Leaving it out without deleting it: add
SFXSYSTEM_DISABLE_NETCODEto Project Settings > Player > Scripting Define Symbols. Every multiplayer assembly (the core, its editor tools, its tests and the FishNet, NGO and Mirror adapters) is then left out of the build, so none of its code or statics reach your game, and updating the package doesn’t bring the folder back. Nothing else changes. Useful when your game does its own networking (a racing game that sends car state, not sound events).
| Layer | What it does |
|---|---|
SFXSystem.Runtime |
The audio engine. Knows nothing about networking. |
SFXSystem.Netcode (core) |
The agnostic contract: payload, transport interface, router, replicator, clock/transport, and the NetworkedAudioManager façade. References Runtime only. |
SFXSystem.Netcode.Ngo / .FishNet / … |
One tiny adapter per backend — the only code that references that stack. Auto-enabled when its package is installed. |
17.3 Quick Start
Step 1 — Install a netcode backend. NGO: Package Manager → Netcode for GameObjects. FishNet: import from the Asset Store. The matching adapter (SFXSystem.Netcode.Ngo / .FishNet) enables itself. Install only one stack.
Step 2 — Give your events stable IDs. Open Window ▸ Audio System ▸ Audio Event IDs and click Capture All Missing IDs. Every event needs a non-zero Stable ID to be networkable. Newly created events get one automatically on import; the sweep back-fills older ones.
Step 3 — Put the transport in your scene. Create one networked object and add the matching transport component: NGO → NetworkObject + NgoAudioTransport; FishNet → NetworkObject + FishNetAudioTransport. Exactly one per session, on a persistent networked object (e.g. an AudioNetwork GameObject) — not on per-player objects. It must be active in the scene.
Step 4 — (optional) Add settings & health-check. Drop a NetworkAudioSettings component in the scene to opt into proximity culling, an authority validator, and the clock tunables (§17.5). With none present, sensible defaults apply (validation allow-all, culling off). Open Window ▸ Audio System ▸ Network Audio Debug in Play mode to watch live session/clock/loop state.
Step 5 — Post networked sounds.
using AudioSystem.Netcode;
// Heard by everyone (local echo + relay to others):
NetworkedAudioManager.PostNetworkedEvent("door_open", transform.position);
// Still 100% local — only this client hears it. Unchanged:
AudioManager.Instance.PostEvent("ui_click");
With no live session (single-player, not connected), PostNetworkedEvent gracefully falls back to a normal local play — so the same call works everywhere.
Authoring note: the per-event Multiplayer foldout in the
AudioEventinspector (§17.4) only appears once a netcode backend is installed. The fields it exposes live on the RuntimeAudioEventand are inert without theNetcodelayer, so single-player projects stay uncluttered.
17.4 Local vs networked — the mental model
| Call | Who hears it | Use it for |
|---|---|---|
AudioManager.Instance.PostEvent(...) |
only this client | UI clicks, this player’s own HUD, anything client-private |
NetworkedAudioManager.PostNetworkedEvent(...) |
everyone | gunshots, doors, explosions, world sounds all players share |
NetworkedAudioManager.PostServerEvent(...) |
everyone (from the server) | round-start stingers, objective sounds the server decides — no local instigator |
Rule of thumb: if another player should hear it, use PostNetworkedEvent. If the server is the authority for it (no single client instigated it), use PostServerEvent. Otherwise use PostEvent.
Every capability below stays descriptor-only — still no audio on the wire.
Attach a sound to a moving object (emitter binding). Pass a networked GameObject and the sound follows it on every client instead of playing at a frozen point:
NetworkedAudioManager.PostNetworkedEvent("grenade_bounce", grenade.transform.position, grenade);
The object needs a NetworkObject. Remote clients resolve its net-id and parent the voice to it; position is the spawn anchor and the fallback. If the object isn’t resolvable yet (its spawn message may still be in flight), the sound is held for a short retry window (~0.5 s), then falls back to the static position — never a frozen sound at the origin, never an error.
Shared loops you can stop (networked lifetime). For a sound that must start and later stop for everyone — an alarm, a generator hum, a held ambience — use the loop API. It returns a handle:
NetworkedSoundHandle alarm = NetworkedAudioManager.PostNetworkedLoop("alarm_loop", pos, alarmObj);
// …later, stop it on every client:
NetworkedAudioManager.StopNetworked(alarm);
-
A late joiner automatically hears loops already running: the server keeps a registry of active loops and replays them to the new client only.
-
Loop sync tiers control the joiner’s phase — authored per-event (Multiplayer foldout, Loop Sync Tier): 0 None (joiner starts from the beginning; everyone free-runs); 1 Aligned at join (joiner fast-forwards into the shared phase once, then free-runs); 2 Phase-locked (continuously grid-anchored and gapless — stays in phase across all machines; use for musical/rhythmic loops). Tier 2 requires variation parity — leave Independent Variation off, or each client phase-locks a different clip (the inspector warns). Only meaningful for a loop that repeats one clip; an auto-advancing Sequence container is meant to free-run (this guard is not yet tested).
Tier 2 is experimental. It is still under construction and shouldn’t be relied upon in production in its current state. Two-PC testing shows it holding phase with no measurable drift over 15+ minutes, but a late joiner can play one or two iterations out of phase (then go briefly silent) before it locks on. Tiers 0 and 1 are unaffected.
-
Off-session the same calls degrade to a plain local loop; the handle still stops it. Persistence is an explicit choice (this API), never inferred from a container’s loop flag.
Same random variant on every client (variation parity). A RandomContainer normally rolls independently per machine. Networked posts force the same variant — clip choice and the per-voice pitch/volume roll — on every client by default.
NetworkedAudioManager.PostNetworkedEvent("footstep", pos); // identical everywhere (default)
NetworkedAudioManager.PostNetworkedEvent("crowd_murmur", pos, independentVariation: true); // each client rolls its own
independentVariation is a nullable bool: null (default) uses the event’s authored Independent Variation (default) flag; true/false overrides per call. The seed also covers the parts of a post that run later: a Play action with a Delay, and a SequenceContainer’s later clips (in Random order too) each get a seed worked out from the post’s, so they pick the same variants on every machine. (Before 3.1, a Random-order Sequence past its first entry, and delayed Plays, escaped the seed.)
Server-authored sounds. When the server is the authority for a sound with no single client instigator, PostServer* originates from the server to everyone, with no local echo:
NetworkedAudioManager.PostServerEvent("round_start", Vector3.zero); // server only — warns + no-ops on a client
NetworkedSoundHandle hum = NetworkedAudioManager.PostServerLoop("reactor_hum", reactorPos, reactor);
NetworkedAudioManager.StopNetworked(hum); // server-authored stop → All
Client-instigated relays run through a server-side authority validator first (assign NetworkAudioSettings.ServerValidator in code); reject → the sound is dropped for others (the instigator still heard its echo). A client can stop only loops it owns; server-authored loops can only be stopped by the server.
Proximity culling — don’t send. With culling on, the server sends a spatial (3D) sound only to clients whose listener is within its audible radius — the packet is never sent out of range. That halves fanout and closes an audio-wallhack. 2D sounds are never culled.
var settings = FindObjectOfType<NetworkAudioSettings>();
settings.ProximityCulling = true;
settings.ListenerPositionProvider = clientId => PlayerPositions.TryGet(clientId, out var p) ? p : (Vector3?)null;
Return null from the provider when a position is unknown → that client is not culled (fail-safe). A null provider makes culling inert (the Network Audio Debug window flags this). Active loops are re-evaluated over time (~33 Hz): a listener walking into range starts hearing the loop.
Per-event reliability. Every relay is reliable by default. Mark spammy positional one-shots (footsteps, full-auto gunfire) Unreliable Delivery in the Multiplayer foldout to send them on the unreliable channel (lower overhead, no head-of-line blocking). Loops, stops, snapshots, and scheduled cues always stay reliable.
17.5 Per-event authoring (Multiplayer foldout)
Shown in the AudioEvent inspector only when a netcode backend is installed:
| Field | Description |
|---|---|
Unreliable Delivery |
Relay this event’s one-shots on the unreliable channel. |
Independent Variation (default) |
Default for independentVariation when a post passes null. |
Loop Sync Tier |
0 None / 1 Aligned-at-join / 2 Phase-locked (experimental, see §17.4). Tier 2 needs variation parity, Max Instances 0 (or at least 3), Cooldown 0, and — for a Routing or Blend that plays several clips at once — clips of equal length. The inspector warns about each. |
NetworkAudioSettings (a scene component) holds the culling/clock config and the game-supplied hooks (ListenerPositionProvider, ServerValidator). See API_REFERENCE.md → Netcode (Multiplayer) for the full member list.
17.6 Musical sync — scheduled & quantized cues
For cross-machine rhythmic timing, the layer adds a shared network clock (maps network time onto each machine’s local audio clock) and an optional musical transport (tempo + phase). Cues land together on every machine — the industry transport-and-quantize model, not varispeed.
// Fire on a shared network time (each client maps it to its own dsp clock):
double t = NetworkClock.NetworkTime + 0.5;
NetworkedAudioManager.PostNetworkedScheduled("beep", pos, t);
// Server sets tempo once (networked on change, replayed to late joiners):
NetworkMusicTransport.SetTempo(bpm: 128, beatsPerBar: 4);
// Quantize a cue to the next beat/bar of the shared grid:
NetworkedAudioManager.PostNetworkedQuantized("stinger", pos, Quantize.NextBar);
- A scheduled cue is clamped to at least a minimum lead ahead (so even a distant client receives it before its onset); one that still arrives late is dropped rather than fired out-of-phase.
PostServerScheduled/PostServerQuantizedare the server-authored twins.- A mid-session
SetTempochange re-anchors seamlessly at the next bar of the current grid. - Scheduled/quantized cues are global (non-positional) by design and bypass proximity culling — they’re for music, not positional gameplay.
NetworkClock.ResyncClock()forces a hard re-sample after a known hitch.
17.7 Per-backend notes
NGO (Netcode for GameObjects) 2.x — Adapter NgoAudioTransport, gated on com.unity.netcode.gameobjects (SFXSYSTEM_NGO). In-scene NetworkObjects auto-spawn on server start. Late-join snapshot fires from OnClientConnectedCallback (NGO has no per-observer hook, so it relies on scene-object sync completing before the callback). Server id is 0 and remote clients start at 1. NGO has had light testing only; the full Phase 4–7 sweep FishNet had is still to do. It compiles and coexists with FishNet.
FishNet v4 — Adapter FishNetAudioTransport, gated on the FISHNET define. The transport’s NetworkObject must be active in the loaded scene — it only registers once the object network-spawns (if a freshly added object doesn’t spawn, save the scene). Late-join snapshot fires from OnSpawnServer(NetworkConnection) — per-observer, robust timing. Client ids can start at 0, so server-authored loop ids use a reserved 0xFFFFFFFF owner namespace (handled for you).
Run one backend at a time. Both adapters can be present in the project (each is gated), but you start a session with a single stack.
17.8 Current limitations (by design, for now)
- Networked events should be “play” sounds. Global state changes (RTPC / Switch / State / ducking) stay locally driven by each client’s own game logic — networked posting doesn’t replicate global audio state. (Variation parity covers randomness within a chosen branch, not the switch/state/RTPC input that chooses the branch.)
- Scheduled / quantized cues are global (non-positional) and bypass proximity culling by design — they’re for musical sync, not positional gameplay.
- Tier-2 phase-locked loops are experimental — not for production yet. Known issue: a late joiner can play one or two iterations out of phase before locking on.
- Tier-2 phase-locked loops are delivered to all peers (the culler skips them).
- Cross-machine tightness is bounded by the clock estimate — quantization buys slot-agreement and drift-immunity, not closeness.
- Position is full precision (quantization for smaller packets is a future optimization). Static / single-context (one
AudioManager, one router, one transport per process). - Scale. Built for co-op and competitive sessions, scaling as far as your chosen netcode’s authoritative-server model does (typically tens of players). Adds proximity culling but not MMO-grade interest management or sharding; for hundreds+ concurrent players a different replication architecture is required.
- Breadth still open: Mirror adapter and NGO 1.x back-compat. The NGO 2.x adapter has had light testing only.
17.9 Extending: writing a new backend adapter
- Create a gated assembly under
Netcode/Adapters/<Stack>/referencingSFXSystem.Runtime,SFXSystem.Netcode, and that stack’s runtime assembly, constrained on the stack’s define symbol. - Implement
INetworkAudioTransporton a networked behaviour: Play via the stack’s RPCs inBroadcast, raiseOnEventReceivedon receipt, marshal theemitterGameObject to a net-id and implementTryResolveEmitter; Stop viaBroadcastStop→OnStopReceived; ScopeAlloriginates directly to all clients + host (never a dedicated server); Scheduled / transport implementBroadcastScheduled/BroadcastTransportand raiseOnScheduledReceived/OnTransportReceived/OnLoopJoinReceived, exposeServerTime+RoundTripTime; Late join replaysNetworkAudioRegistry.Snapshot(...)to a joining client; Register withNetworkAudioRouter.Register(this)/Unregister(this)and pumpAudioEventReplicator.Tick()+NetworkClock.Tick()fromUpdate. - Relay to everyone except the sender (it already played the local echo).
The core and the audio runtime never change.
17.10 Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| Sound plays locally but not on other clients | Transport component missing/inactive, or not on a spawned NetworkObject. Check Network Audio Debug — is the session Active? |
'<event>' has no stable id warning |
Run Audio Event IDs ▸ Capture All Missing IDs. |
| Nothing networks at all | Backend not installed, or no NetworkManager/transport in the scene. |
| Hearing the sound twice while testing | Multiplayer Play Mode plays both virtual players through one set of speakers (a flam). Expected; on real separate machines each player hears it once. |
| A moving sound plays at a frozen point on remote clients | The emitter object has no NetworkObject, or isn’t spawned/observed on that client — it fell back to the static position. |
| A late joiner doesn’t hear a running loop | Only PostNetworkedLoop sounds are snapshotted. On NGO, confirm the joiner synced the transport object before the snapshot fired (§17.7). |
StopNetworked doesn’t silence remotes |
Keep the NetworkedSoundHandle from PostNetworkedLoop and pass that handle. |
| Culling on but everyone still hears everything | No ListenerPositionProvider assigned → culling is inert. Network Audio Debug ▸ Config Health flags this. |
| A Tier-2 loop plays a different clip per client | The event has both Loop Sync Tier 2 and Independent Variation on. Turn Independent Variation off (the inspector warns). |
| A Tier-2 loop cuts out after a cycle or two | Each iteration is an ordinary post of the event, and they overlap. Max Instances 1–2 steals an iteration; a Cooldown rejects one. Use Max Instances 0 and Cooldown 0 (the inspector warns). |
| A layered Tier-2 loop doubles up or gaps every cycle | The Routing/Blend plays clips of different lengths; the loop length comes from the first clip. Make the layers the same length (the inspector warns). |
| A late joiner’s Tier-2 loop is out of phase for a cycle or two, goes quiet, then locks on | Known issue — Tier 2 is experimental (§17.4). Tiers 0 and 1 don’t have it. |
| One machine’s audio slows down, crackles, or drifts badly even on Tier 0 | That machine’s audio device is falling behind (its audio clock runs slow), not the network. Check the output device and its buffer size; close audio-heavy apps. Tier 2 re-anchors each cycle and recovers once the device does. |
| Quantized cue does nothing / warns | No musical transport set — call NetworkMusicTransport.SetTempo(...) on the server first. |
17.11 Dedicated servers: Inert
A dedicated server runs the same scenes and prefabs as a client, and must play nothing and cost nothing. Set Pepper inert once, at boot, before any Audio Manager starts:
// The server's boot code, before the first scene with sounds loads.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void SilenceServer()
{
if (IsDedicatedServer()) AudioManager.Inert = true; // your own check
}
- Every post (
AudioEvent.Post,PostMultiPosition,AudioManager.PostandPostEvent,AudioEmitter.Post) returns one shared, already-finished handle, without allocating and without logging, whether an Audio Manager exists or not. ItsOnFinishedruns at once when you subscribe;EventNameis empty. - An Audio Manager that starts while inert builds no voices, coroutines or per-frame step, and loads no buses. It still becomes
AudioManager.Instanceand loads the event registry, so code that looks events up (the multiplayer relay) keeps working. - The manager’s parameter, switch and RTPC calls do nothing. Values set on emitters and handles are kept as plain data and never stepped. Listening points still register (nothing reads them), so a game’s references to them stay valid.
- The ambient system doesn’t create itself when a zone, portal or ambient source is switched on.
- It can’t change while an Audio Manager runs (it warns and keeps its value); a server stays a server. It goes back to false at the start of each play session.
- Pepper doesn’t detect a server or batch mode by itself (its own tests run in batch mode): your game decides.
From the Pepper.SFX 3.1 guides (USER_GUIDE.md). Something wrong or unclear? Tell me.