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.

Where it runs: one region, US West (Oregon), with no nearest-region routing: every client connects there, so players far away see more latency: typically about 70 ms round trip from the US East Coast, about 150 ms from Western Europe, and 100-200 ms from East Asia and Australia, plus each player's own network. That is fine for party, turn-based, co-op and casual action games; for twitch-speed play overseas, measure with rtt() (or gipity realtime bench) before promising it.

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()
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")

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 to everyone else or to chosen recipients. No synced state; newcomers get a __players roster and everyone gets __player_joined / __player_left. Good for: chat, notifications, signaling, real-time feeds, simple multiplayer.

State Room

Shared 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. Good for: games, collaborative editors, dashboards, turn-based games, anything needing shared state.

Messages (both room types)

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 README is the API reference (its "Rooms" section lists every room-handle method in one block). 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 at once on a clean leave (tab closed or reloaded), or after the seat hold (30 s default) for a dropped connection that didn't return; a blip within the hold never fires it. Worked file: examples/party-game.js in the kit.

Several players on one computer (couch co-op): pass { seats: N } (1-8) to host / joinByCode / joinFromUrl / quickMatch; createParty's seats is the table total, enforced by the server (a 3-player join with 2 left throws 'full'). Details: app-realtime-reference.

Screen + phone controllers (couch / party games)

One 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: 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. The TV page uses one seat (none with host({ seats: 0 })) and one of the room's max_clients (8 phones = 9).

// TV page
const party = createParty(rt, { seats: 9 });          // the TV + up to 8 phones
const table = await party.host({ host: 'Living room' });
showQrCode(table.inviteUrl); showCode(table.code);
const input = table.channel('input');
input.on('press', (m) => {
  const seat = seatFor(table.room.peerInfo(m.senderId)?.clientId);   // clientId survives a phone reload
  game.apply(seat, m.key, m.down);
  latency.push(table.room.serverNow() - m.sentAt);                  // input age, ms
});
input.send('vibrate', { ms: 200 }, { to: m.senderId });            // one phone only

// Phone page (opened from the QR code)
const table = await party.joinFromUrl();
const input = table.channel('input');
leftBtn.onpointerdown = () => input.sendToHost('press', { key: 'left', down: true });
leftBtn.onpointerup   = () => input.sendToHost('press', { key: 'left', down: false });

Rejoining a full table works from the same invite link or code once a seat frees; a phone whose page crashed takes its held seat back even when full (see the reference).

Host handoff (when a phone hosts)

A hosting phone stops running when its screen locks. With handoff, the host role moves to another player's page, which gets the old host's last checkpoint:

const table = await party.host({ host: name, handoff: true });      // the host: phone or TV
setInterval(() => table.setCheckpoint(referee.snapshot()), 500);    // <= 64 KB, last write wins
const t = await party.joinFromUrl({ canHost: true });               // players who may take over
t.onHostChange(({ isMe, reason, hostEpoch, checkpoint }) => {
  if (isMe) referee = rebuild(checkpoint?.data);                     // only the new host gets it
});

It moves when the host's connection, messages, or visible page are gone for host_grace_seconds (default 5). Each change bumps hostEpoch; t.isHost() is always current; a stale host's sends are dropped ('undelivered', 'stale-host'). Peers see page visibility: peerInfo(sid).visible, onPeerVisibility. Successors, setSuccessors / transferHost, in-flight messages: app-realtime-reference.

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 - joins resolve before the room's state has synced, so channel.get(key) is undefined until the first sync. To read state at once (e.g. a lobby joiner inspecting the host's match), await new Promise((r) => channel.onReady(r)) first; otherwise drive the UI from channel.onChange.

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'. A realtime server restart (a platform deploy) ends every room: clients get 'lost' and should re-join; room state (checkpoints included) is not persisted across it.

Delivery guarantees during reconnect and handoff: nothing is queued or replayed. A peer that is away misses broadcasts (a send targeted only at it comes back 'undelivered'); after 'reconnected' it gets new messages only, while the data map, player list, and current host resync. Messages that reached an old host are not redelivered to its successor. Full rules, reloads and crashes: app-realtime-reference.

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 two equivalent ways - both 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