Skip to content

Wire protocol

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.

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 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
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
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
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
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
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

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/<roomKey> 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.


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).