Large servers & scale
Hundreds or thousands of players per shard, proximity voice at range, radio-style nets, command channels, audiences — the channel topology, the numbers behind node sizing and the backend flow that holds it together.
This guide is for any title where one world holds many players at once — open-world survival and shooters, MMO shards, battle royale lobbies, social and VR worlds, large co-op raids, persistent roleplay servers. The same primitives cover all of them: a few channel types, one token per player, nodes sized by what the server has to do per participant.
Channel topology#
Design the layout per world instance (shard, match, server), not per player. A player holds one session and joins several channels at once; the SDK decides which channel the microphone goes to.
| Need | Channel type | One per | Notes |
|---|---|---|---|
| Proximity ("local") voice | positional | shard, or sector on very large maps | distance attenuation, direction, radius-scoped roster and chat |
| Team / squad / party | team | group | plain conference, everyone hears everyone |
| Radio-style net (platoon, faction, guild) | team | net / frequency | a player joins several; push-to-talk picks the one to transmit on |
| Leaders / staff broadcast | command | shard | designated speakers transmit, everyone in it hears |
| Direct line | whisper | pair | one-to-one; created ad hoc |
| Event stage, streamer, GM | team with audience | event | thousands of listeners, a handful of speakers, server-mixed |
| Mic check | echo | — | the player hears themselves |
Rule of thumb: one positional channel per shard of up to ~2,000 players, split into sectors beyond that; one team channel per group, created ad hoc from the token so your backend never pre-creates thousands of rows.
Step 1 — the proximity channel#
Create it once per shard through your backend (cell API, SDK key) or let the first token create it ad hoc:
{
"name": "shard-17-world",
"config": {
"channel_type": "positional",
"max_participants": 2000,
"codec": "opus",
"bitrate": 32000,
"min_bitrate": 12000,
"enable_dtx": true,
"enable_fec": true,
"audio_profile": "voice",
"positional_config": {
"near_distance": 1.5,
"far_distance": 40.0,
"rolloff": "logarithmic",
"max_radius": 60.0,
"directional": true,
"coordinate_system": "left_handed",
"roster_radius": 70.0,
"text_radius": 25.0
},
"ambient": { "max_voices": 4, "ambient_gain": 0.15 }
}
}What each knob does at scale:
near_distance/far_distance/rolloff— full volume insidenear, silence pastfar;logarithmicsounds natural outdoors,linearis better in small interiors. Units are your game's units.max_radius— the server stops forwarding a speaker to listeners beyond it. This is what makes 2,000 players on one channel cheap: each listener receives only the voices in range, not 2,000 streams.roster_radius/text_radius— who shows up in a player's roster and who receives their "say" text. Keeproster_radius ≥ max_radiusso every voice you hear has a name. A fresh joiner is invisible until their first position update, so nobody can enumerate the shard by joining.directional: true— the server sends azimuth/elevation; the SDK pans locally (Unity/Unreal/native) or via Web Audio HRTF (browser).ambient— in a crowd, only the four loudest voices stay at full gain, the rest become a murmur. Essential for hubs, markets, spawn areas.
Positions go from the client at 10 Hz (SetPosition / PositionUpdate); more is wasted. Occlusion and reverb zones are client-side hints relayed by the server — put your own DSP on them.
Step 2 — groups and radio-style nets#
A team channel is a plain conference. For squads and parties create it ad hoc from the token so it exists exactly as long as someone is in it:
{
"external_id": "player-2991",
"display_name": "Kestrel",
"channels": [
{ "channel_id": "<shard-17-world id>", "join": true, "speak": true, "receive": true },
{ "ad_hoc": { "name": "squad-4f2a", "channel_type": "team", "max_participants": 8 }, "join": true, "speak": true, "receive": true },
{ "ad_hoc": { "name": "faction-blue", "channel_type": "team", "max_participants": 500 }, "join": true, "speak": false, "receive": true }
]
}A "radio" is just another team channel the player is in while also being in proximity. The SDK's transmit target (push-to-talk per channel: transmitToChannel / TransmitToChannelAsync / SetTransmission + SetChannelFocus) selects where the microphone goes; the player keeps hearing every channel they joined. Radio effects (band-pass, squelch, static with distance) are client-side presets so each player hears their own radio the way your game configured it — the server never processes audio for them.
For a wide net with hundreds of members, give most members speak: false (as faction-blue above) and promote on demand; a channel where everyone can speak at once is a channel where everyone will.
Step 3 — leaders, staff, events#
command lets only designated speakers transmit while everyone in it listens: use it for the shard's commander, raid lead, game master or event host. Combine with priority and ducking so leaders are heard over the squad:
{ "channel_id": "<shard-17-command id>", "join": true, "speak": true, "receive": true, "priority": true }"ducking": { "gain": 0.25, "attack_ms": 60, "release_ms": 400, "hold_ms": 250, "moderators": false }While a priority speaker talks, everyone else in the channel is attenuated to 25 %. For events with thousands of listeners switch on the audience shape:
"audience": { "hide_listeners": true, "mix_for_listeners": true, "max_speakers": 8,
"speaker_admission": "reject", "demote_idle_ms": 3000 }Listeners get one server-mixed stream and are hidden from each other's rosters; up to eight speakers are mixed. This is how a 5,000-person stage fits on one L node.
Step 4 — the token#
Players never hold your SDK key. Your game server (or match service) mints a session token per player with the grants above and hands it to the client:
POST /v1/tokens
x-api-key: <SDK key — server-side only>
Content-Type: application/json
{ "external_id": "player-2991", "display_name": "Kestrel",
"channels": [ … ],
"metadata": { "shard": 17 } }Grants are join, speak, receive, moderate, priority. The response carries the token, the resolved channel IDs and the endpoint (ws_url and friends) the client connects to. Re-issue a token when the player changes squad (the SDK swaps channels without reconnecting) or before it expires; the cell refuses expired tokens at the next join, not mid-call. Everything a shard needs — mute, kick, move, change gain — is a cell API call with the same key, from your server. See Tokens.
Sizing nodes#
Rated capacity per dedicated node: S 500 · M 2,000 · L 5,000 participants for plain forwarding (SFU). What changes the picture is per-participant server work:
| Workload | Cost on the node | Where it bites |
|---|---|---|
| Forwarding (SFU, positional range) | ~5–9 µs CPU per delivered frame — network-bound first | large open channels; fine |
| Encrypted transports (QUIC, WebTransport, WebRTC) | 1.4–2× the CPU of raw UDP for the same frames | browser-heavy audiences |
| Server noise suppression | 200 noisy uplinks ≈ 3 cores | |
| Server mixing | one decoder per active speaker + one encoder per mixed downlink | team shape with 1,000 private mixers does not fit an 8-core host; audience shape does |
| Cross-node channels (cascade) | ~0.25 core per node, half the fan-out each | lets one channel span two nodes |
Measured on one 8 vCPU host at 1,000 sessions / 100 channels / 2 speakers each: 0.43–0.57 core on UDP (two hosts, two builds), 0.80 on QUIC, 3.5–3.75 cores with noise suppression on 200 uplinks, ~4.9 cores for an audience-shaped mix, 7.73 cores (saturated) for mix + suppression. The same shape ramped on one node: 2,000 participants → 1.1 cores, 3,000 → 1.65, 5,000 → 2.7 cores, 238 MiB, 100 % delivered, p99 8.7 ms — that run is where the L rating comes from. Above it the measurement host (node, load generator and databases on the same 8 vCPUs) ran out, not the node, so figures past 5,000 per node are extrapolation. Planning rules that follow:
- Budget the node for concurrent participants at peak, not registered players; count 1 session per connected client.
- Prefer client-side noise suppression (all SDKs have it) and keep server suppression for channels that need it.
- For big listening channels use
audiencewithmix_for_listeners— one mixer, not one per member. - Keep
max_radiushonest: half the radius is a quarter of the fan-out. - One M node per 2,000 concurrent per region is the default; two S nodes beat one M when you want to lose half, not all, on a host failure.
- Autoscale with min 1 / max N per region; scale-down waits for an empty node.
Regions and placement#
A project has a home region; channels are placed on an available node there (healthy and under 90 % of capacity). Players connect to the node that holds the channel, so put the shard where its players are. Cross-node channels exist for when one shard outgrows a node, but the simplest fix for a 6,000-player world is three sectors on three nodes. The third step of the spending ladder — "new channels in the home region only" — is exactly the manual lever you would pull when the forecast overshoots.
Several regions#
The shared pool is one such backbone: a single cell whose media nodes sit in several regions (the first is eu-central; a region appears in the catalog the moment a healthy pool node serves it and disappears with the last one), with the database and Redis reachable only over the pool's private mesh. Your own nodes (BYOC, Cloud) form your project's own cell the same way — add nodes in more regions and they join it as regional media nodes.
Grow in this order. First, more regions on the same backbone (Console → Project → Regions, or POST /v1/projects/{id}/regions, then nodes in them): channels span these regions, players are sent to the nearest node and audio between regions rides the backbone — this covers almost every game. Second, when a region is on a separate backbone (another continent, or an isolated deployment a publisher asks for — data residency, blast radius), channels stop at that boundary: use it for regional shards (an EU shard in EU, a US shard in US), not for one channel spanning oceans; proximity voice across 100 ms+ is not something you want anyway. The console shows which regions share a backbone. Plan limits: Free 1 region, Indie 2, Cloud / BYOC unlimited.
Steering players: let the control plane do it — POST /v1/projects/{id}/tokens picks the cell from the region hint, the player's coordinates or the request's country, and returns the nearest node of that cell. GET /v1/projects/{id}/discover gives the ranked list with probe_url per region for a lobby region picker. Both work on any hostname, so no GeoDNS or anycast is needed in front of the control plane.
Reconnects and failure#
Sessions survive transport switches (QUIC → WebRTC → WebSocket in browsers) and short drops; the SDK resumes with the same token and rejoins its channels. If a node is lost, channels on it are recreated on another node of the region when players rejoin — positions and rosters rebuild within a second of the first updates. Keep token TTLs longer than your longest expected match so a reconnect never needs a new token mid-fight.
Cost of a large shard#
A 1,500-concurrent shard on one M node running 24 × 7 is $109.50 / month in node-hours plus traffic over 10 TB at $0.01 / GB and TURN over 200 GB at $0.04 / GB — about $140–160 all in for a very chatty server. The same shard on the shared pool is impossible (300 cap), and at a per-CCU rate of a few cents per hour it would be thousands. Use the calculator with your own peak and hours; What you pay for lists every line.
Engine notes#
- Unity / Unreal / Godot: the SDKs expose position updates, transmit-channel selection, per-channel volume, roster-scoped events and the channel scope (
GetChannelScope) so your UI can show "who can hear me". - Custom engines and dedicated-server mods: the C ABI gives capture/playback and the same session model; positions come from your game state. Minting tokens from the dedicated server keeps the SDK key off every client.
- Browsers: WebTransport first, then WebRTC; positional audio is server-mixed stereo with optional per-participant tracks for HRTF — the latter costs more downlink traffic, so enable it where it matters.