{
  "name": "app-realtime",
  "title": "Real-Time Multiplayer",
  "description": "Real-time multiplayer: rooms, lobbies, WebSocket connections, shared state, and message relay",
  "guid": "sk_plat_rtmp",
  "category": "App services",
  "requiredTools": [
    "realtime_room"
  ],
  "content": "# Real-Time Multiplayer\n\nGipity 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.\n\n**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.\n\n## Room Types\n\n### Relay Room\nPure message broker - clients send typed messages, all others receive. No server state.\n**Good for:** chat, notifications, signaling, real-time feeds, simple multiplayer.\n\n### State Room\nServer-authoritative shared state. Auto-tracks players in a synced map. Generic key-value `data` map (values are JSON strings) auto-synced to all clients.\n**Good for:** games, collaborative editors, dashboards, turn-based games, anything needing shared truth.\n\n## The realtime kit (start here)\n\nFor 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.\n\n**Channels** - one room, namespaced sub-streams. `rt.channel(name, { sync })` where `sync` is:\n- `messages` - pub/sub relay.\n- `presence` - ephemeral per-peer state (cursors, positions) at ~20 Hz.\n- `entities` - per-record CRUD; `authority: 'shared'` (last-write-wins) or `'host'` (elected writer + physics delta-sync).\n- `store` - synchronous whole-object key-value (`get` / `set` / `update` / `onChange`) - the shape a turn-based game or match state wants. `authority: 'host'` optional.\n\n**Multi-room (lobby games)** - one client, many rooms:\n\n```js\nimport { createRealtime, createDirectory } from '@gipity/realtime';\nconst rt = createRealtime();\nconst lobby = await rt.join('lobby');             // shared directory room\nconst match = await rt.create('match');           // a fresh match instance\nconst other = await rt.joinById(roomId, 'match'); // join an advertised one\n```\n\n`createDirectory(lobby)` turns the lobby into a heartbeat'd listing of open rooms.\n\n**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.\n\n**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'`.\n\nWorked 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.\n\n## Provisioning a room\n\nA 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:\n\n- **Declarative (best for deployed apps)** - declare it in `gipity.yaml` as a `realtime` deploy phase. `gipity deploy` reconciles it (creates if missing, no-op if it exists) - reproducible, no separate step. The `3d-world` / `3d-engine` templates already ship this.\n  ```yaml\n  deploy:\n    phases:\n      - name: realtime\n        type: realtime\n        rooms:\n          - name: game-lobby\n            room_type: state\n            auth_level: public\n  ```\n- **CLI** - `gipity realtime room create game-lobby --type state --auth public` (also `list`, `info`, `delete`). Deterministic and scriptable - good for CI. The same command exists in the web CLI as `/realtime room ...`.\n- **Agent tool** - `realtime_room action=create name=game-lobby room_type=state auth_level=public`. Use when working inside a chat turn.\n\n## Quick Start\n\n1. Provision a room (see \"Provisioning a room\" above) - e.g. the agent tool:\n```\nrealtime_room action=create name=game-lobby room_type=state auth_level=public max_clients=50\n```\n\n2. 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`:\n```html\n<script src=\"https://unpkg.com/colyseus.js@0.16.22/dist/colyseus.js\"></script>\n```\n\n3. Connect to the room:\n\n**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`.\n\n```js\n// Get app token - MUST be absolute URL to API server, POST with app GUID\nconst resp = await fetch('https://a.gipity.ai/api/token', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ app: '<PROJECT_GUID>' })\n});\nconst { data: { token } } = await resp.json();\n// ✗ WRONG: fetch('/api/token')           - relative URL hits app host, not API\n// ✗ WRONG: fetch('https://a.gipity.ai/api/token')  with GET - must be POST\n// ✗ WRONG: const { token } = await ...   - token is inside data: { data: { token } }\n\nconst client = new Colyseus.Client(\"wss://rt.gipity.ai\");\nconst room = await client.joinOrCreate(\"state\", {\n  app: \"<PROJECT_GUID>\",\n  room: \"game-lobby\",\n  token\n});\n```\n\n## Room Discovery (Lobby / Matchmaking)\n\nThe client library does **NOT** have a room listing function. To list rooms, use the REST endpoint:\n\n```js\n// List available rooms (uses the app token from step 1)\nconst roomsResp = await fetch(\n  'https://rt.gipity.ai/rooms?room=game-lobby&token=' + encodeURIComponent(token)\n);\nconst { rooms } = await roomsResp.json();\n// rooms = [{ roomId, clients, maxClients, metadata }, ...]\n```\n\n### Lobby Pattern - Join Existing or Create New\n```js\nconst roomsResp = await fetch(\n  'https://rt.gipity.ai/rooms?room=game-lobby&token=' + encodeURIComponent(token)\n);\nconst { rooms } = await roomsResp.json();\n\n// Find a room that isn't full\nconst available = rooms.find(r => r.clients < r.maxClients);\nlet room;\nif (available) {\n  // Join existing room by ID\n  room = await client.joinById(available.roomId, {\n    app: \"<PROJECT_GUID>\", room: \"game-lobby\", token\n  });\n} else {\n  // No room available - create a new one\n  room = await client.joinOrCreate(\"state\", {\n    app: \"<PROJECT_GUID>\", room: \"game-lobby\", token\n  });\n}\n```\n\n> **Note:** The `room` query param is optional - omit it to list all rooms for your app.\n> **Never use** `client.getAvailableRooms()` - it does not exist in the client library.\n\n## Relay Room Patterns\n\n```js\n// Send a typed message\nroom.send(\"chat\", { user: \"Alice\", text: \"Hello!\" });\nroom.send(\"move\", { x: 10, y: 20 });\n\n// Receive messages by type\nroom.onMessage(\"chat\", (msg) => {\n  console.log(msg.user + \": \" + msg.text);\n});\nroom.onMessage(\"move\", (msg) => {\n  movePlayer(msg.x, msg.y);\n});\n```\n\n## State Room Patterns\n\n**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.\n\n### Players (auto-tracked)\n```js\n// Detect joins/leaves by diffing room.state.players on every update.\nconst knownPlayers = new Set();\nroom.onStateChange((state) => {\n  if (!state.players) return;            // undefined on a fresh room\n  const present = new Set();\n  state.players.forEach((player, sessionId) => {\n    present.add(sessionId);\n    if (knownPlayers.has(sessionId)) return;\n    knownPlayers.add(sessionId);\n    console.log(\"Player joined:\", player.displayName);\n  });\n  for (const sessionId of [...knownPlayers]) {\n    if (present.has(sessionId)) continue;\n    knownPlayers.delete(sessionId);\n    console.log(\"Player left:\", sessionId);\n  }\n});\n\n// ✗ WRONG - .onAdd is not a function in colyseus.js 0.16:\n// state.players.onAdd((player, sid) => { ... })\n\n// Set custom player data (e.g. score, position)\nroom.send(\"set_player_data\", { data: JSON.stringify({ score: 100 }) });\n```\n\n### Shared Data (key-value, auto-synced)\n```js\n// Set shared data (any client can set, all clients receive)\nroom.send(\"set_data\", { key: \"gameState\", value: JSON.stringify({ round: 1, phase: \"playing\" }) });\nroom.send(\"delete_data\", { key: \"oldKey\" });\n\n// Detect changes by diffing room.state.data on every update. Values are JSON\n// strings - compare them raw to spot a change, then parse.\nconst dataSeen = new Map();   // key -> last raw JSON string\nroom.onStateChange((state) => {\n  if (!state.data) return;               // undefined on a fresh room\n  state.data.forEach((value, key) => {\n    if (dataSeen.get(key) === value) return;   // unchanged\n    dataSeen.set(key, value);\n    console.log(key, \"changed to:\", JSON.parse(value));\n  });\n  for (const key of [...dataSeen.keys()]) {\n    if (state.data.has(key)) continue;\n    dataSeen.delete(key);\n    console.log(key, \"deleted\");\n  }\n});\n\n// ✗ WRONG - .onChange is not a function in colyseus.js 0.16:\n// state.data.onChange((value, key) => { ... })\n```\n\n### Custom Messages (broadcast to all)\n```js\n// Any unrecognized message type is broadcast to all other clients\nroom.send(\"explosion\", { x: 50, y: 30, radius: 10 });\nroom.onMessage(\"explosion\", (data) => renderExplosion(data));\n```\n\n## Turn-Based Game Pattern\n\nUse a state room with a `currentTurn` key:\n```js\n// Host sets initial turn\nroom.send(\"set_data\", { key: \"currentTurn\", value: JSON.stringify(room.sessionId) });\nroom.send(\"set_data\", { key: \"board\", value: JSON.stringify(Array(9).fill(null)) });\n\n// On each move, update board + advance turn\nfunction makeMove(index) {\n  const board = JSON.parse(room.state.data.get(\"board\"));\n  board[index] = mySymbol;\n  room.send(\"set_data\", { key: \"board\", value: JSON.stringify(board) });\n  room.send(\"set_data\", { key: \"currentTurn\", value: JSON.stringify(opponentSessionId) });\n}\n\n// React to board/turn changes by diffing data on every update\nconst seen = new Map();\nroom.onStateChange((state) => {\n  if (!state.data) return;\n  state.data.forEach((value, key) => {\n    if (seen.get(key) === value) return;   // unchanged\n    seen.set(key, value);\n    if (key === \"board\") renderBoard(JSON.parse(value));\n    if (key === \"currentTurn\") updateTurnIndicator(JSON.parse(value));\n  });\n});\n```\n\n## State Room - Safe Initialization Boilerplate\n\nCopy-paste this as your starting point for any state room app:\n```js\n// 1. Get token\nconst resp = await fetch('https://a.gipity.ai/api/token', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ app: '<PROJECT_GUID>' })\n});\nconst { data: { token } } = await resp.json();\n\n// 2. Connect (or use Room Discovery to join an existing room - see above)\nconst client = new Colyseus.Client(\"wss://rt.gipity.ai\");  // from CDN loaded above\nconst room = await client.joinOrCreate(\"state\", {\n  app: \"<PROJECT_GUID>\",\n  room: \"my-room\",\n  token\n});\n\n// 3. React to state by diffing it. onStateChange fires with the full state\n//    on every server update; colyseus.js 0.16 has NO .onAdd/.onChange\n//    callbacks on the maps - diff against what you have seen instead.\nconst knownPlayers = new Set();\nconst dataSeen = new Map();\nroom.onStateChange((state) => {\n  // Players\n  if (state.players) {\n    const present = new Set();\n    state.players.forEach((player, sessionId) => {\n      present.add(sessionId);\n      if (!knownPlayers.has(sessionId)) {\n        knownPlayers.add(sessionId);\n        // Handle player join\n      }\n    });\n    for (const sessionId of [...knownPlayers]) {\n      if (!present.has(sessionId)) {\n        knownPlayers.delete(sessionId);\n        // Handle player leave\n      }\n    }\n  }\n  // Shared data - values are JSON strings\n  if (state.data) {\n    state.data.forEach((value, key) => {\n      if (dataSeen.get(key) !== value) {\n        dataSeen.set(key, value);\n        const parsed = JSON.parse(value);\n        // Handle data change\n      }\n    });\n  }\n});\n\n// 4. Send messages\nroom.send(\"set_data\", { key: \"myKey\", value: JSON.stringify({ foo: \"bar\" }) });\n\n// 5. Cleanup on leave\nroom.onLeave((code) => {\n  console.log(\"Left room, code:\", code);\n});\n```\n\n## Auth\n- **public**: Pass app token in join options - no login needed\n- **user**: Requires Gipity session cookie (Sign in with Gipity)\n\n## URL-param test mode (highly recommended for multiplayer)\n\nA 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:\n\n- `?test-name=Alice` - auto-fills the player name on load.\n- `?test-action=host` - once on the lobby, auto-clicks Host (or your equivalent).\n- `?test-action=join` - once on the lobby, auto-joins the first open game.\n- `?test-action=join&room=<id>` - joins a specific room.\n\nWith those, verification becomes two passive `gipity page-inspect` calls (the existing `multi-test` helper covers it):\n\n```\ngipity page-inspect \".../app/?test-name=Alice&test-action=host\" --wait 8000 --json\ngipity page-inspect \".../app/?test-name=Bob&test-action=join\"   --wait 10000 --json\n```\n\nNo 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.\n\n## Tips\n- The `@gipity/realtime` kit (`gipity add realtime`) covers lobby + match rooms, a `store` channel for whole-object state, presence, host election, and automatic reconnection - prefer it over hand-rolling any of the above\n- Split state into many small keys, not one big JSON blob (each key change re-syncs the entire value)\n- Room config changes apply to new instances only - existing connections are unaffected\n- Room instances have a max client limit (see `realtime_room info` for current limits); the server auto-creates new instances when rooms fill up\n- Use relay rooms for simple message passing; use state rooms when you need server-authoritative truth\n- 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"
}
