Gipity apps get WebSocket-powered rooms for multiplayer games, chat, collaborative apps, and live dashboards. A room must be provisioned for the project before clients can connect - see "Provisioning a room" below.
Most apps should build on the @gipity/realtime kit (gipity add realtime) rather than the raw client documented further down - see "The realtime kit" below. The raw Colyseus patterns are kept as a reference and a fallback.
Room Types
Relay Room
Pure message broker - clients send typed messages, all others receive. No server state. Good for: chat, notifications, signaling, real-time feeds, simple multiplayer.
State Room
Server-authoritative shared state. Auto-tracks players in a synced map. Generic key-value data map (values are JSON strings) auto-synced to all clients.
Good for: games, collaborative editors, dashboards, turn-based games, anything needing shared truth.
The realtime kit (start here)
For anything beyond a toy, do not hand-roll the Colyseus client - run gipity add realtime and build on the @gipity/realtime kit. It wraps everything in this doc (onStateChange diffing, tokens, reconnection, lobby + match rooms) behind a tested, engine-agnostic API. The raw Colyseus patterns below are the fallback and a reference for what the kit does internally.
Channels - one room, namespaced sub-streams. rt.channel(name, { sync }) where sync is:
messages- pub/sub relay.presence- ephemeral per-peer state (cursors, positions) at ~20 Hz.entities- per-record CRUD;authority: 'shared'(last-write-wins) or'host'(elected writer + physics delta-sync).store- synchronous whole-object key-value (get/set/update/onChange) - the shape a turn-based game or match state wants.authority: 'host'optional.
Multi-room (lobby games) - one client, many rooms:
import { createRealtime, createDirectory } from '@gipity/realtime';
const rt = createRealtime();
const lobby = await rt.join('lobby'); // shared directory room
const match = await rt.create('match'); // a fresh match instance
const other = await rt.joinById(roomId, 'match'); // join an advertised one
createDirectory(lobby) turns the lobby into a heartbeat'd listing of open rooms.
Reading state right after a join - rt.joinById(...) resolves on join, before the room's state has synced. channel.get(key) will return undefined until the first sync lands. If you need to read state immediately on join (e.g. a lobby joiner inspecting the host's match state), await new Promise((r) => channel.onReady(r)) first. Otherwise rely on channel.onChange to drive your UI.
Reconnection is automatic - an unclean drop is recovered via the Colyseus reconnection token with the session id preserved (channels and seats survive a blip). Observe it with rt.on('reconnecting') / 'reconnected' / 'lost'.
Worked references ship inside the kit: examples/ has one file per shape (chat, whiteboard, kanban, city-builder, agent-ops, desktop, lobby, connect-four) plus README.md. Room names still need provisioning - see below.
Provisioning a room
A room must exist before an app can connect. There are three equivalent ways - all create the same room record, so pick whichever fits the workflow:
- Declarative (best for deployed apps) - declare it in
gipity.yamlas arealtimedeploy phase.gipity deployreconciles it (creates if missing, no-op if it exists) - reproducible, no separate step. The3d-world/3d-enginetemplates already ship this.deploy: phases: - name: realtime type: realtime rooms: - name: game-lobby room_type: state auth_level: public - CLI -
gipity realtime room create game-lobby --type state --auth public(alsolist,info,delete). Deterministic and scriptable - good for CI. The same command exists in the web CLI as/realtime room .... - Agent tool -
realtime_room action=create name=game-lobby room_type=state auth_level=public. Use when working inside a chat turn.
Quick Start
- Provision a room (see "Provisioning a room" above) - 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 (all API patterns are documented below - 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. Never useclient.getAvailableRooms()- it does not exist in the client library.
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.
Players (auto-tracked)
// Detect joins/leaves by diffing room.state.players on every update.
const knownPlayers = new Set();
room.onStateChange((state) => {
if (!state.players) return; // undefined on a fresh room
const present = new Set();
state.players.forEach((player, sessionId) => {
present.add(sessionId);
if (knownPlayers.has(sessionId)) return;
knownPlayers.add(sessionId);
console.log("Player joined:", player.displayName);
});
for (const sessionId of [...knownPlayers]) {
if (present.has(sessionId)) continue;
knownPlayers.delete(sessionId);
console.log("Player left:", sessionId);
}
});
// ✗ WRONG - .onAdd is not a function in colyseus.js 0.16:
// state.players.onAdd((player, sid) => { ... })
// Set custom player data (e.g. score, position)
room.send("set_player_data", { data: JSON.stringify({ score: 100 }) });
Shared Data (key-value, auto-synced)
// 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" });
// Detect changes by diffing room.state.data on every update. Values are JSON
// strings - compare them raw to spot a change, then parse.
const dataSeen = new Map(); // key -> last raw JSON string
room.onStateChange((state) => {
if (!state.data) return; // undefined on a fresh room
state.data.forEach((value, key) => {
if (dataSeen.get(key) === value) return; // unchanged
dataSeen.set(key, value);
console.log(key, "changed to:", JSON.parse(value));
});
for (const key of [...dataSeen.keys()]) {
if (state.data.has(key)) continue;
dataSeen.delete(key);
console.log(key, "deleted");
}
});
// ✗ 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
Copy-paste this as your starting point for any state room app:
// 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();
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
}
});
}
});
// 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);
});
Auth
- public: Pass app token in join options - no login needed
- user: Requires Gipity session cookie (Sign in with Gipity)
URL-param test mode (highly recommended for multiplayer)
A click-driven multi-client test (two browsers, host on one, join from the other) is real work to write and slow to run. A small URL-param test mode in the app turns it into two passive page loads:
?test-name=Alice- auto-fills the player name on load.?test-action=host- once on the lobby, auto-clicks Host (or your equivalent).?test-action=join- once on the lobby, auto-joins the first open game.?test-action=join&room=<id>- joins a specific room.
With those, verification becomes two passive gipity page-inspect calls (the existing multi-test helper covers it):
gipity page-inspect ".../app/?test-name=Alice&test-action=host" --wait 8000 --json
gipity page-inspect ".../app/?test-name=Bob&test-action=join" --wait 10000 --json
No Puppeteer, no Chromium libs, no DOM driving. Implement it once per multiplayer app and every realtime change is a 30-second smoke test from there on. Pair it with the data-testid / data-screen / data-ready conventions from web-app-basics for any leftover click-driven tests.
Tips
- The
@gipity/realtimekit (gipity add realtime) covers lobby + match rooms, astorechannel for whole-object state, presence, host election, and automatic reconnection - prefer it over hand-rolling any of the above - Split state into many small keys, not one big JSON blob (each key change re-syncs the entire value)
- Room config changes apply to new instances only - existing connections are unaffected
- Room instances have a max client limit (see
realtime_room infofor current limits); the server auto-creates new instances when rooms fill up - Use relay rooms for simple message passing; use state rooms when you need server-authoritative truth
- When editing connection code, read the entire connection function before making changes - partial edits that fix one issue while missing a related one waste a full deploy cycle