The long tail of app-realtime: 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.
Quick start (raw client)
- Provision a room (see "Provisioning a room" in app-realtime) - e.g. the agent tool:
realtime_room action=create name=game-lobby room_type=state auth_level=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.x, msg.y);
});
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 (broadcast to all)
// Any unrecognized message type is broadcast to all other clients
room.send("explosion", { x: 50, y: 30, radius: 10 });
room.onMessage("explosion", (data) => renderExplosion(data));
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