The long tail of app-realtime: the kit's host handoff, reload, and delivery rules in full, then driving a room with the raw Colyseus client instead of the @gipity/realtime kit.
Read app-realtime first, and prefer the kit. gipity add realtime wraps everything on this page - onStateChange diffing, tokens, reconnection, presence, lobby + match rooms - behind a tested, engine-agnostic API. Reach for the raw client only when you need something the kit does not expose, or to understand what it does internally.
Rooms must still be provisioned before any client can connect, and the realtime build loop still ends with a concurrent two-client check - both are covered in app-realtime.
Host handoff, reloads, and delivery (kit)
The kit rules app-realtime summarizes, in full.
Host handoff
Opt in per table with party.host({ handoff: true, graceSeconds }), or for every table of a room with host_handoff: true (and host_grace_seconds) in gipity.yaml. Players that may take over join with { canHost: true } (party.joinFromUrl, joinByCode, join, or rt.join*(name, { canHost: true })). The host calls table.setCheckpoint(obj), optionally table.setSuccessors([sid, ...]), and table.transferHost(sid) to hand over on purpose (e.g. before leaving). Worked file: examples/referee-handoff.js in the kit.
- The host is gone when its connection closed and did not come back, it sent nothing, or its page was hidden, for
graceSeconds(default the room'shost_grace_seconds, 5). A host page that reloads inside the grace keeps the role (reason: 'resumed', with its own checkpoint back). - Successor (server-side, deterministic): the first present player in
table.setSuccessors([sid, ...]), else the longest-connected player that joined with{ canHost: true }; hidden or disconnected players are skipped. Nobody eligible: the role waits forhost_hold_seconds, as without handoff. - One host at a time. Every change bumps
hostEpochand firesonHostChangeeverywhere (reason:'claimed'|'resumed'|'transfer'|'left'|'disconnected'|'grace-expired'|'hidden'|'vacated').t.isHost()andt.hostEpoch()are always current. What a page sends while it still believes it is host after losing the role (a phone waking up) is dropped by the server, and that page's room fires'undelivered'withreason: 'stale-host'; the current host's messages arrive withmsg.hostEpoch. - Checkpoint:
setCheckpoint(obj)keeps the latest (last write wins, at most 64 KB of JSON; bigger throws), stamped{ data, serverTs, hostEpoch }. Only the page that receives the role gets it (inonHostChange, ascheckpoint); everyone else seesnull, so it can hold seeds. It returnsfalse(nothing sent) when this page is not the host. - Leaving on purpose: a host that calls
table.leave()on a handoff table hands the role to its successor at once (reason: 'left'), with no grace wait. A reload or tab close never does this, so a reloading host keeps its grace.table.cancel()ends the table instead of handing it on. - The old host comes back as a normal player; it gets the role back only if the current host calls
table.transferHost(sid). - The table's invite link and code keep working after the handoff (the listing moves with the role).
- Visibility: the kit reports when a page is hidden; peers see
table.room.peerInfo(sid).visibleandtable.onPeerVisibility((sid, visible) => ...), so the host can tell a suspended player from a lagging one. - Boards the old host simulated for others (bots, controller-only phones) live in its checkpoint or not at all; players who run their own board keep running through the handoff.
Testing host handoff live
A clean leave is easy to test; a locked phone is the case that breaks games. gipity page test --at "<label|index>:<action>@<ms>" does it to one client partway through the --hold window (ms counted from that client's window start), while --observe watches the others:
gipity page test ".../app/?role={{label}}" --clients 3 --labels host,phone,phone --hold 15000 --at host:freeze@5000 --at host:wake@12000 --observe "window.game?.isHost?.() ?? null"
freezeis a locked phone: the page goes hidden, then its JS stops (no timers, no rendering, no message handlers), and its socket stays open, so the server sees no leave. The phones should getonHostChangeoncegraceSecondspasses.wakeresumes it; the browser then drops the socket it held across the freeze, so the kit reconnects and the old host comes back as a player. Its samples read(paused)while frozen.hide/show: a backgrounded tab (JS keeps running at about one timer tick per second);offline/online: a network drop that stalls the socket without closing it.phone:hide@3000hits every client labelledphone; an index (0:freeze@5000) hits one. Anything still applied at the end of--holdis restored, and each client'stimeline:line (andlifecyclein--json) shows when each step actually ran.
Seats (several players per computer)
A party table's capacity is counted in seats, not connections, so one computer can carry 1 to 4 couch players. Every join takes { seats: N } (1-8, default 1): party.host({ seats }), joinByCode(code, { seats }), joinFromUrl({ seats }), join(entry, { seats }), quickMatch({ seats }), and underneath rt.join*(name, { seats }). seats on createParty is the table's total, the host page's own seats included; party.host({ seats: 0 }) is a screen that only displays (a TV + 8 phones is seats: 8, or seats: 9 with the TV taking one). max_clients on match still caps connections, so it must admit the host page too.
- The server enforces it. The host creates the match room with
maxSeats(rt.create(name, { maxSeats }), 1-256; only the creator sets it). The server checks and claims seats in one step, so concurrent joins cannot oversubscribe: on an 8-car table with 2 seats left, a 3-player computer is refused'full'(message... is full: 3 seats requested, 2 of 8 free), a 2-player one fits. WithoutmaxSeatsthere is no seat cap. A seat-full room refuses joins rather than sharding, so use seats for tables joined by code, id or scope. - Held seats count. A dropped computer keeps all its seats for
seat_hold_seconds; a resume (same session) gets the same seats back; they free when it leaves for good. - Visible everywhere.
table.room.peerInfo(sid).seats(players on that computer),table.room.seats(){ used, total, mine }, and on the tableseats,seatsUsed(),seatsFree(),mySeats(). The listing carriesseatsandseatsUsed(the host page keeps it current), the'playing'/'open'flip andonFullcount seats, andquickMatch({ seats })skips tables without room. - Older kits (before 3.8) never send seats: each of their joins claims 1, exactly as before.
Rejoining and reloads
Rejoining a full table. When the table fills, its listing flips to 'playing' and joiners get 'full'; when a seat frees (a phone left, or a dropped phone's seat hold ran out) it flips back to 'open' by itself, so a phone that reloads can rejoin from the same invite link or code. If the app sets status itself with table.setListing({ status: 'playing' }) (e.g. to keep a running match closed), the kit stops reopening it. A phone whose page crashed (no clean leave) still has its seat held for seat_hold_seconds: opening the invite link again from the same browser takes that held seat back with its old session id, even though the table is full. Any other device waits for the hold to run out.
Reloads and crashes. A page that closes or reloads normally leaves cleanly: its seat frees at once and the reloaded page joins as a new session. A page that dies without a clean leave (browser crash, OS-killed tab) keeps its seat for the hold, and the next join of that room from the same browser (join, joinExisting, joinById; never create) resumes the held seat with the old session id and host role instead of being refused as 'full' by its own ghost. rt.resume(name, { scope, roomId }) does only that. A host reclaims its role after a clean reload whichever way it joined (join with a scope, joinExisting, or joinById).
Delivery guarantees during reconnect and handoff
Nothing is queued and nothing is replayed:
- While connected, messages are delivered once, in order per sender.
'host'is resolved when the message reaches the server: whoever holds the role then. - A broadcast sent while a peer is away (dropped, inside its seat hold) never reaches it. A send targeted only at an away peer comes back to the sender as
'undelivered'('no-recipient', or'no-host'forsendToHostwhile the host is away). - Messages a page sends while it is reconnecting are dropped by the kit.
- After
'reconnected'a page gets new messages only. Shared state does resync in full: the data map (store, server/sharedentities), the player list, and the current host (onHostChangefires if the role moved while the page was away). - Across a host handoff, messages that already reached the old host are not redelivered to the new one; the new host starts from the last checkpoint. Don't rely on the checkpoint alone: when a page becomes host, have it broadcast a resync request and have every player answer with its latest state. A player that already sent its last update (a finished board, a message sent in the gap between hosts) will not send again on its own, and the new host would keep a stale copy. What the old host sends under its old epoch is dropped (
'stale-host').
Quick start (raw client)
- Provision a room (see "Provisioning a room" in app-realtime) - e.g.:
gipity realtime room create game-lobby --type state --auth public --max-clients 50
- In your app's HTML, load the realtime client from CDN (every raw-client API is documented on this page - do not search the web for external docs). Pin the exact version - the state-room API below is written for
0.16.22:
<script src="https://unpkg.com/colyseus.js@0.16.22/dist/colyseus.js"></script>
- Connect to the room:
IMPORTANT: The token endpoint is on the API server, NOT the app host. You MUST use the absolute URL https://a.gipity.ai/api/token - never a relative path like /api/token. It is a POST request and the token is nested under data.
// Get app token - MUST be absolute URL to API server, POST with app GUID
const resp = await fetch('https://a.gipity.ai/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app: '<PROJECT_GUID>' })
});
const { data: { token } } = await resp.json();
// ✗ WRONG: fetch('/api/token') - relative URL hits app host, not API
// ✗ WRONG: fetch('https://a.gipity.ai/api/token') with GET - must be POST
// ✗ WRONG: const { token } = await ... - token is inside data: { data: { token } }
const client = new Colyseus.Client("wss://rt.gipity.ai");
const room = await client.joinOrCreate("state", {
app: "<PROJECT_GUID>",
room: "game-lobby",
token
});
Room Discovery (Lobby / Matchmaking)
The client library does NOT have a room listing function. To list rooms, use the REST endpoint:
// List available rooms (uses the app token from step 1)
const roomsResp = await fetch(
'https://rt.gipity.ai/rooms?room=game-lobby&token=' + encodeURIComponent(token)
);
const { rooms } = await roomsResp.json();
// rooms = [{ roomId, clients, maxClients, metadata }, ...]
Lobby Pattern - Join Existing or Create New
const roomsResp = await fetch(
'https://rt.gipity.ai/rooms?room=game-lobby&token=' + encodeURIComponent(token)
);
const { rooms } = await roomsResp.json();
// Find a room that isn't full
const available = rooms.find(r => r.clients < r.maxClients);
let room;
if (available) {
// Join existing room by ID
room = await client.joinById(available.roomId, {
app: "<PROJECT_GUID>", room: "game-lobby", token
});
} else {
// No room available - create a new one
room = await client.joinOrCreate("state", {
app: "<PROJECT_GUID>", room: "game-lobby", token
});
}
Note: The
roomquery param is optional - omit it to list all rooms for your app. Add&scope=<value>to list only one scope's instances. Never useclient.getAvailableRooms()- it does not exist in the client library. Raw join options also acceptscope(opaque instance partition key - same semantics as the kit'sscopeconfig, documented in app-realtime).
Relay Room Patterns
// Send a typed message
room.send("chat", { user: "Alice", text: "Hello!" });
room.send("move", { x: 10, y: 20 });
// Receive messages by type
room.onMessage("chat", (msg) => {
console.log(msg.user + ": " + msg.text);
});
room.onMessage("move", (msg) => {
movePlayer(msg.senderId, msg.x, msg.y); // senderId/senderName/serverTs are stamped by the server
});
A relay room also sends __players (the roster, to a newcomer), __player_joined and __player_left. Register room.onMessage("*", ...) if you don't handle them, or colyseus.js logs a warning per message.
State Room Patterns
IMPORTANT - how to read state-room state. The Colyseus client (colyseus.js@0.16.22) does not expose .onAdd() / .onChange() / .onRemove() callbacks on the players and data maps - those were removed after 0.14, and calling them throws TypeError: ... is not a function. Instead, react to state inside room.onStateChange - it fires with the full state on every server update - and diff it against what you have already seen. The maps are also undefined on a fresh room, so always guard before reading them.
The canonical diff loop for both maps lives in "State room - safe initialization boilerplate" at the bottom of this page - copy it from there rather than re-deriving it. The sections here cover the messages you send and the APIs that do not exist.
Players (auto-tracked)
The server tracks joined players in room.state.players (each record carries displayName). Detect joins and leaves by diffing that map inside room.onStateChange (see the boilerplate); set custom per-player fields with a message:
// Set custom player data (e.g. score, position)
room.send("set_player_data", { data: JSON.stringify({ score: 100 }) });
// ✗ WRONG - .onAdd is not a function in colyseus.js 0.16:
// state.players.onAdd((player, sid) => { ... })
Shared Data (key-value, auto-synced)
Any client can set a key and every client receives it. Values are JSON strings - compare them raw to spot a change, then parse. Detect changes and deletions by diffing room.state.data (see the boilerplate):
// Set shared data (any client can set, all clients receive)
room.send("set_data", { key: "gameState", value: JSON.stringify({ round: 1, phase: "playing" }) });
room.send("delete_data", { key: "oldKey" });
// ✗ WRONG - .onChange is not a function in colyseus.js 0.16:
// state.data.onChange((value, key) => { ... })
Custom Messages (both room types)
// Any unrecognized message type goes to all OTHER clients...
room.send("explosion", { x: 50, y: 30, radius: 10 });
room.onMessage("explosion", (data) => renderExplosion(data)); // data.senderId, data.serverTs
// ...or only to chosen ones: a session id, a list, or the room's host.
room.send("buzz", { __to: someSessionId });
room.send("press", { key: "left", __to: "host" });
// Built-in ping for RTT and clock offset (answered to the sender only).
room.send("__ping", { t: Date.now() });
room.onMessage("__pong", ({ t, serverTs }) => {
const rtt = Date.now() - t;
const offset = serverTs - (t + rtt / 2); // server clock minus local clock
});
senderId and serverTs are set by the server and overwrite anything the client sent; messages the current host sends with its epoch (__hostEpoch, see host handoff below; the kit always adds it) also carry a server-set hostEpoch. __to is stripped before delivery. A send that reaches nobody, or is refused, is answered to the sender only with __undelivered { type, to, reason }: reason is 'no-host' for __to: "host" while no host is connected, 'no-recipient' when none of the listed session ids is in the room, 'stale-host' for a message sent under a host epoch the sender no longer holds, 'not-host' for a host-only request from a non-host, 'too-large' for a checkpoint over 64 KB. Broadcasts and delivered messages get no reply. Nothing is queued for a client that is away (in its seat hold) or replayed when it reconnects.
Seats. Join option seats (1-8, default 1; 0 only with host: true) is how many players the connection carries; the creator's maxSeats (1-256) caps the instance's total. A join that does not fit is refused with Room '<name>' is full: N seats requested, F of M free. State rooms sync state.players[sid].seats, state.seatsUsed and state.maxSeats (0 = no cap); relay rooms put seats on each __players / __player_joined entry and seatsUsed (and maxSeats in __players) on the membership messages.
Host role. The host is a role held by one client, and that client takes a connection (and a seat, unless it joins with seats: 0) like any other (size max_clients as players + 1). Join with { host: true } to claim the room's host role when it is free; the server replies privately with __host_key { key }, and a later join with { host: true, hostKey } (e.g. after a page reload) takes the role back. Everyone receives __host { hostId, hostEpoch, previousHostId, reason } on join, on every change, and after a reconnect; state rooms also sync state.hostId and state.hostEpoch. hostEpoch goes up by one on every change. Optional join option clientId (a stable per-device id, up to 64 chars) is synced as state.players[sid].clientId.
Host handoff (raw protocol). Opt in with the join options { host: true, handoff: true, graceSeconds } (or the room's host_handoff: true); players that may take over join with { canHost: true }. The host must send something at least every heartbeatMs (given to it in its __host message; a __ping will do), or after the grace the server moves the role: to the first present id of the host's __successors { ids }, else to the longest-connected canHost client. Host-only requests: __checkpoint { data } (at most 64 KB of JSON; the new host receives it as checkpoint { data, serverTs, hostEpoch } in its own __host message, no one else ever does), __successors { ids }, __transfer_host { to }. Add __hostEpoch to what a host sends: the server delivers it stamped hostEpoch, and drops it once that epoch is over. Send __visibility { visible } when the page is hidden or shown; state rooms sync it as state.players[sid].visible, relay rooms broadcast __visibility { sessionId, visible }. The kit does all of this for you.
Limits: 10,000 characters per message or data value (a host checkpoint may be 64 KB), 120 messages/s per client (bursts to 240; excess is dropped, a sustained flood is disconnected), 1,000 data keys and 2 MB of data per room.
Turn-Based Game Pattern
Use a state room with a currentTurn key:
// Host sets initial turn
room.send("set_data", { key: "currentTurn", value: JSON.stringify(room.sessionId) });
room.send("set_data", { key: "board", value: JSON.stringify(Array(9).fill(null)) });
// On each move, update board + advance turn
function makeMove(index) {
const board = JSON.parse(room.state.data.get("board"));
board[index] = mySymbol;
room.send("set_data", { key: "board", value: JSON.stringify(board) });
room.send("set_data", { key: "currentTurn", value: JSON.stringify(opponentSessionId) });
}
// React to board/turn changes by diffing data on every update
const seen = new Map();
room.onStateChange((state) => {
if (!state.data) return;
state.data.forEach((value, key) => {
if (seen.get(key) === value) return; // unchanged
seen.set(key, value);
if (key === "board") renderBoard(JSON.parse(value));
if (key === "currentTurn") updateTurnIndicator(JSON.parse(value));
});
});
State room - safe initialization boilerplate
The canonical diff loop. Copy-paste this as your starting point for any state room app - it covers player join/leave and shared-data change/delete in one onStateChange handler:
// 1. Get token
const resp = await fetch('https://a.gipity.ai/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app: '<PROJECT_GUID>' })
});
const { data: { token } } = await resp.json();
// 2. Connect (or use Room Discovery to join an existing room - see above)
const client = new Colyseus.Client("wss://rt.gipity.ai"); // from CDN loaded above
const room = await client.joinOrCreate("state", {
app: "<PROJECT_GUID>",
room: "my-room",
token
});
// 3. React to state by diffing it. onStateChange fires with the full state
// on every server update; colyseus.js 0.16 has NO .onAdd/.onChange
// callbacks on the maps - diff against what you have seen instead.
const knownPlayers = new Set();
const dataSeen = new Map(); // key -> last raw JSON string
room.onStateChange((state) => {
// Players
if (state.players) {
const present = new Set();
state.players.forEach((player, sessionId) => {
present.add(sessionId);
if (!knownPlayers.has(sessionId)) {
knownPlayers.add(sessionId);
// Handle player join
}
});
for (const sessionId of [...knownPlayers]) {
if (!present.has(sessionId)) {
knownPlayers.delete(sessionId);
// Handle player leave
}
}
}
// Shared data - values are JSON strings
if (state.data) {
state.data.forEach((value, key) => {
if (dataSeen.get(key) !== value) {
dataSeen.set(key, value);
const parsed = JSON.parse(value);
// Handle data change
}
});
for (const key of [...dataSeen.keys()]) {
if (!state.data.has(key)) {
dataSeen.delete(key);
// Handle data delete
}
}
}
});
// 4. Send messages
room.send("set_data", { key: "myKey", value: JSON.stringify({ foo: "bar" }) });
// 5. Cleanup on leave
room.onLeave((code) => {
console.log("Left room, code:", code);
});
Related
- app-realtime - the main guide: the
@gipity/realtimekit, party/lobby flows, presence, provisioning, and how to verify multiplayer across concurrent clients - realtime-scheduled-app - end-to-end recipe combining presence + messages with a database and a scheduled poster