{
  "name": "app-realtime",
  "title": "Real-Time Multiplayer",
  "description": "Real-time multiplayer: rooms, lobbies, invite links, 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 Colyseus client - see \"The realtime kit\" below. The raw client is kept as a fallback in [app-realtime-reference](app-realtime-reference.md) (`gipity skill read app-realtime-reference`).\n\n**The realtime build loop ends with a concurrent two-client check, not a page load.** Realtime work is only \"done\" after `gipity page test <url> --clients 2 --observe ...` shows the clients seeing each other (details in \"Verifying presence/shared state across clients\" below). A single `page inspect`/`page eval` cannot verify multiplayer, and two sequential evals are a false negative - plan the test-mode hooks for this while building, not after.\n\n## Pick a room model (all built into the kit)\n\n| Model | Use for | API |\n|---|---|---|\n| **One shared space** | presence lists, chat, collab docs, dashboards | `createRealtime({ room })` + `connect()` |\n| **Spaces from the URL** | per-team / per-session spaces off one app link | same, plus `scope: <url param>` |\n| **Invite a friend** | 1v1 / private matches via share link or 4-char code | `createParty()` → `host()` / `joinFromUrl()` / `joinByCode()` |\n| **Open lobby** | browse open games, quick-match strangers | `createParty()` → `onTables()` / `join(entry)` / `quickMatch()` |\n| **One screen, phone controllers** | couch/party games: a TV page hosts, phones scan a QR code and send inputs | `createParty()` → `host()` on the screen, `joinFromUrl()` on phones, `sendToHost()` (see \"Screen + phone controllers\") |\n\nDon't hand-roll a lobby, invite link, or room-code flow out of the primitives - `createParty` already owns those flows and their failure modes (cancelable hosting, typed join errors, one staleness window). Hand-rolled versions have repeatedly shipped ghost tables and UIs stuck on \"Joining…\".\n\n## Room Types\n\n### Relay Room\nPure message broker - clients send typed messages to everyone else or to chosen recipients. No synced state; newcomers get a `__players` roster and everyone gets `__player_joined` / `__player_left`.\n**Good for:** chat, notifications, signaling, real-time feeds, simple multiplayer.\n\n### State Room\nShared state held by the server. Auto-tracks players in a synced map, plus a generic key-value `data` map (values are JSON strings) auto-synced to all clients. The server stores and relays; it runs no game logic, and any client can write any key. For a single source of truth, make one client the authority (the host: a screen, or a `host`-authority channel) and have the others send it inputs.\n**Good for:** games, collaborative editors, dashboards, turn-based games, anything needing shared state.\n\n### Messages (both room types)\n- **The server stamps every relayed message** with `senderId` (the sender's session id) and `serverTs` (server receive time, ms). Client-supplied values for these are overwritten, so `senderId` is safe to trust for \"who pressed this\". The kit also sets `sentAt` (send time, in server-clock ms) on `messages`-channel sends once its clock is synced.\n- **Targeted sends:** `ch.send(type, data, { to: sessionId })`, `{ to: [id1, id2] }`, or `ch.sendToHost(type, data)`. Without `to`, a message goes to everyone else. Messages are delivered immediately (no batching), reliably, and in order per sender.\n- **Limits:** 10,000 characters per message or data value; 120 messages/s per client (bursts to 240; excess is dropped and a sustained flood is disconnected); 1,000 data keys and 2 MB of data per room.\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 - connecting by hand, room discovery over REST, relay/state message shapes, and the state-room boilerplate - are the fallback and a reference for what the kit does internally: read [app-realtime-reference](app-realtime-reference.md).\n\n**The kit documents itself - `gipity skill read realtime` prints its README.** This skill covers *what to build*; the kit's own README is the *API reference* and is the right place to look up a method surface. In particular its \"Rooms\" section lists every room-handle method in one block - `connect` / `disconnect` / `isConnected` / `isSynced` / `peers` / `onPeerJoin` / `onPeerLeave` / `getRoomId` / `getSessionId` / `getLastError` / `channel` / `on` / `metrics` - along with the note that `onPeerLeave` already waits out the seat hold (30 s by default) on an unclean drop. Read the README and `examples/` instead of reverse-engineering `lib/`; both ship inside the app at `src/packages/realtime/`.\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**Party (lobby games, invite links, room codes) - use this, don't hand-roll.** `createParty(rt)` owns the whole host/join flow: share codes, invite URLs, the live table list, quick-match, cancelable hosting, and typed join failures:\n\n```js\nimport { createRealtime, createParty } from '@gipity/realtime';\nconst rt = createRealtime();\nconst party = createParty(rt, { seats: 2 });   // uses rooms 'lobby' + 'match'\n\n// Host: share the invite link or code; cancel() if the host backs out\nconst table = await party.host({ host: name });\nshowShareUi(table.inviteUrl, table.code);      // e.g. a copy-link button\ntable.onFull(() => startGame(table));          // opponent arrived\nbackBtn.onclick = () => table.cancel();        // delists everywhere, no ghosts\n\n// Guest: an invite link joins on page load (resolves null when no ?join=)\nconst joined = await party.joinFromUrl();\n// ...or: await party.joinByCode(code) / party.onTables(render) + party.join(entry)\n//        / party.quickMatch({ host: name })\n```\n\nEvery failed join **throws a `RealtimeJoinError`** with `err.code` `'not-found'` | `'full'` | `'gone'` | `'auth'` | `'offline'` | `'failed'` - catch it and show the right message (\"game is full\", \"invite expired\") instead of a stuck \"Joining…\". Game state goes in a `store` channel on `table.channel('state', { sync: 'store' })`. `table.onPeerLeave` fires when a player is gone: immediately when they leave cleanly (closed or reloaded the tab), and after the seat hold (30 s by default) when their connection dropped and they didn't come back. A network blip within the hold never fires it. Worked file: `examples/party-game.js` in the kit.\n\n### Screen + phone controllers (couch / party games)\n\nOne page on a TV or laptop runs the game and hosts the table; phones scan a QR code and act as controllers. The host is a **role**, not a player: `party.host()` holds it, phones never do, and a reloaded TV page resumes the same table (same code, phones still seated) and takes the role back.\n\n```js\n// TV page\nconst party = createParty(rt, { seats: 9 });          // the TV + up to 8 phones\nconst table = await party.host({ host: 'Living room' });\nshowQrCode(table.inviteUrl); showCode(table.code);\nconst input = table.channel('input');\ninput.on('press', (m) => {\n  const seat = seatFor(table.room.peerInfo(m.senderId)?.clientId);   // clientId survives a phone reload\n  game.apply(seat, m.key, m.down);\n  latency.push(table.room.serverNow() - m.sentAt);                  // input age, ms\n});\ninput.send('vibrate', { ms: 200 }, { to: m.senderId });            // one phone only\n\n// Phone page (opened from the QR code)\nconst table = await party.joinFromUrl();\nconst input = table.channel('input');\nleftBtn.onpointerdown = () => input.sendToHost('press', { key: 'left', down: true });\nleftBtn.onpointerup   = () => input.sendToHost('press', { key: 'left', down: false });\n```\n\n- `table.room.hostId()` / `isHost()` / `onHostChange(cb)` expose the role; `rtt()` and `serverNow()` come from the kit's built-in clock sync.\n- `peerInfo(sid).clientId` is a random id kept in the phone's localStorage, so a phone that reloads (a new session id) can get its old seat back.\n- Set seat counts and holds on the `match` room in `gipity.yaml`: `max_clients: 9`, `seat_hold_seconds: 30` (a dropped phone keeps its seat), `host_hold_seconds: 60` (a dropped or reloading TV keeps the host role).\n- Measure before you ship: `gipity realtime bench match --clients 8 --rate 20` reports controller-to-screen input age (p50/p95/p99), loss and ordering against the live server.\n\n**Multi-room primitives** (what party is built on) - one client, many rooms:\n\n```js\nimport { createRealtime, createDirectory } from '@gipity/realtime';\nconst rt = createRealtime();\nconst lobby = await rt.join('lobby');             // joinOrCreate a shared room\nconst match = await rt.create('match');           // a fresh match instance\nconst other = await rt.joinById(roomId, 'match'); // join an advertised one\nconst only  = await rt.joinExisting('match', { scope: code }); // join, NEVER create\n```\n\nAll four throw `RealtimeJoinError` on failure. `createDirectory(lobby)` turns the lobby into a heartbeat'd listing of open rooms.\n\n**Scope - many spaces from one provisioned room.** `scope` is an opaque partition key: same `(room, scope)` → same instance, different scope → separate instance of the same provisioned room. Key it off a URL param so one app link serves many independent teams/sessions. **Never derive the room NAME from a URL** - unprovisioned room names are rejected by the server; derive the scope.\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 reconnection token with the session id preserved (channels and seats survive a blip within the room's seat hold). Observe it with `rt.on('reconnecting')` / `'reconnected'` / `'lost'`. Messages sent while reconnecting are dropped, not queued. A realtime server restart (a platform deploy) ends every room: clients get `'lost'` and should re-join; room state is not persisted across it.\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### Presence done well\n\nA who's-here / presence list has three quality gaps that are easy to miss while building and obvious in use. Make these the default for any presence app:\n\n1. **Persist identity across reloads.** Generate a stable peer id once and store it (plus the chosen name) in `localStorage`; pre-fill the name input on load so a returning teammate isn't re-prompted on every refresh.\n2. **Scope the space from the URL - via `scope`, never the room name.** Read a `?team=` (or similar) param into the `scope` option so two different teams sharing the same app link land in separate presence lists instead of one global room. The room *name* stays the provisioned one - a URL-derived room name is rejected by the server as unprovisioned.\n3. **Render a stable identity per peer.** Show an initials avatar plus the stable peer id, not the display name alone - otherwise two people both named \"Sam\" are indistinguishable.\n\n```js\nimport { createRealtime } from '@gipity/realtime';\n\n// 1. Stable identity that survives a reload - generate a peer id once, persist the\n//    chosen name, and pre-fill the input so a returning teammate isn't re-prompted.\nconst peerId = localStorage.getItem('peerId')\n  || ('peer-' + Math.random().toString(36).slice(2, 10));\nlocalStorage.setItem('peerId', peerId);\nnameInput.value = localStorage.getItem('name') || '';   // pre-fill, don't re-ask\n\n// 2. Scope the SPACE from the URL so different teams sharing one app link get\n//    separate presence lists. The room name stays the provisioned one; the\n//    scope partitions it into one instance per team.\nconst scope = new URLSearchParams(location.search).get('team') // ?team=engineering\n  || 'general';                                                // sensible default\n\nconst rt = createRealtime({ room: 'standup', scope });  // synchronous - connect() below\nconst here = rt.channel('presence', { sync: 'presence' });\n\nfunction announce(name) {\n  localStorage.setItem('name', name);                   // remember for next visit\n  here.setLocal({ peerId, name });                      // announce a stable identity\n}                                                       // (rebroadcast ~20 Hz until changed)\n\n// 3. Render a stable identity per peer so two people both named \"Sam\" stay distinct:\n//    an initials avatar plus the stable peer id, not the display name alone.\n//    onChange fires PER PEER as (sid, peer) - don't expect the whole roster as its\n//    argument. Rebuild the roster from peers(), a Map of sid → peer holding every\n//    REMOTE peer; your own state is here.local(), not in the Map.\nfunction renderAll() {\n  const everyone = [here.local(), ...here.peers().values()].filter(Boolean);\n  renderRoster(everyone.map(p => ({\n    initials: (p.name || '?').trim().slice(0, 2).toUpperCase(),\n    name: p.name,\n    id: p.peerId,                                       // disambiguates duplicate names\n  })));\n}\nhere.onChange(renderAll);                               // (sid, peer) per update\nhere.onLeave(renderAll);\n\nawait rt.connect();\n```\n\nThe presence channel's surface is exactly: `setLocal(obj)`, `local()`, `peers()` (a Map), `onChange(cb)` / `onJoin(cb)` / `onLeave(cb)` (all fire per peer as `cb(sid, peer)`; `onLeave` gets just `sid`), and `metrics()`. There is **no `set()`**, and no callback ever receives the whole roster - always rebuild from `peers()`.\n\nPlain `setLocal(obj)` needs **no adapter** - payloads are merged into peer records with an `Object.assign`. A custom presence adapter (`{ encode, apply, newPeer }`, e.g. for quantized positions) is only for controlling the wire format - its contract is documented in the kit's README (\"The presence adapter contract\") and `contracts/adapter.contract.md` - the same rule as every other kit internal: the README and `examples/` are the reference, `lib/` is not.\n\n## Provisioning a room\n\nA room must exist before an app can connect - the server rejects unprovisioned room names. `gipity add realtime` already provisions three `state`/`public` rooms: one named after the project, plus `lobby` and `match` (what `createParty` uses), so kit apps usually need no extra step. Many *instances* of one provisioned room come free via `scope` - never provision per team/session/code. For additional names 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 it if missing, updates it when a setting changed, and leaves it alone otherwise - reproducible, no separate step. The `3d-world` / `3d-engine` templates already ship this. Optional per room: `max_clients` (1-200, default 50), `seat_hold_seconds` (0-300, default 30), `host_hold_seconds` (0-600, default 60).\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 [--max-clients N --seat-hold S --host-hold S]` (also `list`, `info`, `delete`). Deterministic and scriptable - good for CI.\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\n## Human/AI parity\n\nWhen an app fields **both bot and human players**, anything a bot can do on its turn must also be reachable by a human through the UI - bots and humans get an equivalent set of actions, never a strict subset. If a bot can buy, build, mortgage, sell, bid, or trade, the human needs an affordance for each. Trading is the one most often dropped, so wire it up too.\n\n## Auth\n- **public**: Pass app token in join options - no login needed\n- **user**: Requires a signed-in Gipity user: the join must carry their Gipity session (the `sessionId` join option)\n\n## Verifying presence/shared state across clients\n\nPresence and shared state only mean anything when two clients are **live at the same time**. Two important traps:\n\n- `gipity page test` *without* `--observe` does passive page loads only - it never submits a name, clicks, or reads shared state, so it can't tell you whether clients see each other.\n- Two separate `gipity page eval` calls run **sequentially** - each finishes before the next starts, so the clients never coexist and each sees only itself. That looks identical to a broken presence kit, but it's a false negative from the harness, not a bug in your app. Don't go debugging the transport over it.\n\nThe fix for both is the interactive mode of `page test`, which spins up N **genuinely concurrent** clients, drives an action in each, samples the shared state over a window, and then **verifies the clients actually overlapped in time** (refusing to call a non-overlapping run a pass):\n\n```\ngipity page test \".../app/\" --clients 2 --labels Alice,Bob   --action \"document.querySelector('#name').value='{{label}}'; document.querySelector('form').requestSubmit();\"   --observe \"document.querySelectorAll('.present').length\"\n```\n\n`{{label}}` / `{{i}}` are substituted per client. Each client runs `--action` once, then samples `--observe` across `--hold` ms (default 8000, `--samples` readings). A working presence app prints each client's count rising as the other joins (e.g. `1 → 1 → 2 → 2`) plus `✓ all clients overlapped for ~Ns`. If the clients didn't overlap (too much `--stagger`, or more `--clients` than free browser slots), it says so loudly and exits non-zero instead of giving you a misleading green. Use `--wait-for <selector>` to gate the action on the form being ready.\n\n## URL-param test mode (also handy for multiplayer lobbies)\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 is a single `gipity page test` run - it loads the URL in N staggered headless clients (client 0 settles first as host, the rest join) and flags any error/crash lines across their consoles:\n\n```\ngipity page test \".../app/?test-name=Player&test-action=join\" --clients 3 --stagger 8 --wait 10000\n```\n\nFor asymmetric roles (one host, others join), don't run two separate calls - they execute sequentially, so the host client is gone (or its room is stale) by the time the joiners load, and you get a false negative. Co-launch the roles in **one** interactive invocation instead: with `--observe`, `{{label}}` / `{{i}}` substitute into the URL too, and the overlap check confirms the roles actually coexisted:\n\n```\ngipity page test \".../app/?test-name={{label}}&test-action={{label}}\" --clients 2 --labels host,join   --observe \"document.querySelector('[data-screen]')?.dataset.screen\"\n```\n\nClient 0 loads `?test-action=host` and client 1 `?test-action=join`, genuinely concurrently - no backgrounding + `sleep` dance, no stale lingering room to land in.\n\nNo Puppeteer, no Chromium libs, no DOM driving - a passive `page test` (no `--observe`) just loads the URL, so the URL-param test mode is what makes the load alone exercise the join path. (When you'd rather drive the form than add a test mode, the interactive `page test --observe` above does the DOM driving for you.) 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 party flows (invite links / codes / browse / quick-match), lobby + match rooms, a `store` channel for whole-object state, presence, scope partitioning, host election, and automatic reconnection - prefer it over hand-rolling any of the above\n- A multiplayer game needs a visible share affordance: show the invite link / code with a copy button on the \"waiting for opponent\" screen (that is when it's needed), not after the game starts\n- Split state into many small keys, not one big JSON blob (each key change re-syncs the entire value)\n- Changing a room in `gipity.yaml` and redeploying updates it; running instances apply new settings to their next joiner (within about 30 s)\n- Room instances have a max client limit (`gipity realtime room info <name>`). A full **unscoped** room shards: the next joiner gets a fresh instance. A full **scoped** room (a specific table or invite code) rejects further joins with `err.code === 'full'`, so a party is never split across two instances\n- Use relay rooms for simple message passing; use state rooms when you need synced shared state\n- Scope values and room ids are visible to anyone with the app's public token (the lobby lists them), so treat a room code as a convenience, not a secret\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\n\n## Related\n- [app-realtime-reference](app-realtime-reference.md) - the raw Colyseus client: connecting without the kit, room discovery over REST, relay + state room message patterns, turn-based games, and the safe state-room initialization boilerplate\n- [realtime-scheduled-app](realtime-scheduled-app.md) - end-to-end recipe combining presence + messages with a database and a scheduled poster\n"
}
