04 GUIDE · MULTIPLAYER
Build your own
room server.
We run this in our own games. It isn't a hosted service; here's how to build one like it. @fzlt/core/presence and @fzlt/core/presence/check are in-house / experimental: used in our games, not a supported multiplayer service. Our room server is private, so this guide gives you the contract instead: what presence speaks, the full wire protocol, the pattern we built ours on, and the threat checklist we hold it to. A server that keeps this contract works with presence, and with any page that already speaks it.
The contract in numbers
- 2,197 B
@fzlt/core/presence, gzip, measured on this build - 12 B
every message, both ways
- 8
message kinds; clients send 3 of them
- 1
subprotocol:
fizlet-rooms
1. What presence speaks
- Lockstep. Every peer runs the same fixed-step sim from the same seed, which the room hands out. Only inputs cross the wire. The room never runs the game.
- Inputs per tick. Each step, presence sends one
INPUTfor framef + delay(default 2: 33 ms at 60 Hz) carrying the player's 32-bit input word. A frame runs only once every seat's input for it is in. A missing input stalls the sim (net.stalls); it never guesses. - The hash check. Every
hashEveryframes (default 30) it sends aHASHof the sim state.net.desyncis the first frame whose hashes differed, or -1. - First write wins. It keeps the first
INPUTand the firstHASHit receives per seat and frame, and counts the rest innet.dropped.LEAVEsets the seat's bit innet.lost;AWAYsets it innet.awayuntil that seat'sRESUME, and on anyRESUMEpresence resends its own inputs for the frames around the current one. - Joining.
join(url, signal, protocols, { retry, lobby })opens the WebSocket and resolves atWELCOMEwith{ transport, slot, players, seed, mode }. Before that,lobby(start, seat, seated, size)runs on eachLOBBY, and the host'sstart(seats, mode)sendsSTART. After it, a drop (1006, or a server restart:1011,1012,1014) reconnects on its own withresume.<token>next to yourprotocols(retrytries, from 250 ms and doubling: about 16 s by default); meanwhile your own seat is innet.away, and when the tries run out it is innet.lost. The token stays in memory, never in a URL. Any other close ends the match. The npm release v0.21.2 handles kinds 1 to 4 only (the lobby and resume came after it): there, a page that wants them handles those kinds itself, and presence counts them innet.dropped.
// wss://<your server>/room/0?players=2. The access code rides in the subprotocols, never the URL.
const { transport, slot, players, seed } = await join(`${SERVER}/room/0?players=2`, undefined, ["fizlet-rooms", `invite.${code}`]);
const e = createEngine({ seed }), keys = controls(e); // WELCOME: your seat, the count, one seed
const bytes = new Uint8Array(state.buffer); // the sim's state, as bytes
const net = presence(e, {
transport, slot, players, delay: 2, // INPUT for frame f + 2, once a frame
input: () => (keys.down("left") ? 1 : 0) | (keys.down("right") ? 2 : 0) | (keys.down("fire") ? 4 : 0),
step: (_e, inputs, frame) => sim(inputs, frame), // runs once every seat's INPUT is in
hash: () => hash32(bytes), // HASH every 30 frames
});
e.start();
// net.stalls (waits), net.desync (-1 while in sync), net.lost (seats the room said LEAVE)2. The wire format
Binary WebSocket messages of exactly 12 bytes, integers little-endian. Anything else (a text frame, any other size) is a protocol error: close the socket with 1009.
- Byte 0,
kind: one of the eight below. - Byte 1,
slot: a seat,0toplayers - 1. The room overwrites it with the sender's real seat before relaying. - Byte 2,
players: the number of seats in the match (inLOBBY, the room's size; inSTART, the count to start at). - Byte 3,
mode: the host'sSTARTbyte inWELCOME, the seated count inLOBBY, else 0. - Bytes 4 to 7, u32: the frame (
INPUT,HASH) or the seed (WELCOME). - Bytes 8 to 11, u32: the input word (
INPUT), the state hash (HASH) or the resume token (WELCOME).
3. The message kinds
Clients send only INPUT, HASH and, the host before the start, START. Every other kind comes from the room.
INPUT· 1 · a seat → every other seatu32 frame, u32 input word. Relay only after the start, and only the first one per seat and frame: each seat's frames must rise. Stamp byte 1 with the sender's seat.
HASH· 2 · a seat → every other seatu32 frame, u32 state hash. The same rules as
INPUT, counted apart from it.WELCOME· 3 · room → each seat, at the startslotis your seat,playersthe match's count,modethe host'sSTARTbyte (else 0), then the u32 seed (one per room, the same for everyone) and the u32 resume token (random, never 0, sent only to that seat). Sent again to a seat that resumes.LEAVE· 4 · room → the others, after the startslotleft for good: a clean close, a kick, or its resume hold ran out. Lockstep then waits on that seat, so the game decides what happens: a bot in its place, or the end of the match.RESUME· 5 · room → everyoneslotis back in its seat. Every peer resends its own inputs for its last few frames; first write wins drops the repeats.AWAY· 6 · room → the othersslotdropped and its seat is held for a while: show "reconnecting". Lockstep waits meanwhile.START· 7 · the host (seat 0) → room, before the startByte 2: start once this many are seated (0: now). Byte 3: any byte the game picks, such as its mode or team layout. Never relayed; a
STARTfrom another seat, or after the start, is ignored.LOBBY· 8 · room → every waiting seatOn each join and each leave before the start:
slotis your seat,playersthe room's size, byte 3 how many are seated. Seat 0 is the host, so only it shows a start key.
4. Joining: the handshake and the access check
- The URL.
GET /room/<n>?players=<p>as a WebSocket upgrade: one numbered room pern, opened forpseats. Refuse annyou don't run (404), a request that isn't an upgrade (426) and apoutside 1 to your seat cap (400). - The subprotocols. The client offers
fizlet-roomsplus its access token:invite.<code>, orpass.<pass>anddevice.<key>(16 to 64 base64url characters, made once per device). Answerfizlet-roomsin the101: a browser that offered subprotocols fails the handshake when the server picks none. Tokens never go in the URL, because URLs land in logs. - Refused before a socket.
403with no valid access or from an origin you don't allow,429withretry-afterover a rate,409when the room is already open for a different player count. - Closed after it.
4000 room full(full, or already playing),1008over the message rate,1009for anything but a 12-byte binary message,1001withroom not filled,room idleorroom time is up. - The sign-in check.
POST /invitewith a text body: the code, or the pass and the device key on two lines. A text body keeps it a simple request, with no CORS preflight. Answer200 {"ok":true,"players":N}, whereNis the most seats a room takes so a page offers only sizes that can join, or403 {"ok":false}. With passes, add"pass":trueto every answer and awhyto a refusal:pass expired,pass revoked,pass in use on another deviceorpass not valid. The check takes no seat. Read the body with a limit.
5. The lobby: start before the room is full
Open the room with the most seats the mode can take (?players=4). The first to join is seat 0, the host. Every waiting seat hears LOBBY on each join and leave. The host's START begins the match once n seats are taken (0: now), with the seats taken, renumbered 0 to k - 1 in seat order; WELCOME then says players = k and carries the host's mode byte. The game fills the empty places with bots, which every peer simulates the same way from the seed. A room without START starts when it is full, so a client should handle both.
6. Resume: a dropped seat comes back
A drop is a close with no clean code (1006, or a server restart: 1011, 1012, 1014) or a socket error. Hold the seat for a while (our default: 20 s) and tell the others AWAY. A clean close (1000, 1001, no code, 3000 to 4999) or a kick is a leave: LEAVE at once.
To come back, the client opens the same room with one more subprotocol, resume.<token in hex>, next to its access token. Answer WELCOME (the same seat and token), then RESUME for that seat, then the other seats' recent INPUTs (we keep the last 64 per seat); the others hear RESUME. If the old socket still sits in the seat, close it 4001 resumed and send no LEAVE. A wrong or expired token gets the socket closed 4002 seat gone: a resume never takes a fresh seat.
export const INPUT = 1, HASH = 2, WELCOME = 3, LEAVE = 4, RESUME = 5, AWAY = 6, START = 7, LOBBY = 8;
type Peer = { send(data: Uint8Array): void };
/** 12 bytes, little-endian: kind, slot, players, mode, u32 frame (or seed), u32 value (or token). */
export function msg(kind: number, slot: number, players: number, frame: number, value: number, mode = 0) {
const b = new Uint8Array(12), v = new DataView(b.buffer);
b[0] = kind, b[1] = slot, b[2] = players, b[3] = mode;
v.setUint32(4, frame >>> 0, true);
v.setUint32(8, value >>> 0, true);
return b;
}
export class Room {
started = false;
want = 0; // START: begin once this many are seated (0: when full)
mode = 0; // START: the host's byte, sent back in WELCOME
seats: (Peer | null)[] = [];
last = new Float64Array(0); // the last INPUT and HASH frame relayed per seat
tokens = new Uint32Array(0); // resume tokens, drawn at the start
constructor(public players: number, readonly seed: number) {
this.size(players, new Array<Peer | null>(players).fill(null));
}
size(players: number, seats: (Peer | null)[]) {
this.players = players;
this.seats = seats;
this.last = new Float64Array(players * 2).fill(-1);
this.tokens = new Uint32Array(players);
}
/** A new socket: its seat, or -1 (close it 4000 "room full"). */
join(peer: Peer) {
const slot = this.started ? -1 : this.seats.indexOf(null);
if (slot < 0) return -1;
this.seats[slot] = peer;
this.ready();
this.lobby();
return slot;
}
/** Before the start, every waiting seat hears LOBBY: its seat, the room size, how many are seated. */
lobby() {
const n = this.seats.filter(Boolean).length;
if (!this.started) this.seats.forEach((p, s) => p?.send(msg(LOBBY, s, this.players, 0, 0, n)));
}
/** Start when full, or once START's count is seated: with the seats taken, renumbered 0..k-1. */
ready() {
const taken = this.seats.filter((p): p is Peer => p !== null);
if (this.started || !(taken.length === this.players || (this.want && taken.length >= this.want))) return;
if (taken.length < this.players) this.size(taken.length, taken);
this.started = true;
crypto.getRandomValues(this.tokens).forEach((t, s) => (this.tokens[s] = t || 1));
this.seats.forEach((p, s) => p!.send(msg(WELCOME, s, this.players, this.seed, this.tokens[s]!, this.mode)));
}
/** A message from the socket in `slot` (already checked: binary, 12 bytes). True when relayed. */
message(slot: number, data: Uint8Array) {
const b = Uint8Array.from(data);
if (!this.started) {
if (b[0] === START && slot === 0) (this.want = Math.max(1, b[2]!)), (this.mode = b[3]!), this.ready();
return false; // START is taken here, never relayed
}
if (b[0] !== INPUT && b[0] !== HASH) return false; // only these two are relayed
const k = slot * 2 + b[0] - 1, f = (b[4]! | (b[5]! << 8) | (b[6]! << 16) | (b[7]! << 24)) >>> 0;
if (f <= this.last[k]!) return false; // first write wins: each seat's frames must rise
this.last[k] = f;
b[1] = slot; // the sender's real seat: nobody speaks for another player
this.seats.forEach((p, s) => s !== slot && p?.send(b));
return true;
}
/** A seat left for good: a clean close, a kick, or (with resume) its hold ran out. */
leave(slot: number) {
if (!this.seats[slot]) return;
this.seats[slot] = null;
this.tokens[slot] = 0;
this.last[slot * 2] = this.last[slot * 2 + 1] = -1;
if (this.started) this.seats.forEach((p) => p?.send(msg(LEAVE, slot, this.players, 0, 0)));
if (this.seats.some(Boolean)) this.lobby();
else this.started = false; // empty: make a fresh Room for the next join
}
}7. The pattern: one room, one stateful object
- A stateless front, then one object per room. The front checks the origin, the rate and the access token, then hands
/room/<n>to the one object named for that room: on Cloudflare, a Durable Object; elsewhere, one single-threaded process or actor per room. Every message for a room passes through one place, in order, so seating, the start and first write wins need no locks. - It seats, seeds and relays. The room never runs the sim, and reads nothing from an input but its frame.
- Idle costs nothing. We use WebSocket hibernation, so an idle room holds no running object. What a room must remember across a sleep (the seats, the tokens, held seats, the time of the last relayed message) rides in each socket's attachment, not in storage. A seat that leaves or drops clears its attachment, so a woken room never seats a ghost.
- One alarm per room for every deadline: the lobby, idle, the room's lifetime and the end of each resume hold.
- Measured locally, through
wrangler dev: two Fizlet engines play 600 lockstep frames through one room, byte-identical.
8. The threat checklist
What we hold our own server to. Each line is a check for yours.
The gate comes before the room
Check access before any room object is touched. Store codes hashed (we keep SHA-256 of the trimmed, upper-cased code), never the codes. Ours are 10 to 64 characters.
Tokens stay out of URLs
Access codes, passes and resume tokens travel in subprotocols or a POST body. Keep request-URL logging off anyway, and log nothing your code reads from a client.
Rate limits before storage
Count access checks and joins per IP before the lookup, refused with
429, and joins per IP into each room, counted by the room before it accepts a socket. Know whether your counter is per process or global.Hard caps in code
Every cap gets a ceiling that config can lower but never lift: rooms, seats, message rate and burst, message size, the lobby, idle and lifetime timers. Your worst case is rooms × seats × rate messages a second, each relayed to the other seats.
Nobody speaks for anyone else
Stamp byte 1 with the sender's seat. Relay only
INPUTandHASH;WELCOME,LEAVE,RESUME,AWAYandLOBBYcome from the room. TakeSTARTfrom seat 0 only, before the start.First write wins
Pass on only a seat's first
INPUT, and firstHASH, per frame: each seat's frames must rise. presence keeps the first it receives too, so a peer can't split the others' sims.Resume tokens are seats
32 random bits per seat, drawn at the start, sent only to that seat, offered back as a subprotocol, behind the gate and the join rate. A wrong token is closed
4002and never takes a fresh seat. Tokens die with the hold or a leave.Bounded bodies
Read request bodies with a limit, chunked or not, and stop reading past it.
Origins stop browsers only
An origin allowlist keeps other pages' scripts out. Any other client can send any
Origin: access is the gate.Passes, if you hand out play time
Sign them (we use HMAC-SHA256 over the payload, checked in constant time), cap their lifetime, lock each to the first devices that use it, and keep a revoke list. With passes required and no signing secret set, refuse every join: never fall back to open.
What the checklist does not protect
- Requests are billed before your code runs. Rate limits bound what a client makes you do, not the requests themselves: put billing alerts and your platform's DDoS or WAF rules in front.
- An access code is a shared secret. Hand out one per person, so one can be revoked.
- A malicious peer can still stall the match (lockstep waits), play its own seat however it likes, and report a false hash, which shows as a desync on the honest peers. It can't move another player, run a frame with two different inputs or send room messages.
- Room numbers are guessable: anyone with access can take an open seat.
- A held seat stalls the others for the hold. After
LEAVEthey still wait, unless the game puts a bot in the seat. - Matches are not private from the server: it sees every input, and presence trusts it.
- A room that sleeps forgets which frames it relayed and its recent inputs. presence's own first write wins still holds, and the peers' resends on
RESUMEcarry the inputs. - Read IP addresses for counting only: keep them in memory for a minute's window, never store or log them.
Questions
Can I use your room server?
No. It runs our games and isn't a hosted service, and its code is private now; its earlier history stays in this repository's git log. This guide is the contract, so a server you write works with presence.
Does a game need its own server code?
No. The room seats, seeds and relays. The game runs in every browser, the same on each.
Will the protocol change?
It is what our server and presence speak today. presence is BETA: a minor release may change it, and the CHANGELOG says how.