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:

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:

  1. 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.
  2. 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.
  3. 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:

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

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:

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:

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

Related