# rooms_sdk — full documentation > Single-file export of the rooms_sdk docs site for text models. > Source of truth: the rooms_sdk repository (docs/, packages/, examples/). ## Contents 1. Getting started 2. Concepts 3. Identity (reference page) 4. Identity (repository document) 5. Wire protocol (reference page with frame table) 6. Wire protocol (repository document) 7. Room templates (reference page) 8. Room templates (repository document) 9. Hosted node HTTP API (reference page) 10. Hosted node HTTP API (repository document) 11. Self-hosting 12. RoomAuthority interface contract (spec) --- # Getting started Install, run, then host — everything below is distilled from the runnable [`examples/counter`](https://github.com/holotype/rooms_sdk/tree/main/examples/counter) in the repository. ## 1. Install rooms_sdk is consumed as **git path dependencies** (it is not on pub.dev). From a Dart or Flutter project next to a clone of the repository: ```yaml dependencies: protocol: path: ../path/to/rooms_sdk/packages/protocol client: path: ../path/to/rooms_sdk/packages/client server_sdk: path: ../path/to/rooms_sdk/packages/server_sdk ``` Then `dart pub get` (or `flutter pub get`). `client` runs on native and web; `server_sdk` is native-only (it binds real sockets). ## 2. Run the counter example The complete join → chat → shared-state round trip in one command: ```sh cd examples/counter dart run ``` It spawns a dedicated `RoomServer` in its own isolate on a loopback socket; two `RoomClient`s join over **real WebSockets**, exchange chat through room social, and share a counter through a `RoomAuthority` whose snapshot is replicated to everyone. ## 3. Host your first room ### The authority — your whole game Validation, simulation and snapshots. Networking never imports your game types and vice versa: ```dart class CounterAuthority implements RoomAuthority { final counters = {}; @override int capacity = 8, tick = 0; @override double elapsed = 0; @override void update(double seconds) { elapsed += seconds; tick++; } @override Object? validateJoin(Map message) => null; @override void addPlayer(String id, String name, Object? data) => counters[id] = 0; @override void removePlayer(String id) => counters.remove(id); @override Map welcome(String id) => {'sequence': 0}; @override void command(String id, Map message) { if (message['type'] == 'increment') counters[id] = counters[id]! + 1; } @override Map snapshot() => { 'type': 'snapshot', 'epoch': 0, 'tick': tick, 'time': elapsed, 'players': {for (final e in counters.entries) e.key: {'count': e.value}}, }; @override void resume(String id, Object? data) {} @override void disconnect(String id) {} @override void dispose() {} } ``` Every method, when the hub calls it, and its invariants are specified in [`/spec/room-authority.md`](/spec/room-authority.md). ### The server — one room per process ```dart final server = RoomServer( CounterAuthority(), protocolVersion: 1, // your choice; client and server must match simulationHz: 60, snapshotHz: 15, roomKey: 'demo-room', ); await server.start(port: 8790); print('room at ${server.localUri}'); // ws://host:8790/play/demo-room ``` For in-process or player-hosted rooms, skip `RoomServer` and drive a `RoomHub` directly with a `MemoryTransportPair` or your own `GameConnection` adapter — see [Concepts → hosting shapes](/docs/concepts/). ### The client — typed per game ```dart class CounterClient extends RoomClient { CounterClient(Uri uri, String name) : super(uri: uri, name: name, protocolVersion: 1); @override RoomSnapshot decodeSnapshot(Map data) => RoomSnapshot(data); } final alice = CounterClient(server.localUri, 'Alice'); await alice.connect(); // join/resume handshake, first snapshot alice.social.sendChat('hello'); // chat via room social alice.sendControl({'type': 'increment'}); // mutates authority state // every peer's alice.latest.players now carries the new count await alice.close(); ``` Reconnects resume automatically from a one-time token inside a 15 s grace window. To host many rooms in one process behind an HTTP API instead, see [Self-hosting](/docs/self-hosting/). --- *Distilled from [README.md](https://github.com/holotype/rooms_sdk) and `examples/counter`; if the two drift, the repository wins.* --- # Concepts ## The authority model A room is an **authoritative simulation with attached clients**. Your game implements `RoomAuthority` — validation, simulation and snapshots — and the room engine (`RoomHub`) owns everything else: transports, the session handshake, acks, rate limits, snapshot encoding and the social lane. The boundary is strict in both directions: - Networking never imports physics, UI or game types. - The game never sees a socket; it sees peer ids, commands and ticks. Clients send **requests** (inputs, publishes, chat); the authority owns all state; everyone else receives **snapshots**. That is the entire security model — a misbehaving client can only ever change what the authority lets it change. The full call-site contract is specified in [`/spec/room-authority.md`](/spec/room-authority.md). ## Hosting shapes One room engine runs everywhere; hosting is a composition choice, not an architecture: | Shape | Entry point | Transport in | | --- | --- | --- | | Dedicated server | `RoomServer` (`server_sdk`), binds HTTP and upgrades `/play/` WebSocket requests | Real WebSocket listener (native only) | | In-process host | `RoomHub` + `MemoryTransportPair` (`protocol`) | Memory pipe, no sockets | | Player-hosted | `RoomHub` + any `GameConnection` adapter (e.g. WebRTC) | Anything implementing the connection interfaces | | Rooms node | `packages/node` — many rooms in one process behind a supervisor | One port, `/play/` + `/v1` HTTP API | ## Join / resume lifecycle 1. **Join.** The client sends a `join` frame with a `protocol` version (exact match required), a player name and optional game fields. The hub validates, calls `validateJoin`, assigns a stable peer id (`p1`, `p2`, …) and answers `welcome` — with a fresh **one-time resume token** — followed by a baseline, an initial snapshot and the roster (with chat history). 2. **Play.** Commands flow client → authority; snapshots flow hub → clients at `snapshotHz`. 3. **Disconnect.** A lost transport starts a **grace window** (default 15 s). The player's slot and state are reserved; the roster shows them as disconnected but present. 4. **Resume.** Reconnecting with the resume token inside the window restores the same peer id, replays roster/chat history and closes any old transport. Resumes never need a new join token from the control plane. 5. **Reap.** Past the grace window the player is removed for good (`removePlayer`), their row disappears from snapshots, and the next join is a fresh id. A resume token bound to a verified transport identity cannot be used by another identity. ## Baseline / delta replication Snapshots flow through `SnapshotEncoder` as **baseline-relative deltas**: - A reliable **full baseline** is refreshed once per second and on every epoch change. Every delta refers to the current baseline — never to another delta — so a lost or reordered delta cannot corrupt the next one; the next baseline always repairs the receiver. - Disposable **deltas** ride a separate (optionally unreliable) state lane between baselines. Receivers that miss too much get display-only recovery snapshots instead of a new baseline. - Floats are quantized to 4 decimal places with a float32 deadband, so sub-quantum changes drop out entirely; presence masks preserve resets to zero, `false` and `null`. - Acknowledgment runs the other way: every applied snapshot tick is acked, and a receiver that stops ackging for > 5 s while blocked is evicted. - When the transport supports binary and a `WireStateCodec` (e.g. FlatBuffers) is configured, states are encoded as binary frames; JSON text remains the fallback, and the two can share a transport during migration. The exact frame shapes are in the [wire protocol reference](/docs/protocol/) and [`/spec/rooms-protocol.json`](/spec/rooms-protocol.json). ## Room social Chat, pings, roster and moderation ride the same reliable lane as control frames — never the disposable state lane: - Chat text is single-line, capped at 280 characters, rejects hidden control characters and bidi overrides; history caps at 100 entries; rate limit 4 messages / 10 s per player. - Pings (`here`, `help`, `ball`) carry **authority-supplied** locations and expire after 8 s; speech bubbles after 10 s. - The room leader can lock the room, and kick or ban players. Bans require an identity verified by the transport adapter — never a join payload — and the room-lifetime ban list caps at 256 identities. ## Templates overview When you do not want to write Dart at all, the rooms node runs platform-provided authorities configured by JSON room configs: - **Relay** — client-authoritative state rows (presence, chat, small published state), quota-enforced. Your game publishes key/value rows; the server owns quotas and replication. - **Movement** — server-simulated physics over configured static colliders with spawn points, movement parameters and an input whitelist. Positions are server-simulated and server-validated (anti-cheat by construction). See the [room templates reference](/docs/templates/) for the config schemas. --- *Distilled from `docs/protocol.md` and the source of `packages/protocol`; the [wire protocol reference](/docs/protocol/) embeds the repository document verbatim.* --- # Identity (reference page) Users can join with plain guest tokens (the control plane mints one-time join tokens), or sign in with their AT Protocol identity. The two providers compose; the quick shape: | | Guest | AT Protocol sign-in | | --- | --- | --- | | Credential | one-time `jt_` token from the control plane | the same token, minted by the node after OAuth, carrying the verified DID | | Bans | per-connection only | bind to the DID (new handle, new device — still banned) | | Resume | this device | the same DID resumes anywhere; guests cannot resume a DID session | | Profile | none | displayName/avatar prefetched from the user's PDS on the roster | --- *Generated from `docs/identity.md` (embedded above via markdown import).* --- # Identity (repository document) # Identity: guests, AT Protocol sign-in, and DID-bound rooms Two join-credential providers ship with the node (`auth.providers` in `node.json`), and they compose: | Provider | Credential | What the hub binds | | --- | --- | --- | | `guest` | a control-plane-minted one-time join token (`POST /v1/rooms` / `…/tokens`) | nothing — the peer has no verified identity | | `atproto` | the same join tokens, minted by the node after an AT Protocol (Bluesky &c.) OAuth sign-in, carrying the verified DID | bans, moderation eligibility and resume key on the DID | `guest` alone is the v0 default; adding `atproto` changes nothing for existing rooms except that the creator-claim seam now verifies **signed** creator tokens (the opaque string comparison remains for embedders that construct authorities directly). ## The atproto flow (web/server shape — the node is the OAuth client) ``` game node AT Protocol world | | | |-- GET /auth/start -->| | | ?handle&room |-- resolveHandle --> DID --> DID doc --> PDS | | [&creator=true] |-- AS metadata --> PAR (PKCE + DPoP) ------> | |<-- {authorizationUrl,|-- | | flow} ------------| | | | |== user's browser goes to authorizationUrl, signs in, approves ======| | | | |<-- redirect /auth/callback?code&state&iss --| | |-- token exchange: PKCE + DPoP + | | | private_key_jwt (ES256, JWKS at | | | /oauth/jwks.json) --> sub = DID -------->| | |-- mint jt_ (claims: rm, exp, jti, did, hd) | |<-- poll /auth/result?flow=... -- {joinToken, did, roomKey, url, | | [creatorToken]} -------------------| | | | |== WebSocket /play/?token= ===================>| ``` - The client half of the flow is `NodeAuthClient` in `packages/atproto_identity`: start → open the browser (injected callback: `window.open` on the web, the system browser on desktop) → poll `/auth/result`. **Polling is the completion mechanism** — one mechanism that works unchanged for web and desktop; no loopback listener and no `postMessage`. Mobile custom-scheme flows are a deliberate TODO. - The `flow` id in `/auth/result` is an unguessable bearer (exactly like the one-time join token it returns); the auth endpoints are CORS-open for that reason. - Without `format=json` the node 302s the browser straight to the authorization URL (popup-friendly); with it, the JSON above comes back for programmatic clients. ### What the DID unlocks - **Bans bind to the DID** (`RoomHub`'s adapter-verified identity): a banned user is refused at the join handshake on every future DID token — new handles, new devices — for that room's lifetime. - **Resume across devices**: the resume token is bound to the identity; a fresh DID token keeps the same peer id, roster row and (relay) published state. A **guest** token cannot resume a DID-bound session (`Session identity changed`), and a DID token cannot resume a guest session. - **Moderation eligibility**: only identity-bearing peers can be banned (unchanged rule — now it means "only DID-bearing peers"). - **Creator claims** (`ct_` tokens minted with `creator=true`): the relay and movement authorities verify them as signed node tokens (`cr: true`, optionally DID-bearing) bound to the room key, instead of comparing opaque strings. The DID binding of the creator claim is delivery-channel only in v0 (the token is minted at `/auth/callback` to the verified holder); the authority cannot yet compare the claim's DID with the joiner's adapter identity — see *Deferred* below. ### Node configuration ```json "auth": { "providers": ["guest", "atproto"], "atproto": { "clientName": "rooms example node", "entrywayUrl": "https://bsky.social", "plcDirectoryUrl": "https://plc.directory", "jwksPath": "/etc/rooms/oauth-client-jwks.json" } } ``` - Generate the client key once per node: `cd packages/node && dart run tool/gen_client_jwks.dart /etc/rooms/oauth-client-jwks.json` — an EC P-256 private JWK. The node serves only its public half at `/oauth/jwks.json`; the private file is a deployment secret (mount it read-only, like `node.json`). - The client metadata document at `/oauth/client.json` **is** the `client_id` (atproto OAuth profile, `application_type: web`, `token_endpoint_auth_method: private_key_jwt`, redirect `{publicBaseUrl}/auth/callback`). - Identity resolution is entryway XRPC (`com.atproto.identity.resolveHandle`) plus the PLC directory / `did:web` well-knowns, both configurable above. - `publicBaseUrl` must be **https** for real authorization servers (a production AS rejects non-confidential or non-https web clients). ## Portable profiles (prefilled from the user's PDS) On first join with a DID the node fetches `app.bsky.actor.getProfile` from the user's PDS **unauthenticated** (public profiles), caches `displayName`/`avatar`/`handle` keyed by DID, and exposes the profile on the room social roster path clients already consume: `members[].profile = {did, handle?, displayName?, avatar?}` (absent for guests and until the fetch lands — it never blocks the join; the roster updates when the profile arrives). - Cache layers: memory → SQLite (`did_profiles`) → PDS, with a TTL (default 24 h; stale entries are refreshed in the background and served meanwhile). A `handle → DID` cache (10 min TTL) front-loads future sign-ins. - **Privacy**: a DID is a persistent identifier. Profiles are stored **node-scoped** (one row per DID in the node's SQLite) and exposed **room-scoped** (only to peers of rooms the DID actually joined). Hosts deploying the atproto provider should say so in their privacy policy; wiping the `did_profiles` and `handle_cache` tables opts a deployment out of profile retention without affecting anything else. - `avatar` is the PDS blob URL as-is; clients apply their own remote-image policy when rendering it. ## Package layout - `packages/atproto_identity` (pub name `rooms_atproto`) — pure identity: desktop loopback OAuth (`AtprotoAccountManager` + `OAuthLoopback`, extracted from the original consumer's accounts stack), the session vault interface, and `NodeAuthClient`. `protocol`/`client` stay dependency-clean — no atproto imports outside these two packages. - `packages/server_sdk` — the node-side provider (`AtprotoAuthProvider`, `NodeClientKeys`, `ConfidentialClient`, `NodeAtprotoResolver`, `ProfileService`), DID-bearing join tokens, the signed creator-claim seam, and the roster profile plumbing (`RoomMember.profile`, `RoomHub.profileOf`/`refreshRoster`). - `packages/node` — config (`auth.providers`), wiring, the JWKS generator tool. ## Real-world caveats (and what tests fake) The test suites run against an **in-test authorization server and PDS** (plain `HttpServer` over real TLS with a per-run self-signed certificate — `atproto_oauth` hardcodes https for AS metadata/PAR/token). No real PDS is ever contacted. Places where reality differs from the fakes: - The fake AS approves automatically — no consent screen, no account chooser, no `access_denied` paths in the node tests (the extracted desktop manager does map denial to cancellation). - The fake AS verifies the client assertion (ES256 against the served JWKS), PKCE (S256) and DPoP presence, but not the DPoP proof itself. - Handle resolution goes through the entryway XRPC, not DNS TXT / `/.well-known/atproto-did`, and without bidirectional handle verification. - Profile fetch is unauthenticated v0 — private-profile behavior depends on the PDS (authenticated fetch with the sign-in session is deferred; the node does not retain OAuth sessions). - In-flight auth flows live in process memory: a node restart drops them (the user signs in again). Join-token consumption still persists. - `http://127.0.0.1` origins are accepted in dev/test paths only (`allowPrivateNetwork`); production requires https everywhere. ## Deferred - Creator-claim DID binding enforced against the joiner's adapter identity (needs the authority to see the hub's verified identity). - Authenticated (session-carrying) profile fetch; avatar blob proxying. - Per-room profile visibility controls; a room-creation-time "DID-only" room flag. - Mobile custom-scheme completion for `NodeAuthClient`. - Session persistence node-side (refresh-token vaulting for service-style calls on the user's behalf). --- # Wire protocol (reference page with frame table) The frame table below is derived from the implementation (`packages/protocol/lib/src/room_hub.dart`) and kept honest by a build-time cross-check. The machine-readable version — with limits, lanes and close codes — is [`/spec/rooms-protocol.json`](/spec/rooms-protocol.json). All frames are JSON text. Client → server frames are capped at 4096 characters and rate-limited to 120 per second per peer. The handshake, social and error frames ride a reliable control lane; disposable state deltas prefer a separate state lane when the transport provides one. ## Frame table ### Handshake — client → server | Frame | Fields | Semantics | | --- | --- | --- | | `join` | `type`, `protocol` (int), `name` (string, fresh joins), `resume` (string, optional), `binaryStates` (bool, optional), *game fields* | First frame on a fresh transport, within 5 s. `protocol` must exactly match. `resume` re-attaches a reserved slot; unknown/expired → error. The whole message goes to `validateJoin`. Rejections (banned, locked, full, invalid) → `error` + close 1008 | ### Handshake — server → client | Frame | Fields | Semantics | | --- | --- | --- | | `welcome` | `type`, `protocol`, `id` (peer id), `resume` (one-time token), `capacity`, *authority `welcome()` fields* | Sent after a successful join **or** resume; ids are stable across resume (`p1`, `p2`, …) | | `baseline` | `type`, `epoch`, `tick`, *full snapshot state* | Reliable full quantized snapshot; the reference point for all following deltas | | `snapshot` | `type`, `epoch`, `tick`, `initial` (bool, optional), *full state* | Sent with `initial: true` right after the baseline; refreshed ~1 s / on epoch change; recovery copies for stalled receivers carry `initial: true` too | ### Input and social — client → server | Frame | Fields | Semantics | | --- | --- | --- | | `ack` | `type`, `ack` (int) | Confirms the last applied snapshot tick. The `ack` field is honored on **any** frame (`ackTick ≤ ack ≤ sentTick`) and drives stall eviction | | `chat` | `type`, `text` | 1–280 chars, single line; 4 msgs / 10 s. Rejection → `socialNotice`, no disconnect | | `ping` | `type`, `kind` (`here`/`help`/`ball`) | Location comes from the authority (`RoomSocialAuthority.pingLocation`), never the client; 3 / 5 s | | `moderate` | `type`, `action` (`lock`/`kick`/`ban`), `locked` (bool, lock), `playerId` (kick/ban) | Leader only. `lock` refuses fresh joins; `kick`/`ban` remove the target (`removed` frame + close 1008); `ban` records the transport-verified identity (≤ 256) | | `leave` | `type` | Voluntary leave; slot released immediately, reply `left` | | *game commands* | `type` (game-defined), `ack` (optional), *payload* | Everything else goes verbatim to `authority.command()` — e.g. relay `publish`/`publishRoom`, movement `input`, the example's `increment`. Throwing from `command` disconnects the sender | ### Social and errors — server → client | Frame | Fields | Semantics | | --- | --- | --- | | `social` | `type`, `leaderId`, `locked`, `members` (`[{id, name, connected, canBan}]`), `history` (on join) | Roster broadcast on every membership/lock change; the join copy carries chat history (≤ 100) | | `chat` | `type`, `message` (`{id, playerId, playerName, text, system}`) | User chat and system notices (joined / reconnected / left / removed) | | `ping` | `type`, `ping` (`{id, playerId, playerName, kind, location {x, y, z, epoch}}`) | Broadcast; expires after 8 s | | `left` | `type` | Sent to the peer that just left | | `removed` | `type`, `message` | Sent to a kicked/banned peer before close 1008 | | `socialNotice` | `type`, `message` | Rejected social attempt; sender only, no disconnect | | `error` | `type`, `message` | Any protocol error; always followed by close 1008 | ### State replication — server → client | Frame | Fields | Semantics | | --- | --- | --- | | `state` | `type`, `base` (int), `tick`, `epoch`, `delta` (`{remove?, set}`) | Baseline-relative delta on the disposable lane. Deltas whose `base`/`epoch` do not match the receiver's baseline are dropped and repaired by the next baseline | ### Close codes | Code | Reason | When | | --- | --- | --- | | 1000 | `Reconnected elsewhere` | the session resumed on a new transport | | 1001 | `Host closed the room` / `State receiver stalled` | room closed or drained; receiver unacked > 5 s while blocked | | 1008 | `Join timeout` / `Invalid request` / `Removed by host` | no join in 5 s; any protocol error; kick/ban | ## Repository document --- *Generated from `docs/protocol.md` (embedded above via markdown import) and the frame handling in `packages/protocol/lib/src/room_hub.dart`; the message table is cross-checked against the source at build time (`site/scripts/check-spec.mjs`).* --- # Wire protocol (repository document) # Rooms protocol overview This is a compact description of the wire behavior implemented in `packages/protocol`. It is rewritten from the protocol documentation that ships with the code's original consumer (a multiplayer golf game); the protocol itself is game-agnostic. ## Shapes One room engine (`RoomHub`) runs everywhere; hosting is a composition choice, not an architecture: | Shape | Entry point | Transport in | | --- | --- | --- | | Dedicated server | `RoomServer` (`server_sdk`), binds HTTP and upgrades `/play/` WebSocket requests | Real WebSocket listener (native only) | | In-process host | `RoomHub` + `MemoryTransportPair` (`protocol`) | Memory pipe, no sockets | | Player-hosted | `RoomHub` + any `GameConnection` adapter (e.g. WebRTC) | Anything implementing the connection interfaces | A consuming game implements `RoomAuthority` — validation, simulation and snapshots. The networking never imports physics, UI or game types. ## Session protocol - **Join/resume handshake.** A client sends `join` with a `protocol` version (exact match required), player name and an optional `resume` token. The hub answers `welcome` with the peer id, a one-time resume token, capacity and a sequence seed, followed by the initial state. A disconnected peer's slot is reserved for a grace window (default 15 s); resuming with the token restores the id and replays roster/chat history. A resume token bound to a verified transport identity cannot be used by another identity. - **Fixed-step simulation.** The hub ticks the authority at `simulationHz` (default 120) on a fixed-step clock that never dilates simulated time by more than 0.1 s under load; snapshots are cut at `snapshotHz` (default 20). - **Input and acks.** Clients send `input`/control frames carrying a sequence and the epoch/tick they saw; the authority receives them through `command()`. Every applied snapshot is acknowledged (`ack`); a bounded acknowledgment window plus reliable recovery frames keep a lossy or reordered lane from stalling a receiver. Input is rate-limited (default 120 packets/s, 4 KB frames); a stalled host legitimately buffers a bounded burst (600 packets, 1 s expiry) that ordinary floods do not get. Epoch changes invalidate stale commands. - **State replication.** Snapshots flow through `SnapshotEncoder` as baseline-relative deltas: a reliable full baseline refreshed once per second (and on epoch change), disposable deltas in between. Deltas are quantized (4 decimal places by default) with a float32 deadband, so sub-quantum changes drop out; presence masks preserve resets to zero, false or null. A lost or reordered delta cannot corrupt the next one. - **Binary state lane.** When the transport implements `BinaryGameConnection` and a `WireStateCodec` (e.g. FlatBuffers) is configured, states are encoded as binary frames; JSON text remains the fallback. Binary frames never begin with `{`, so binary and JSON frames can share a transport during migration. - **Eviction and capacity.** Overloaded or silent receivers are evicted; joins beyond `RoomAuthority.capacity` are refused; kicks release slots immediately. ## Room social Chat, pings, roster and moderation ride the same reliable lane as control frames (`RoomSocialHost`/`RoomSocialClient` in `protocol`): - Chat text is single-line, capped at 280 characters, rejecting hidden control characters and bidi overrides; history is capped at 100 entries; rate limit 4 messages / 10 s per player. - Pings (`here`, `help`, `ball`) carry authority-supplied locations and expire after 8 s; speech bubbles after 10 s. - The room leader (first connected peer unless the authority implements `RoomSocialAuthority`) can lock the room, and kick or ban players; the room-lifetime ban list is capped at 256 identities. Bans require an identity verified by the transport adapter, never a join payload. ## Packages - `packages/protocol` — everything above. No `dart:io`; compiles for the web (`dart compile js tool/web_compile_smoke.dart`). - `packages/client` — `RoomClient` (typed subclass per game), resumable reconnects with a playback clock for interpolated rendering, and WebSocket adapters for native (`dart:io`) and web (binary frames surfaced as byte lists, mirroring `dart:io` semantics). - `packages/server_sdk` — native-only dedicated room server (`RoomServer`, `ServerWebSocketConnection`). See `examples/counter` for a complete join → chat → shared-state round trip. --- # Room templates (reference page) A room template is a **platform-provided authority**: you submit a JSON room config, the node validates it against the schema below and runs the corresponding authority. Templates are pinned verbatim at creation and the schema evolves append-only. Quick orientation — the two templates: - **Relay** — client-authoritative state rows (`players{id → row}` + `room{key → row}`), quota-enforced. Core implemented in `server_sdk` as `RelayAuthority`. - **Movement** — server-simulated kinematic movement over configured static colliders with anti-cheat by construction. Core implemented as `MovementTemplateAuthority` + the `physics_react3d` backend. --- *Generated from `docs/room_templates.md` (embedded above via markdown import).* --- # Room templates (repository document) # Room templates & config schema — v1 A room template is a platform-provided authority. Users do not write Dart; they submit a **room config** (JSON) validated against this document, and the server runs the corresponding authority. Two templates launch together: 1. **Relay** — client-authoritative state rows (presence, chat, small published state), quota-enforced. Core implemented in `server_sdk` as `RelayAuthority`. 2. **Movement** — server-simulated physics: static colliders, spawn points and movement parameters configured here; positions are server-simulated, server-validated (anti-cheat) and flow through the same baseline/delta replication. **Core implemented** in `server_sdk` as `MovementTemplateAuthority` (engine-free) plus the `physics_react3d` backend package; the hosted control plane still returns `unsupported_template` until node wiring lands. ## Common envelope ```json { "version": 1, "template": "relay", "capacity": 16, "chat": true } ``` - `version` — **required**, must be `1`. Unknown versions are rejected. - `template` — **required**, `"relay"` or `"movement"`. - `capacity` — **required**, integer `1–128` connected players. - `chat` — **optional**, boolean, default `true`. Chat, pings and speech bubbles on/off. **[DEFERRED enforcement]** moderation (roster, lock, kick, ban) rides the same social lane but stays available even when `chat` is `false`; disabling moderation with chat is explicitly **not** what this flag means. Enforcement lands with the hub's optional-social work. **Evolution rule (append-only).** Rooms **pin their config verbatim at creation**. Later schema versions may add optional fields and never rename, retype or remove existing ones; validators reject unknown fields so a config pinned on v1 keeps validating identically forever. **Shared validation posture** (applies to every field below): numbers must be finite; strings reject control characters and bidi overrides (the `cleanChat` rule); sizes are counted in UTF-8 bytes; unknown fields are rejected. ## Relay template Room state is `players{id → row}` + `room{key → row}`. Each player owns exactly one row, keyed by their peer id, containing the declared **player keys**. **Room keys** are leader-only. Deltas flow through the shared `SnapshotEncoder`, so relay rooms get baseline/delta replication and the binary state lane (FlatBuffers codecs) for free; floats are quantized to 4 decimal places with a float32 deadband, like every other room. ### Full example config ```json { "version": 1, "template": "relay", "capacity": 16, "chat": true, "maxRows": 64, "totalBytes": 32768, "playerKeys": { "pose": { "maxBytes": 256, "publishesPerSecond": 10 }, "emote": { "maxBytes": 96, "publishesPerSecond": 2 } }, "roomKeys": { "phase": { "maxBytes": 64, "publishesPerSecond": 1 } } } ``` ### Fields and validation rules (enforced server-side) | Field | Rule | | --- | --- | | `capacity` | int `1–128` | | `chat` | bool, default `true` (see enforcement deferral above) | | `maxRows` | int `1–1024`. Total retained rows (non-empty player rows + room rows combined); writes that would exceed it are rejected | | `totalBytes` | int `1–262144`. Sum of encoded row bytes across the whole room; writes that would exceed it are rejected | | `playerKeys` | object, `1–16` keys. Key names match `^[a-z][a-zA-Z0-9_]{0,31}$` | | `roomKeys` | object, `0–16` keys, same key-name rule | | per-key `maxBytes` | int `1–2048`. UTF-8 size of the encoded value | | per-key `publishesPerSecond` | int `1–30`. Sliding one-second window per key **per publisher** | ### Publish protocol (client → server, reliable control lane) ```json { "type": "publish", "key": "pose", "value": { "x": 1.25, "y": 0, "z": -3.5 } } { "type": "publishRoom", "key": "phase", "value": "started" } ``` - `publish` writes `players[][key]` — a player can only ever write their own row. There is no addressee field to forge. - `publishRoom` writes `room[key]` — **leader only**. - Values are arbitrary JSON (objects, arrays, scalars). Deep validation: finite numbers, no control characters in any string, string map keys only. ### Rejection posture (the chat hardening culture) Invalid or over-quota publishes are **dropped and counted, never disconnected**: the authoritative snapshot is the feedback loop, and the next valid publish lands (rows are idempotent state pushes, like poses). Rejection counters are exposed on the authority for operators and tests. Floods are handled one layer down: the room's input lane already caps frames at 4 KB / 120 packets per second and disconnects violators. ### Ownership, leader and moderation - The room **creator** is the leader — never "first joiner". The claim arrives with the join: v0 passes a `creatorToken` secret at authority construction and the creating client presents it in its join payload; **[TODO join tokens]** the signed join token will carry `creator: true` and the authority seam switches to claim verification. - Leader powers (lock/kick/ban) and chat/pings/roster/history come from the room social layer automatically — `RelayAuthority` implements `RoomSocialAuthority`, so the hub's first-peer leader fallback never engages. - Pings (`here`/`help`/`ball`) need authority-side coordinates: the relay authority answers them from the player's published row when any player key holds an object with finite `x`, `y`, `z` numbers. ### Snapshot shape ```json { "type": "snapshot", "epoch": 0, "tick": 210, "time": 3.5, "players": { "p1": { "pose": { "x": 1.25, "y": 0, "z": -3.5 } } }, "room": { "phase": "started" } } ``` Every present player appears in `players` (with an empty row before their first publish). Rows disappear when a player is removed (not while merely disconnected inside the resume grace window). ## Movement template Server-simulated kinematic character movement over static colliders. The config format is **engine-agnostic JSON**; the reference engine is **reactphysics3d_dart** (in-house, native FFI, already proven under dart2wasm in a browser-worker authority). **Rapier via WASM was considered and rejected** for v0: it would require new binding work, while the reactphysics3d_dart path exists and has a proven browser fallback for player-hosted rooms. **Architecture [DECIDED].** `server_sdk` stays engine-free: it defines `MovementTemplateAuthority` (all config, validation and movement game logic) behind a thin `PhysicsBackend` interface (build world from collider set, add player bodies, apply movement intents, step, read poses). The reference binding lives in the separate `physics_react3d` package (private git dependency on reactphysics3d_dart); tests use a deterministic kinematic backend with the same movement parameters. Hosts drive the room at the configured rates: `RoomHub(authority, simulationHz: tick.simulationHz, snapshotHz: tick.snapshotHz)` — the authority steps the backend once per fixed `update` tick. ### Full example config ```json { "version": 1, "template": "movement", "capacity": 32, "chat": true, "inputsPerSecond": 60, "tick": { "simulationHz": 60, "snapshotHz": 20 }, "colliders": { "boxes": [ { "name": "floor", "center": [0, -0.5, 0], "halfExtents": [50, 0.5, 50] } ], "capsules": [ { "name": "pillar", "base": [10, 0, 4], "top": [10, 3, 4], "radius": 0.4 } ], "heightfield": { "size": [64, 64], "data": [ /* 65×65 row-major heights, meters */ ] } }, "spawnPoints": [ { "position": [0, 1, 0], "yaw": 0 } ], "movement": { "walkSpeed": 4.5, "runSpeed": 8.0, "gravity": -9.81, "jumpSpeed": 5.2, "airControl": 0.35, "acceleration": 40.0 }, "inputMapping": { "moveX": { "type": "axis", "range": [-1, 1] }, "moveZ": { "type": "axis", "range": [-1, 1] }, "jump": { "type": "action" }, "sprint": { "type": "action" } } } ``` Every template section is **required** in v1 — the example above is the canonical shape; later versions may only add optional fields (e.g. `inputsPerSecond`, `name` labels). Inside `colliders`, each kind is optional but at least one entry is needed in total. ### Field → engine mapping (reactphysics3d_dart, as implemented) | Field | mapping | | --- | --- | | `boxes[].center/halfExtents` | `BoxShape(halfExtents)` on a static `RigidBody` at `center` (orientation identity) | | `capsules[].base/top/radius` | `CapsuleShape(radius, height)` static body at the segment midpoint; height = `‖top−base‖ − 2·radius` (the engine height excludes the hemispherical caps, hence the `segment ≥ 2·radius` validation), oriented from +Y onto the segment | | `heightfield.size/data` | `HeightFieldShape` from row-major floats: rows = `nz+1` (rows run along Z), columns = `nx+1`, one-metre sample spacing, so the field spans `nx × nz` metres **centred on the origin**. The engine also centres the terrain's vertical bounds on the origin (`h_world = h − (h_min+h_max)/2`); the backend lifts the field body by `(h_min+h_max)/2` so samples land at their configured absolute heights. Static body. | | `movement.gravity` | `DynamicsWorld.setGravity` (vertical dynamics are engine-owned) | | `movement.walk/runSpeed, acceleration, airControl` | authority-side horizontal velocity model: velocity approaches `input · speed` (unit-circle clamped) at `acceleration` per second, scaled by `airControl` while airborne; the backend sets body velocity targets each tick | | `movement.jumpSpeed` | grounded-only vertical velocity impulse (rising-edge action, buffered 0.25 s so a press just before landing still fires) | | `spawnPoints[]` | player capsule spawn on join (round-robin); spawn positions are **feet** positions, capsule centre at `spawn.y + playerHeight/2` | | `inputMapping` | whitelist for `{"type": "input", ...}` command payloads (see input protocol below) | | `tick.simulationHz/snapshotHz` | host hub rates: `RoomHub(simulationHz, snapshotHz)`; the authority steps the backend once per fixed update tick (`snapshotHz ≤ simulationHz`) | Player bodies are dynamic capsules under velocity-target control (walls block, floors support, gravity owns vertical motion); orientation is pinned upright (angular velocity zeroed each step, orientation reset after it) — facing is yaw-only and authority-side. The player capsule size is a fixed v0 constant (radius 0.4 m, total height 1.8 m), overridable on the backend constructor. **[FUTURE config field]** a `player` collider section if templates ever need per-game avatar sizes. The grounded check raycasts from the body centre past the feet (rays ignore shapes they start inside, so a player's own capsule never matches). ### Validation rules (enforced at config validation time) - `capacity` `1–128`; `tick.simulationHz` `10–240`, `snapshotHz` `1–60` and `≤ simulationHz` (relay rooms default to 20/10 for density; movement rooms need the headroom). - `colliders` at least one entry (each kind optional); every coordinate finite; `halfExtents` and `radius` `> 0` and `≤ 500`; capsule segment length `‖top−base‖ ≥ 2·radius` (the engine's cylinder height must be non-negative); collider `name` labels are optional clean strings (≤ 64 chars); `heightfield.size` each dimension `16–1024`; `data.length == (nx+1) * (nz+1)`; heights finite and `|h| ≤ 10000`. - `spawnPoints` `1–64`, coordinates finite. - `movement` values finite; speeds `0.1–100`; `gravity` `-30–0`; `jumpSpeed` `0–50`; `airControl` `0–1`; `acceleration` `0.1–500`. - `inputMapping` `1–16` entries; names match the key-name rule above; axes declare `range` `[min, max]`, finite, `min < max`. The movement model has fixed semantics: `moveX` and `moveZ` **must** be declared as axes (steering), and the reserved names `jump`/`sprint` must be actions if present; other declared names are whitelisted and validated but inert in v0 (reserved for future template semantics). - `inputsPerSecond` — **optional** int `1–240`, default `tick.simulationHz` (one input per simulation step). Sliding one-second window per player. - Anti-cheat contract: client inputs are only *requests*; the server simulates from the last acknowledged state, so teleports, speed hacks and out-of-range axes are impossible by construction. Positions replicate through the same baseline/delta lane as the relay template. ### Input protocol (client → server, reliable control lane) ```json { "type": "input", "moveX": 0.5, "moveZ": -1, "jump": false, "sprint": true } ``` - Only fields declared in `inputMapping` are accepted (plus `type`); unknown fields, wrong types (axis not a number, action not a bool) and non-finite numbers reject the **whole message**. Missing fields default: axes `0`, actions `false` — inputs are idempotent state pushes, like relay rows. - Axis values are **clamped** into the declared range and the input vector onto the unit circle — never rejected for magnitude. Speed is server-owned (`walkSpeed`/`runSpeed`); clients only send direction. - Rate limit: `inputsPerSecond` per player per sliding one-second window; **rejected inputs never consume rate budget** (relay semantics). Invalid or over-rate inputs are dropped and counted (`rejectedInputs`, `lastRejection`), never disconnected. - `jump` is a rising-edge action with a 0.25 s buffer; holding it does not re-trigger until released. - On disconnect the input state is cleared (the avatar decelerates to a stop); the body survives the resume grace window and is released on removal. ### Snapshot shape ```json { "type": "snapshot", "epoch": 0, "tick": 1200, "time": 20.0, "players": { "p1": { "pose": { "x": 4.25, "y": 0.9, "z": -3.5, "yaw": 1.57 } } }, "room": { "template": "movement", "simulationHz": 60, "snapshotHz": 20, "movement": { "...": "echo of the movement parameters" }, "spawnPoints": [ { "position": [0, 1, 0], "yaw": 0 } ], "colliders": { "boxes": 1, "capsules": 1, "heightfield": [64, 64] } } } ``` Poses are server-simulated positions plus authority-side yaw; the hub's `SnapshotEncoder` quantizes floats to 4 decimal places like every other room, and clients interpolate. The `room` rows are a small **static config echo** (rates, movement parameters, spawn points, collider counts) — never the heightfield samples; clients render local geometry from the pinned template config itself. The join `welcome` message carries the assigned spawn (`{ "sequence": 0, "spawn": { "x", "y", "z", "yaw" } }`, round-robin over `spawnPoints`). The creator-claim, leader and ping semantics are identical to the relay template (`MovementTemplateAuthority` implements `RoomSocialAuthority`; pings answer from the simulated pose). ### Native library notes (reactphysics3d_dart) reactphysics3d_dart ships prebuilt ReactPhysics3D static archives (linux-x86_64/arm64, macos-arm64) and a native build hook that compiles the C bridge — `dart pub get && dart test` in `physics_react3d` builds and runs against the real engine where a C toolchain exists (verified in this container: linux-x86_64). Other targets need a compatible archive first (that package's BUILDING.md). `server_sdk` movement tests never touch the engine: they run against the deterministic kinematic backend (`test/kinematic_backend.dart`), which Euler-integrates the same movement parameters over an infinite floor plane, so authority logic is tested independently of FFI availability. --- # Hosted node HTTP API (reference page) The control-plane HTTP API manages rooms on a rooms node: create rooms, mint one-time join tokens, look rooms up, drain them. One **service key** per deployment, sent as `Authorization: Bearer `. The endpoints at a glance: | Method & path | Purpose | | --- | --- | | `POST /v1/rooms` | create a room (template + config) → room URL + one-time join token | | `POST /v1/rooms/:id/tokens` | mint an additional one-time join token | | `GET /v1/rooms/:id` | room lookup (status, peers, pinned config) | | `GET /v1/rooms?limit=&cursor=` | cursor-paginated room list | | `DELETE /v1/rooms/:id` | drain: peers get WebSocket close 1001, may not resume | --- *Generated from `docs/control_plane_api.md` (embedded above via markdown import), including its “Node implementation notes” for the self-hosted node’s v0 deviations.* --- # Hosted node HTTP API (repository document) # Control-plane HTTP API — v0 contract This document is the **contract** for room management. Two implementations will exist and must behave identically: the hosted control plane (Cloudflare Workers + D1) and the local emulator (`roombase dev`). A third, in-process implementation now ships in this repo: the self-contained rooms node (`packages/node`) serves this API from its own listener over SQLite — see *Node implementation notes* at the end for its v0 deviations. Items are marked **[DECIDED v0]**, **[DEFERRED]**, or **[TODO]**. The data plane (a node running the `server_sdk` room server behind a supervisor) is out of scope for this document except where the contract touches it (join tokens, room URLs, drain semantics). Product shape: a **hosted room service**. Users do not write Dart. They create rooms through this HTTP API and connect clients; the server runs platform-provided authorities (see `room_templates.md`). Two template types launch together: **relay** (client-authoritative state rows, quota-enforced — core implemented in `server_sdk` as `RelayAuthority`) and **movement** (server-simulated physics — spec only in v0). ## Authentication - **[DECIDED v0]** One **service key** per deployment, sent as `Authorization: Bearer `. Plain constant-time comparison. - **[DEFERRED]** Per-game publishable/secret key pairs, quotas and billing meters per key. The v0 single key keeps the emulator and the Workers implementation trivially compatible; the header shape will not change. All requests must be HTTPS (emulator: HTTP on localhost). Missing/wrong key → `401 unauthorized`. ## Room lifecycle ### `POST /v1/rooms` — create a room Request body (JSON): ```json { "template": "relay", "config": { "...": "room config per room_templates.md, version 1" }, "region": "ap-southeast-1" } ``` - `template` — **required**, `"relay"` or `"movement"`. Relay runs everywhere; `movement` runs on nodes wired with the physics backend (`"movement": true` in the node config, the default for the shipped image). A node that does not run a template returns `unsupported_template`. - `config` — **required**, validated against the template schema in `room_templates.md` **before** the room is provisioned. Unknown fields are rejected; configs are pinned verbatim at creation (append-only evolution — see that document). - `region` — **optional**. **[DEFERRED]** Single-region v0; if present it is recorded and ignored. Response `201 Created`: ```json { "roomId": "rm_9f3k2", "roomKey": "7YPL-xKq3-...", // unguessable; appears in the room URL "template": "relay", "url": "wss://rooms.example.com/play/7YPL-xKq3-...", "joinToken": "jt_C1AA...-only-shown-once", "joinTokenExpiresAt": "2026-12-01T12:15:00Z", "creatorToken": "ct_...", // relay rooms: present with the first join token "createdAt": "2026-12-01T12:00:00Z", "status": "live" } ``` - **[DECIDED v0]** The room URL is signed by the control plane and embedded in the response (`url`). The signature authenticates the room-to-node binding; the node re-verifies it before the WebSocket upgrade. - **[DECIDED v0]** Exactly **one** one-time join token is minted at creation (see *Token semantics*). Additional tokens are minted per join via `POST /v1/rooms/:id/tokens` — rooms need more than one player. - `creatorToken` — for relay rooms, the creating client presents this claim to become room leader. **[DECIDED v0]** opaque secret; **[TODO join tokens]** both tokens become signed JWTs carrying `{"room": …, "creator": bool, "exp": …}` and `RelayAuthority`'s constructor seam is fed by claim verification instead of a string comparison. ### `POST /v1/rooms/:id/tokens` — mint a join token **[DECIDED v0]** Body (optional): `{"creator": false}`. Response `201`: `{"joinToken": "jt_…", "joinTokenExpiresAt": "…"}`. Tokens are one-time and expire (v0: 15 minutes). Only the service key holder (the game's backend) may mint tokens; minting is rate-limited **[DEFERRED]**. ### `GET /v1/rooms/:id` — room lookup Response `200`: `roomId`, `roomKey`, `template`, `status` (`live|draining|closed`), `createdAt`, `peers` (currently connected count), `capacity`, `config` (the pinned config), `url` (always present; the URL itself is not one-time). Token state is **not** exposed (tokens are bearer secrets; only mint-and-show-once). ### `GET /v1/rooms?limit=50&cursor=…` — list rooms **[DECIDED v0]** Cursor pagination (`limit` 1–200, default 50; response carries `nextCursor`, absent on the last page). Ordered by `createdAt` descending. Same shape as lookup, without `config` (list stays small). ### `DELETE /v1/rooms/:id` — delete / drain a room **[DECIDED v0]** Drains: the node closes the room; peers receive WebSocket close code `1001` and may not resume. Response `204`. Deleting a closed room is idempotent (`204`). **[DEFERRED]** drain grace period / scheduled drain. ## Token semantics - **One-time join.** A join token authorizes exactly one successful WebSocket join on its room URL. The node validates it **before** the WebSocket upgrade (path + token + origin allowlist; see the supervisor auth-gate task) and marks it consumed on success. - **[DECIDED v0]** A join token travels as a query parameter: `wss://host/play/?token=`. - **Reconnects do not need a new token.** Within the room's resume grace window (default 15 s) the client resumes with the room protocol's resume token, which is unrelated to join tokens. Beyond the grace window a fresh join — and therefore a fresh join token — is required. - **[TODO reconnect rendezvous]** Today `RoomClient` retries a **fixed** `uri` forever (backoff capped ~4 s). With expiring one-time tokens this hammers a dead URL after the token lapses. The client SDK will grow a rendezvous callback (re-ask the game backend / control plane when the retry budget is exhausted). Until then, direct self-hosted `ws://` use is unaffected and remains the documented escape hatch. - `creatorToken` follows the same rules and additionally carries the leader claim (see `room_templates.md` → relay → ownership). ## Error shape Every non-2xx response carries a JSON body: ```json { "error": { "code": "invalid_config", "message": "human readable", "field": "config.playerKeys.pose.maxBytes" } } ``` - `field` — optional, dotted path of the offending input. - Codes → statuses **[DECIDED v0]**: | Code | HTTP | When | | --- | --- | --- | | `unauthorized` | 401 | missing/invalid service key | | `not_found` | 404 | unknown `roomId` | | `unsupported_template` | 422 | template exists but no node runs it yet | | `invalid_config` | 422 | config fails `room_templates.md` validation | | `invalid_request` | 400 | malformed body/params | | `quota_exceeded` | 429 | room/token creation over quota | | `conflict` | 409 | e.g. DELETE while a conflicting operation holds | | `internal` | 500 | anything else; message must not leak internals | **[DEFERRED]** request-level rate limiting, idempotency keys on POST, audit log, webhooks/events for room lifecycle. ## Consistency with the rest of the system - Config validation rules live in `room_templates.md` (single source of truth); this API only references them. - The data-plane node's pre-upgrade auth gate (origin allowlist + join-token validation + per-IP throttling) is specified in the platform plan's supervisor task; this document fixes what the node must verify: valid signature on `url`, unconsumed matching `token`, room not draining. - The emulator implements this contract in-process with an in-memory store; "identical client code path offline vs hosted" is its acceptance test. ## Node implementation notes (v0) The rooms node (`packages/node`, `bin/node.dart`) implements this contract in-process on its single listener, over SQLite (`rooms`, `token_uses`, `api_keys` tables; see `server_sdk` → `NodeStore`). Deviations from the letter of the contract, all v0-scoped: - **Room URL signature.** The contract has the control plane sign the room URL and the node re-verify that signature. In the single-node v0 the **join token is the credential**: the URL is built from the configured `publicBaseUrl` and carries no separate signature; pre-upgrade token verification (HMAC under the node secret, expiry, room binding, one-time consumption) authenticates the join. A signed-URL scheme rides the same HMAC machinery when multi-node routing arrives. - **Draining.** `DELETE` closes the room immediately (peers get `1001`); the `draining` status is never emitted — a room is `live` or `closed`. - **API keys.** Static keys from the node config, seeded into the `api_keys` table at boot (constant-time compare). Per-game keys extend the same table. - **One port.** Control plane and data plane share the listener; `/v1/*` vs `/play/` routing. TLS terminates at the reverse proxy (Caddy in the shipped compose file). - **Restart semantics.** Room **state** is memory-only; rooms pinned in SQLite are lazily recreated from their config on the next join after a node restart (fresh state, same room key). Join-token consumption persists in SQLite, so minted tokens survive restarts. - **Token TTLs.** Minted join tokens default to 15 minutes (`joinTokenTtlSeconds`, 60–86400 at the node). `POST …/tokens` with `{"creator": true}` returns the room's existing creator token alongside the minted join token. --- # Self-hosting One process, one port; TLS terminates at a reverse proxy. The shipped `docker-compose.yml` runs the rooms node behind **Caddy** (automatic Let's Encrypt for your `DOMAIN`, WebSocket upgrades transparent) and serves this static site from the same origin. ## Quick start (docker compose) ```sh git clone && cd rooms_sdk # 1. DNS: point your domain's A/AAAA record at this machine. # 2. Secrets: cat > .env < EOF # 3. Node config (see the reference below): cp deploy/node.example.json node.json # then edit secrets # 4. Launch: docker compose up -d ``` What comes up: | Service | Role | | --- | | `rooms-node` | the node binary (`packages/node`): `/v1/*` HTTP API + `/play/` WebSockets on one port, SQLite in the `rooms-data` volume | | `site` | one-shot init container that builds this site and publishes `dist/` into the shared `rooms-site` volume | | `caddy` | TLS (HTTP/2 + HTTP/3), proxies `/v1/*` and `/play/*` to the node, serves everything else (docs, `llms.txt`, `/spec/*`) from the site volume | The image is a multi-stage build: the build stage compiles the ReactPhysics3D native library and the AOT bundle (`dart build cli` — native assets linked by build hooks); the runtime is `debian:bookworm-slim`, unprivileged, with `libsqlite3`. ## Node config reference (`node.json`) | Field | Type | Default / rule | Meaning | | --- | --- | --- | --- | | `publicBaseUrl` | string | — | Origin used to build room URLs (`wss://…/play/`); must match the public domain behind Caddy | | `nodeSecret` | string | — | HMAC key for join/creator tokens; generate 32+ random characters | | `apiKeys` | object | — | Service keys (name → secret) seeded into the `api_keys` table; sent as `Authorization: Bearer ` | | `dataDir` | string | `/var/lib/rooms` | SQLite directory (persist it — join-token consumption and room pins survive restarts) | | `listen.port` | int | 8787, 1024–65535 | Node listen port (plain HTTP; TLS is the proxy's job) | | `listen.address` | string | `0.0.0.0` | Bind address | | `allowedOrigins` | string[] | — | Origin allowlist enforced before the WebSocket upgrade | | `limits.maxRooms` | int | 256 | Concurrent rooms; beyond → `quota_exceeded` | | `limits.maxReservedCapacity` | int | 4096 | Sum of room capacities; beyond → `quota_exceeded` | | `limits.idleTtlSeconds` | int | 300 | Idle rooms stop ticking but keep room + state in memory | | `limits.connectionsPerIpPerMinute` | int | 60 | Pre-upgrade per-IP throttle | | `joinTokenTtlSeconds` | int | 900 (60–86400) | Minted join-token lifetime | | `movement` | bool | `true` | Wire the reactphysics3d movement backend (`false` → movement rooms return `unsupported_template`) | Config is fail-fast: unknown or out-of-range fields abort startup. ## TLS via Caddy `deploy/Caddyfile`: ```txt {$DOMAIN} { encode zstd gzip handle /v1/* { reverse_proxy rooms-node:8787 } handle /play/* { reverse_proxy rooms-node:8787 } handle { root * /srv/site try_files {path} {path}/ file_server } } ``` - Set `DOMAIN` (and optionally `ACME_EMAIL`) in the compose environment; certificates are issued and renewed automatically. - HTTP/3 is enabled (`443/udp` is published). - Node routes are explicit; everything else is the static site, so `https://your-domain/` is the docs, `https://your-domain/v1/…` the API, `https://your-domain/llms.txt` the agent index. ## Deployment checklist (e.g. Hetzner) 1. A server with Docker and ports 80/443 (TCP + UDP 443) open. 2. DNS A/AAAA record for your domain → server IP (must resolve before Let's Encrypt will issue). 3. `.env` with `DOMAIN`, `ACME_EMAIL`, `GITHUB_TOKEN` (read access to the private reactphysics3d_dart dependency for the image build). 4. `node.json` from `deploy/node.example.json` — new `nodeSecret`, real `apiKeys`, your game origins in `allowedOrigins`, `publicBaseUrl` = `https://`. 5. `docker compose up -d --build`; check `docker compose ps` and `docker compose logs caddy`. 6. Smoke test: `curl -s https:///v1/rooms` → `401` (Caddy + node + auth all working); `https:///llms.txt` → this site. 7. Backups: the `rooms-data` volume (SQLite). Room **state** is memory-only by design; rooms are lazily recreated from their pinned config on the next join after a restart. ## Running without Docker ```sh cd packages/node cp ../../deploy/node.example.json /tmp/node.json # then edit secrets dart run bin/node.dart --config /tmp/node.json # movement physics included ``` The node handles SIGINT/SIGTERM by draining (rooms closed, peers get 1001). See the [HTTP API reference](/docs/api/) for the endpoint contract and its “Node implementation notes”. ## Docs elsewhere, node here (split origins) This compose file serves the docs from the same origin as the node (`https:///`), which is the single-origin convenience path. The site is plain static output, so it can equally be deployed to Cloudflare Pages (docs there, node here — see `site/README.md` for the wrangler and git-integrated paths). That is two origins and nothing breaks: - the site's internal links are same-origin paths (`/docs/…`, `/llms.txt`, `/spec/…`) — they resolve under whichever domain serves them; - the hosted API's base URL is not hardcoded anywhere in the site: the API reference documents `https:///v1/…`, and each node deployment advertises its own origin via `publicBaseUrl` in `node.json` (it builds the room URLs clients connect to). --- *Generated from `docker-compose.yml`, `deploy/Caddyfile` and `deploy/node.example.json` in the repository.* --- # RoomAuthority interface contract (spec) # RoomAuthority — the interface contract `RoomAuthority` (`packages/protocol/lib/src/room_authority.dart`) is the entire game-side surface of a room: validation, simulation and snapshots. The hub (`RoomHub`) owns every networking concern — transports, handshakes, acks, rate limits, baselining, deltas, chat, moderation — and calls into the authority at precise, documented points. Networking code never imports game types and vice versa. ```dart abstract interface class RoomAuthority { int get capacity; int get tick; double get elapsed; void update(double seconds); Object? validateJoin(Map message); void addPlayer(String id, String name, Object? joinData); void resume(String id, Object? joinData); void disconnect(String id); void removePlayer(String id); Map welcome(String id); void command(String id, Map message); Map snapshot(); void dispose(); } ``` All calls happen on the room's single isolate, synchronously, either from the pump loop (`pumpSeconds` / the periodic timer) or from the transport message handler. There is no concurrency control to write; there is also no re-entrancy: the hub never calls the authority from inside another authority call. ## Method reference ### `int get capacity` Maximum connected players. Read by the hub when a **fresh join** arrives: `_peers.length >= capacity` refuses the join with a `StateError('Room is full')` → `error` frame + close 1008. Also validated `>= 1` at `RoomHub` construction (smaller values throw `ArgumentError`). **Invariants:** stable or growable during live operation is fine — the hub re-reads it per join; shrinking below the current peer count does not evict anyone. ### `int get tick` Simulation tick counter. Read every pump to decide the snapshot cadence (`tick - lastSnapshot >= snapshotTicks`) and the recovery cadence for stalled receivers (`tick - peer.recoveryTick < simulationHz`). **Invariants:** integer, monotonic non-decreasing, advanced in `update()`. It is the room's clock; the hub never writes it. ### `double get elapsed` Simulated seconds. Drives two timeouts: the **resume grace window** (`elapsed - disconnectedAt > resumeGrace` reaps the player) and the **stall eviction clock** (`elapsed - lastAckAt > 5` while a receiver is blocked). **Invariants:** advanced in `update()` by the same `seconds` the hub passed in (directly or accumulated). Must not jump wildly: wall-clock stalls are clamped by the fixed-step clock, and `elapsed` should reflect that clamped time, not real time. ### `void update(double seconds)` Advance the simulation by one fixed step. Called only from the fixed-step clock: `seconds` is always exactly `1 / simulationHz` (default step 1/120 s). The clock clamps catch-up to **0.1 s per advance**, so a stalled process never dilates simulated time — `update` may be called many times back-to-back after a stall, each with the same fixed step. **When:** started by `hub.start()` (periodic timer) or driven manually via `hub.pumpSeconds(seconds)` (tests, externally driven hosts). **Invariants:** must advance `tick` and `elapsed`; must not throw (an exception propagates out of the pump into the timer/test). This is the only method that mutates simulated state on a schedule. ### `Object? validateJoin(Map message)` Inspect the raw decoded `join` message — including game-specific fields (the relay template reads its `creatorToken` claim here, for example). Called on **every handshake, fresh join and resume alike**, before any hub or authority state changes. **Return:** an opaque `joinData` object the hub hands back to `addPlayer` / `resume`. **Invariants:** throw `FormatException` or `StateError` to reject the join — the message text is forwarded to the client in an `error` frame and the transport closes 1008. Keep it pure: it runs before the peer exists, and on resume it runs on every reconnect attempt. ### `void addPlayer(String id, String name, Object? joinData)` A brand-new player joined. `id` is the hub-assigned stable peer id (`p1`, `p2`, …) used in every later call and in `snapshot().players`; `name` is the trimmed 1–24 character display name; `joinData` is what `validateJoin` returned. **When:** fresh joins only (never resumes), after `validateJoin` and before `welcome`/snapshot frames are sent. **Invariants:** allocate per-player state here. Do not throw — the peer is registered after this call returns; a throw aborts the handshake mid-way (the client gets an error frame + close 1008). Reject players in `validateJoin` instead. ### `void resume(String id, Object? joinData)` The peer has a live transport again. Called on **every successful handshake — fresh joins too** (right after `addPlayer` for joins, after token validation for resumes), after `peer.transport` is attached and ack counters reset. **Invariants:** reset transient per-player state that should not survive a reconnect (the movement template clears held input here). Receiving `resume` without a preceding `disconnect` means this is a fresh join. Do not throw. ### `void disconnect(String id)` The peer's transport was lost (network drop, close, eviction candidate). The player's **slot stays reserved** for `resumeGrace` (default 15 s): the roster still lists them with `connected: false`, their row survives in snapshots, and a resume with their token restores everything. **Invariants:** stop depending on live input; keep state. Replaced transports (resume elsewhere) trigger `disconnect` on the old transport before `resume` arrives on the new one. Do not throw. ### `void removePlayer(String id)` The player is gone for good. Called on: a `leave` frame, a kick/ban, or the resume grace window expiring (reap). The hub also removes the peer from its roster, social state and any ban bookkeeping it owns. **Invariants:** release all per-player resources; no further calls reference this id (ids are never reused). Do not throw. ### `Map welcome(String id)` Per-player join payload. Spread into the `welcome` frame. The hub forces `type`, `protocol`, `id`, `resume` and `capacity` afterwards — same-named keys are overwritten. **When:** once per successful handshake, before the baseline snapshot. **Invariants:** must be JSON-encodable and small (it rides the reliable control lane inside the 4 KB frame budget). Examples: the counter game returns `{'sequence': 0}`; the movement template adds the assigned spawn point. ### `void command(String id, Map message)` Everything else a client sends: any post-handshake frame whose type is not `join`/`ack`/`chat`/`ping`/`moderate`/`leave`. The message arrives verbatim (including `type` and any piggybacked `ack` field), after the hub's 120 packets/s input rate limit and stall buffering. **Invariants:** an exception thrown here is treated as a **protocol error** (`error` frame + close 1008) — if bad input should be survivable, catch it yourself and drop-and-count instead (the platform templates do exactly this: relay rejects over-quota publishes, movement rejects invalid inputs, neither ever throws). Commands reference ids from `addPlayer`/`resume`. Input is never trusted: it is a request, state is authoritative in `snapshot()`. ### `Map snapshot()` The complete authoritative state. Called on every successful handshake and at every snapshot cut (`snapshotHz`). **Invariants:** - Must be a fresh JSON-encodable map (the encoder detaches and retains it as a baseline — never return a live mutable reference you keep mutating). - Must contain integer `epoch` and `tick` keys: the encoder keys baselines on them, and epoch changes force a new baseline on every receiver. - Conventionally `type: 'snapshot'` plus game state under `players`/`room` (relay rows, movement poses + config echo) — the shape beyond `epoch`/ `tick` is entirely game-defined. - Floats are quantized to 4 decimal places at the encoder; there is no need to round in the authority. - Must not throw. ### `void dispose()` Called exactly once, when the room closes (`hub.close()`), after all transports are closed. No further calls follow. ## Optional: `RoomSocialAuthority` ```dart abstract interface class RoomSocialAuthority { String? get roomLeaderId; PingLocation? pingLocation(String playerId, String kind); } ``` - `roomLeaderId` — declares the leader instead of the hub's first-connected peer fallback (relay/movement rooms return the creator). Read on every roster broadcast and moderation check. - `pingLocation(playerId, kind)` — the authoritative coordinates for a ping (`here`/`help`/`ball`); `null` declines the ping (the player gets a `socialNotice`). Coordinates **never** come from the client's ping frame. ## A complete (minimal) implementation ```dart class CounterAuthority implements RoomAuthority { final counters = {}; @override int capacity = 8, tick = 0; @override double elapsed = 0; @override void update(double seconds) { elapsed += seconds; tick++; } @override Object? validateJoin(Map message) => null; @override void addPlayer(String id, String name, Object? data) => counters[id] = 0; @override void resume(String id, Object? data) {} @override void disconnect(String id) {} @override void removePlayer(String id) => counters.remove(id); @override Map welcome(String id) => {'sequence': 0}; @override void command(String id, Map message) { if (message['type'] == 'increment') counters[id] = counters[id]! + 1; } @override Map snapshot() => { 'type': 'snapshot', 'epoch': 0, 'tick': tick, 'time': elapsed, 'players': {for (final e in counters.entries) e.key: {'count': e.value}}, }; @override void dispose() {} } ``` *Generated from `packages/protocol/lib/src/room_authority.dart` and the call sites in `packages/protocol/lib/src/room_hub.dart`. Cross-checked by `site/scripts/check-spec.mjs` at build time.* ---