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.

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"

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.

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:

Quick start (raw client)

  1. 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
  1. 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>
  1. 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 room query param is optional - omit it to list all rooms for your app. Add &scope=<value> to list only one scope's instances. Never use client.getAvailableRooms() - it does not exist in the client library. Raw join options also accept scope (opaque instance partition key - same semantics as the kit's scope config, 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