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:

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:

Quick Start

  1. 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
  1. 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>
  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. Never use client.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

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:

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