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 Colyseus client - see "The realtime kit" below. The raw client is kept as a fallback in app-realtime-reference (gipity skill read app-realtime-reference).
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.
Pick a room model (all built into the kit)
| Model | Use for | API |
|---|---|---|
| One shared space | presence lists, chat, collab docs, dashboards | createRealtime({ room }) + connect() |
| Spaces from the URL | per-team / per-session spaces off one app link | same, plus scope: <url param> |
| Invite a friend | 1v1 / private matches via share link or 4-char code | createParty() → host() / joinFromUrl() / joinByCode() |
| Open lobby | browse open games, quick-match strangers | createParty() → onTables() / join(entry) / quickMatch() |
Don'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…".
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 - 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.
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 has the 30 s disconnect grace built in. Read the README and examples/ instead of reverse-engineering lib/; both ship inside the app at src/packages/realtime/.
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.
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:
import { createRealtime, createParty } from '@gipity/realtime';
const rt = createRealtime();
const party = createParty(rt, { seats: 2 }); // uses rooms 'lobby' + 'match'
// Host: share the invite link or code; cancel() if the host backs out
const table = await party.host({ host: name });
showShareUi(table.inviteUrl, table.code); // e.g. a copy-link button
table.onFull(() => startGame(table)); // opponent arrived
backBtn.onclick = () => table.cancel(); // delists everywhere, no ghosts
// Guest: an invite link joins on page load (resolves null when no ?join=)
const joined = await party.joinFromUrl();
// ...or: await party.joinByCode(code) / party.onTables(render) + party.join(entry)
// / party.quickMatch({ host: name })
Every 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 only on PERMANENT departure (the server holds a dropped seat 30 s), so it is safe as a forfeit signal. Worked file: examples/party-game.js in the kit.
Multi-room primitives (what party is built on) - one client, many rooms:
import { createRealtime, createDirectory } from '@gipity/realtime';
const rt = createRealtime();
const lobby = await rt.join('lobby'); // joinOrCreate a shared room
const match = await rt.create('match'); // a fresh match instance
const other = await rt.joinById(roomId, 'match'); // join an advertised one
const only = await rt.joinExisting('match', { scope: code }); // join, NEVER create
All four throw RealtimeJoinError on failure. createDirectory(lobby) turns the lobby into a heartbeat'd listing of open rooms.
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.
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.
Presence done well
A 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:
- 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. - Scope the space from the URL - via
scope, never the room name. Read a?team=(or similar) param into thescopeoption 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. - 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.
import { createRealtime } from '@gipity/realtime';
// 1. Stable identity that survives a reload - generate a peer id once, persist the
// chosen name, and pre-fill the input so a returning teammate isn't re-prompted.
const peerId = localStorage.getItem('peerId')
|| ('peer-' + Math.random().toString(36).slice(2, 10));
localStorage.setItem('peerId', peerId);
nameInput.value = localStorage.getItem('name') || ''; // pre-fill, don't re-ask
// 2. Scope the SPACE from the URL so different teams sharing one app link get
// separate presence lists. The room name stays the provisioned one; the
// scope partitions it into one instance per team.
const scope = new URLSearchParams(location.search).get('team') // ?team=engineering
|| 'general'; // sensible default
const rt = createRealtime({ room: 'standup', scope }); // synchronous - connect() below
const here = rt.channel('presence', { sync: 'presence' });
function announce(name) {
localStorage.setItem('name', name); // remember for next visit
here.setLocal({ peerId, name }); // announce a stable identity
} // (rebroadcast ~20 Hz until changed)
// 3. Render a stable identity per peer so two people both named "Sam" stay distinct:
// an initials avatar plus the stable peer id, not the display name alone.
// onChange fires PER PEER as (sid, peer) - don't expect the whole roster as its
// argument. Rebuild the roster from peers(), a Map of sid → peer holding every
// REMOTE peer; your own state is here.local(), not in the Map.
function renderAll() {
const everyone = [here.local(), ...here.peers().values()].filter(Boolean);
renderRoster(everyone.map(p => ({
initials: (p.name || '?').trim().slice(0, 2).toUpperCase(),
name: p.name,
id: p.peerId, // disambiguates duplicate names
})));
}
here.onChange(renderAll); // (sid, peer) per update
here.onLeave(renderAll);
await rt.connect();
The 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().
Plain 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.
Provisioning a room
A 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:
- 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.
Human/AI parity
When 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.
Auth
- public: Pass app token in join options - no login needed
- user: Requires Gipity session cookie (Sign in with Gipity)
Verifying presence/shared state across clients
Presence and shared state only mean anything when two clients are live at the same time. Two important traps:
gipity page testwithout--observedoes 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.- Two separate
gipity page evalcalls 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.
The 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):
gipity page test ".../app/" --clients 2 --labels Alice,Bob --action "document.querySelector('#name').value='{{label}}'; document.querySelector('form').requestSubmit();" --observe "document.querySelectorAll('.present').length"
{{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.
URL-param test mode (also handy for multiplayer lobbies)
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 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:
gipity page test ".../app/?test-name=Player&test-action=join" --clients 3 --stagger 8 --wait 10000
For 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:
gipity page test ".../app/?test-name={{label}}&test-action={{label}}" --clients 2 --labels host,join --observe "document.querySelector('[data-screen]')?.dataset.screen"
Client 0 loads ?test-action=host and client 1 ?test-action=join, genuinely concurrently - no backgrounding + sleep dance, no stale lingering room to land in.
No 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.
Tips
- The
@gipity/realtimekit (gipity add realtime) covers party flows (invite links / codes / browse / quick-match), lobby + match rooms, astorechannel for whole-object state, presence, scope partitioning, host election, and automatic reconnection - prefer it over hand-rolling any of the above - 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
- 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
Related
- app-realtime-reference - 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
- realtime-scheduled-app - end-to-end recipe combining presence + messages with a database and a scheduled poster