Multiplayer patterns
A multiplayer game is a distributed system with a frame budget. Several browsers must agree on one shared world, and they must agree fast enough that nobody notices the disagreement. The network gives you no help here. Packets arrive late, out of order, or not at all. Each peer runs on a different clock. The only thing every peer shares is a stream of messages over an RTCDataChannel.
This article covers how to keep those peers consistent. It describes the topology this repo uses (a host-and-spokes star, never a mesh) and the two state-sharing patterns the games are actually built on: authoritative-host (pong, worms) and hidden-state (battleship). It also covers determinism: how the host and a guest reproduce the same procedurally generated world from a single seed, and where that guarantee breaks.
You will not find a TURN relay here, and you will not find a game server. Signaling can touch a server to exchange SDP, but gameplay never does. Every state update travels peer-to-peer. That constraint shapes every pattern below.
Part 1: The consistency problem
What "consistent" means
Two peers are consistent when they would answer every gameplay question the same way. Where is the ball? Whose turn is it? Did that shot hit? When the answers diverge, the game splits into two realities, and players see different things on their screens. One sees the ball bounce; the other sees it pass through the wall. From that point the game is broken even if no error is ever thrown.
Consistency is hard because of three facts about the network:
- Latency. A message takes time to travel. By the time a peer reads "ball at x=40", the ball has already moved on the sender's side.
- Jitter. That travel time varies. Updates that the host sends on a steady 30 Hz clock arrive bunched up, then with a gap, then bunched again.
- Loss. Over an unreliable channel, some messages never arrive at all. The game must not wait for them.
You cannot remove these facts. You can only choose a design that survives them. The choice comes down to who decides what is true and what each peer is allowed to know.
Why the topology is a star, not a mesh
The obvious topology for N peers is a full mesh: everyone connects to everyone, so any peer can talk to any other directly. For N peers that is N·(N−1)/2 connections. Four peers need six connections, each with its own ICE negotiation, its own STUN round trip, its own failure mode. Worse, in a mesh there is no single owner of the truth, so every peer must reconcile updates from every other peer. Consistency in a mesh is a hard research problem.
This repo does not build a mesh. It builds a host-and-spokes star. One peer is the host. Every other peer (a guest) holds exactly one connection, to the host. Guests never talk to each other. For N players that is N−1 connections, all anchored on one machine.
src/salon/peer-pool.js is the host side of that star. The host calls peerPool(size) and gets an object that owns one RTCPeerConnection per guest, indexed by slotIdx:
import { peerPool } from '../salon/peer-pool.js';
// Host with room for two guests (a three-player worms match).
const pool = peerPool(2, {
onSlotState: (slotIdx, state) => {
// 'connected' | 'disconnected' | 'failed' for one guest
},
onSlotMessage: (slotIdx, msg) => {
// a message from the guest in this slot (an input, usually)
handleGuestInput(slotIdx, msg);
},
});
// Invite guest 0: returns an SDP offer code to hand off via signaling.
const offerCode = await pool.inviteSlot(0);
// ... guest answers ...
await pool.acceptAnswer(0, answerCode);
The pool keeps two parallel arrays, pcs[] and channels[], both indexed by slotIdx. A guest is fully identified by its slot. To message one guest you call pool.send(slotIdx, msg); to message all of them you call pool.broadcast(msg). Broadcast filters to channels whose readyState === 'open', so a half-connected or dropped guest is skipped without a guard at the call site.
The star buys three things:
- One owner of the truth. The host runs the simulation; guests do not have to reconcile anything.
- Linear connection count. N−1 connections instead of N²/2. Each guest pays for one handshake.
- A natural authority boundary. The host is the only peer that talks to everyone, so it is the only peer that can hold global state.
The cost is that the host's uplink carries all traffic, and the host leaving ends the match. For the small player counts this repo targets (two to three), that trade is correct.
The two patterns
Inside that star, the games use two patterns for sharing state. They differ on what each peer is allowed to know.
| Pattern | Who owns truth | What crosses the wire | Each peer sees | Repo games |
|---|---|---|---|---|
| Authoritative-host | Host owns the whole simulation | Guest → host: inputs. Host → guests: full state snapshots / events | Everything, eventually | pong, worms |
| Hidden-state | Each peer owns its own private board | Only events: actions and their results | Only its own state plus revealed events | battleship |
Authoritative-host fits games where one shared world evolves continuously and everyone is meant to see all of it: a bouncing ball, a flying worm, a physics simulation. The host simulates; guests render.
Hidden-state fits games of imperfect information, where one peer must not see another peer's state. Battleship is the clean case: your fleet's positions are a secret. Neither peer can hold the full board, because holding it would mean the other peer's secret has crossed the wire. So nothing crosses the wire except the events both peers are entitled to know.
A third model, lockstep, appears in the comparison below but no repo game uses it as its primary loop. It matters because the determinism machinery the games do use (seeded RNG) is the same machinery lockstep is built on.
Part 2: The patterns in depth
Authoritative-host
In the authoritative-host pattern the host runs the real game. Guests run a display of it.
The flow is a loop with two halves:
- Guests send inputs. A guest reads its keyboard and sends a small message: "I am holding left." It does not move anything itself, or if it does, only provisionally (see prediction, below).
- The host simulates and broadcasts. On its own clock, the host applies every guest's inputs plus its own, advances the physics one step, and broadcasts the result. Guests receive that result and render it.
The host is the single source of truth. If the host says the ball is at x=40, the ball is at x=40. A guest that thought otherwise corrects itself on the next snapshot.
The tick and broadcast loop
The simulation advances on a fixed tick, decoupled from rendering. A common choice is 30 Hz for simulation and 60 Hz for render. Inputs arrive whenever they arrive; the host samples the latest known input for each player at each tick.
// Host side. `pool` is a peerPool; `inputs` holds the latest input per player.
const TICK_HZ = 30;
const STEP = 1 / TICK_HZ;
const world = createWorld(); // host owns this
const inputs = { host: neutral(), 0: neutral(), 1: neutral() };
// Guest inputs land here, keyed by slotIdx.
pool.onSlotMessage = (slotIdx, msg) => {
if (msg.t === 'input') inputs[slotIdx] = msg.input;
};
let acc = 0, last = performance.now();
function frame(now) {
acc += Math.min((now - last) / 1000, 0.1); // clamp to survive tab blur
last = now;
// Advance the simulation in fixed steps.
while (acc >= STEP) {
simulate(world, inputs, STEP); // apply all inputs, step physics
acc -= STEP;
}
// Broadcast the authoritative state once per visual frame.
pool.broadcast({ t: 'state', tick: world.tick, snap: snapshot(world) });
renderHostView(world); // host also plays
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Two details matter. The accumulator (acc) keeps the simulation stepping at a fixed rate even when frames are uneven, so the physics is frame-rate independent. The clamp at 0.1 seconds stops a backgrounded tab from running hundreds of catch-up steps when it regains focus (the same clamp gameLoop(tick) applies in src/engine/).
The full round trip (guest input up, host simulation, state snapshot down, guest render) looks like this:
Whether you broadcast a full snapshot every frame or only deltas is a bandwidth tradeoff. For pong, the world is a ball and two paddles; a full snapshot is a handful of numbers, so snapshotting every frame is fine. For worms, more of the state is static once the round starts, so you broadcast events ("worm fired", "crater at x,y") and let guests apply them, sending a full snapshot only on join.
The guest side: rendering someone else's world
A guest receives a stream of snapshots that arrive late and jittered. Drawing each one the instant it lands produces stutter, because the gaps between arrivals are uneven. Two techniques smooth this.
Interpolation. The guest keeps a short buffer of recent snapshots and renders the world slightly in the past, a hundred milliseconds, say. With at least two snapshots straddling that render time, the guest interpolates between them and draws smooth motion. The cost is that the guest always sees a world that is 100 ms old. For pong that is invisible. For a twitch shooter it would not be.
// Guest side. Render ~100ms behind the newest snapshot.
const buffer = []; // [{ t, snap }, ...] by receive time
const DELAY = 0.1;
channel.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.t === 'state') buffer.push({ t: performance.now() / 1000, snap: msg.snap });
};
function render() {
const target = performance.now() / 1000 - DELAY;
const [a, b] = bracket(buffer, target); // two snapshots around `target`
if (a && b) {
const f = (target - a.t) / (b.t - a.t);
draw(lerp(a.snap, b.snap, f)); // smooth between known states
}
requestAnimationFrame(render);
}
Prediction. Interpolation handles other players smoothly but adds delay to your own movement, which feels sluggish. Prediction fixes the local player: the guest applies its own input immediately, then corrects when the host's authoritative snapshot arrives. If the prediction was right, nothing visible happens. If it was wrong, the guest reconciles, snapping or easing back to the host's value. This is the standard client-prediction-with-reconciliation approach; it is more machinery than pong needs, but it is the reason authoritative-host scales to faster games.
Cheat resistance
The authoritative-host pattern resists one class of cheating and is wide open to another.
It resists guest cheating. A guest sends only inputs. The host validates and simulates. A guest that sends "I scored 9999" achieves nothing, because the host does not take a score from a guest; it computes the score from simulating the guest's inputs. The host can ignore impossible inputs (a paddle moving faster than the rules allow) before applying them.
It does not resist host cheating. The host is the authority, so a malicious host can teleport, rewrite scores, or fabricate state, and guests have no second source to check against. There is no server to appeal to. This is intrinsic to peer-to-peer with no trusted third party.
In an authoritative-host game the host is trusted absolutely. A cheating host cannot be detected by guests, because there is no independent authority to compare against. Peer-to-peer is the right choice for friendly play between people who already trust each other. It is the wrong choice for ranked or money games: those need a dedicated server that no player controls.
Hidden-state
Some games depend on a player not knowing something. Battleship is the textbook case: each player places a fleet on a private grid, and the entire game is the slow discovery of where the other fleet is. The moment one peer can read the other's board, the game is over.
This forbids the authoritative-host pattern. The host cannot own both boards, because owning a board means holding it in memory, and the host is also a player who must not know the opponent's layout. So neither peer holds the full game state. Each peer owns only its own board.
What crosses the wire is not state. It is events: an action by one peer, and the result computed by the peer that owns the affected board.
// Each peer owns its own board. Neither ever sends its layout.
const myBoard = placeFleet(); // private: never serialized to the wire
// I fire at a cell on the opponent's board.
function fire(cell) {
send(channel, { t: 'fire', cell });
}
// The opponent fires at me. I am the only one who can resolve this,
// because I am the only peer that knows my own layout.
function onFire({ cell }) {
const ship = myBoard.at(cell);
const kind = !ship ? 'miss' : ship.isSunk(cell) ? 'sunk' : 'hit';
send(channel, {
t: 'result',
cell,
kind,
sunkCells: kind === 'sunk' ? ship.cells : null, // reveal only what's earned
});
}
// The result of my own shot comes back.
function onResult({ cell, kind, sunkCells }) {
markTracking(cell, kind); // update my view of THEIR board
if (kind === 'sunk') revealShip(sunkCells);
}
The asymmetry is the whole point. A fire says only where. The peer that owns the target board is the only one that can answer, and it answers with the minimum the rules require: hit, miss, or sunk; and on a sink, the cells of that one ship, because the rules say a sunk ship is revealed. Nothing else about the layout leaks. The other ships stay secret until they too are hit.
This pattern generalizes to any imperfect-information game: a hand of cards, a fog-of-war map, a hidden role. Hold private state locally, exchange only the events the rules entitle the other peer to learn, and let the owner of each piece of state be the only one who resolves actions against it.
Note that hidden-state does not need a host authority at all. Battleship is two peers exchanging events as equals. It still rides the star topology (guest connects to host), but the trust model is symmetric, because each peer is the sole authority over its own board.
Determinism and lockstep
A different problem appears the moment two peers must generate the same content independently. Worms drops players onto destructible terrain. Both peers must see the same terrain. The host could generate the terrain and broadcast every pixel, but that is a large message for something both machines could compute themselves, if only they computed it the same way.
They can, if the generation is deterministic. A deterministic function returns the same output for the same input every time. Feed both peers the same input and they produce identical terrain with no large transfer.
The input is a seed. The host picks one, broadcasts it, and both sides feed it to the same generator.
import { mulberry32, randomSeed } from '../fx/rng.js';
// HOST: pick one seed, broadcast it, build terrain from it.
const seed = randomSeed();
pool.broadcast({ t: 'seed', seed });
const terrain = buildTerrain(mulberry32(seed));
// GUEST: receive the seed, build the SAME terrain locally.
function onSeed({ seed }) {
const terrain = buildTerrain(mulberry32(seed)); // byte-for-byte identical
}
mulberry32 (in src/fx/rng.js) is a seeded PRNG. Given a seed it returns a function that yields the same sequence of numbers every call, on every machine. buildTerrain consumes that sequence (range, rangeInt, pick all draw from it), so two peers running the same seed walk the same path through the same number sequence and lay down the same terrain. One integer crosses the wire instead of a heightmap.
This is also the foundation of the lockstep model. In lockstep every peer runs the full simulation, and only inputs are exchanged; the simulation advances one step only once every peer's input for that step has arrived. Because the simulation is deterministic, every peer computes the identical next state from the identical inputs, with no state ever transmitted. Lockstep is how classic RTS games synchronized thousands of units over dial-up. It is bandwidth-cheap and exact. Its weakness is that one slow peer stalls everyone, and a single divergence (one peer computing one different number) desyncs the game silently.
The repo does not run a lockstep loop, but it uses lockstep's core trick: seeded determinism to agree on generated content without transmitting it.
The Math.random trap
Determinism is fragile. It breaks the instant any code on the reproducible path calls Math.random() instead of the seeded generator. Math.random() returns a different sequence on every machine and every run. Use it to place one tree, one spawn point, one crater offset, and the two peers diverge. The terrain looks plausible on each screen but is not the same terrain, and players who think they share a world do not.
The rule is narrow and absolute: anything two peers must reproduce identically draws from mulberry32(seed) and never from Math.random(). The one allowed use of Math.random() is randomSeed() itself (choosing the seed in the first place), because that value is then broadcast, so both peers use the same one regardless of where it came from. tests/fx/rng.test.js pins mulberry32's sequence so a refactor cannot silently change it.
The floating-point caveat
Seeded integers are exactly reproducible across machines. Floating-point arithmetic is not, in general. The IEEE-754 result of a single multiply is well defined, but a compiler or JS engine may contract a * b + c into a fused multiply-add with different rounding, reorder associative-looking operations, or evaluate a transcendental function (Math.sin, Math.pow) to a different last bit on a different platform. Over thousands of simulation steps those last-bit differences compound, and two peers running "the same" floating-point physics drift apart.
This is why true lockstep simulations often use fixed-point integer math for anything that must stay in sync. For this repo's games it is rarely a problem, because the deterministic path is mostly terrain generation (run once, from integer-friendly operations), not a long floating-point physics simulation run in parallel on both peers. The host runs the physics (authoritative-host) and broadcasts the result, so guests never run a parallel float simulation that could drift. Keep determinism for generation and seeding; let the host be authoritative for anything involving accumulated floating-point physics.
| Model | Crosses the wire | Trust | Bandwidth | Stall risk | Repo use |
|---|---|---|---|---|---|
| Authoritative-host | Inputs up, snapshots/events down | Host trusted absolutely | Medium: scales with state size | Host can't stall on guests | pong, worms |
| Hidden-state | Events only (action + result) | Symmetric; each peer owns its board | Low: a few bytes per action | None: turn-based | battleship |
| Lockstep | Inputs only | Symmetric; all peers simulate | Lowest: inputs only | One slow peer stalls all | not used as a loop |
Choosing a reliability mode per message class
Not every message wants the same delivery guarantee. The data channel lets you pick ordering and reliability per channel, and the right choice depends on what the message is. See Reliability and ordering for the mechanics; here is the mapping for multiplayer.
| Message class | Example | Mode | Why |
|---|---|---|---|
| Real-time inputs / state | paddle position, ball snapshot | unreliable, unordered (maxRetransmits: 0) |
A lost update is stale anyway; the next one supersedes it. Waiting to retransmit costs latency for data you'll throw away. |
| Discrete events | "shot fired", "crater at x,y", "seed=42" | reliable, ordered | Each event matters exactly once; losing one desyncs the game. Order matters when events build on each other. |
| Lifecycle / control | "game over", "your turn", hub propose/accept |
reliable, ordered | Rare, decisive, must not be dropped. |
| Chat | text between players | reliable, ordered | Not time-critical; users notice dropped or reordered messages. |
A practical setup runs two channels: one unreliable+unordered for the firehose of state, one reliable+ordered for events and control. A continuous-state game (pong) leans on the unreliable channel; a turn-based game (battleship) sends everything reliably because every message is a discrete event that must not be lost.
// Two channels, two guarantees.
const stateCh = pc.createDataChannel('state', {
ordered: false,
maxRetransmits: 0, // fire-and-forget snapshots
});
const eventCh = pc.createDataChannel('events'); // reliable + ordered (defaults)
Joining, leaving, and late-join sync
A star handles membership cleanly because the host is the hub.
Join. A new guest completes the handshake into a free slotIdx. The host detects it through onSlotState(slotIdx, 'connected'). A guest joining mid-match has none of the current state, so the host sends it a one-time full snapshot (the complete world, plus the terrain seed if the game uses one) on the reliable channel, before the guest starts applying incremental updates. The seed is what makes late-join cheap: the host sends one integer and the guest rebuilds the terrain locally instead of receiving a heightmap.
const pool = peerPool(2, {
onSlotState(slotIdx, state) {
if (state === 'connected') {
// Bring the late guest fully up to date, once, reliably.
pool.send(slotIdx, { t: 'sync', snap: fullSnapshot(world), seed });
}
if (state === 'disconnected' || state === 'failed') {
dropPlayer(slotIdx); // free the slot, remove their entity
}
},
});
Leave. A guest dropping fires onSlotState(slotIdx, 'disconnected'|'failed'). The host frees the slot and removes that player's entity from the simulation. Because guests never depend on each other, one guest leaving does not touch the others. pool.broadcast already skips closed channels, so no special handling is needed to stop sending to a departed guest.
Host leaving ends the match: the hub is gone and there is no one to promote without a re-handshake. Host migration (electing a new host and rebuilding the star) is possible but heavy; for short matches between a few friends, ending the game is the honest outcome.
The hub control namespace
Two peers connected over one data channel often want to switch games without tearing down WebRTC: from the lobby into pong, then back, then into battleship. The renegotiation cost of a fresh RTCPeerConnection each time would be wasteful, so the connection is reused and the game is swapped on top of it.
That means hub control messages and in-game messages share a channel. To keep them from colliding, the hub uses a different discriminator key. In-game messages key on type: or short t:. Hub control messages key on c::
// Hub control messages. Note the `c:` key, distinct from in-game `t:`/`type:`.
{ c: 'propose', game: 'pong' } // "want to play pong?"
{ c: 'accept', game: 'pong' } // "yes": both sides load pong
{ c: 'cancel' } // back out
src/hub/router.js multiplexes the channel: a message with c: goes to the hub, anything else goes to the active game's handler. The two namespaces never overlap, so the lobby and the game can talk over the same wire at the same time. When both sides reach accept, src/hub/loader.js brings the game module up and gameplay messages start flowing on the same channel.
Part 3: Recap and going further
Recap
- Consistency is the core problem: every peer must answer gameplay questions the same way, despite latency, jitter, and loss.
- The topology is a host-and-spokes star (
peer-pool.js, one connection per guest indexed byslotIdx), never a mesh. It gives one owner of truth, N−1 connections, and a clean authority boundary. - Authoritative-host (pong, worms): host simulates on a fixed tick and broadcasts; guests send inputs and render with interpolation and optional prediction. Resists guest cheating, not host cheating.
- Hidden-state (battleship): each peer owns a private board and exchanges only events; the owner of each board is the sole resolver of actions against it.
- Determinism: the host picks a seed via
randomSeed(), broadcasts it, and both sides runmulberry32(seed)to reproduce terrain. NeverMath.random()on a reproducible path. Watch floating-point drift in any parallel simulation. - Pick a reliability mode per message class: unreliable+unordered for the state firehose, reliable+ordered for events, control, and chat.
Going further
- Wire protocol: how messages are framed and dispatched on
type/t. - Reliability and ordering: the data channel guarantees behind the per-message-class table above.
- Data channels: creating, configuring, and managing the channels these patterns run on.
- Connection architecture: how the star's connections are established before any of this state ever flows.
Troubleshooting
- Peers see different worlds. Suspect a non-deterministic call on the reproducible path. Grep for
Math.random()and replace it with a draw from the broadcast seed. Confirm both peers received the same seed before generating. - Movement stutters on a guest. The guest is rendering raw snapshots. Add a snapshot buffer and interpolate ~100 ms behind the newest snapshot instead of drawing each one on arrival.
- Your own input feels laggy. Interpolation delays everything, including you. Add client-side prediction for the local player only, and reconcile against the host's authoritative snapshot.
- A guest leaves and the host keeps trying to message it. You shouldn't have to handle this:
pool.broadcastfilters onreadyState === 'open'. If you bypass it with rawchannel.send, guard the state yourself, or route through the pool. - Late joiner shows an empty or wrong world. It missed the initial state. Send a one-time full snapshot (and the seed) on
onSlotState(slotIdx, 'connected'), over the reliable channel, before incremental updates. - A hidden-state game leaks information. A
resultis revealing more than the rules allow, or a board is being serialized into a message. Send only the action and its minimal result; never put a private board on the wire. - Hub control message handled as a game message (or vice versa). Check the discriminator: hub uses
c:, games uset:/type:. A message with the wrong key reaches the wrong handler.