Room templates
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 inserver_sdkasRelayAuthority. - Movement — server-simulated kinematic movement over configured static
colliders with anti-cheat by construction. Core implemented as
MovementTemplateAuthority+ thephysics_react3dbackend.
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:
- Relay — client-authoritative state rows (presence, chat, small
published state), quota-enforced. Core implemented in
server_sdkasRelayAuthority. - 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_sdkasMovementTemplateAuthority(engine-free) plus thephysics_react3dbackend package; the hosted control plane still returnsunsupported_templateuntil node wiring lands.
Common envelope
{ "version": 1, "template": "relay", "capacity": 16, "chat": true}version— required, must be1. Unknown versions are rejected.template— required,"relay"or"movement".capacity— required, integer1–128connected players.chat— optional, boolean, defaulttrue. Chat, pings and speech bubbles on/off. [DEFERRED enforcement] moderation (roster, lock, kick, ban) rides the same social lane but stays available even whenchatisfalse; 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
{ "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)
{ "type": "publish", "key": "pose", "value": { "x": 1.25, "y": 0, "z": -3.5 } }{ "type": "publishRoom", "key": "phase", "value": "started" }publishwritesplayers[<senderId>][key]— a player can only ever write their own row. There is no addressee field to forge.publishRoomwritesroom[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
creatorTokensecret at authority construction and the creating client presents it in its join payload; [TODO join tokens] the signed join token will carrycreator: trueand the authority seam switches to claim verification. - Leader powers (lock/kick/ban) and chat/pings/roster/history come from the
room social layer automatically —
RelayAuthorityimplementsRoomSocialAuthority, 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 finitex,y,znumbers.
Snapshot shape
{ "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
{ "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)
capacity1–128;tick.simulationHz10–240,snapshotHz1–60and≤ simulationHz(relay rooms default to 20/10 for density; movement rooms need the headroom).collidersat least one entry (each kind optional); every coordinate finite;halfExtentsandradius> 0and≤ 500; capsule segment length‖top−base‖ ≥ 2·radius(the engine’s cylinder height must be non-negative); collidernamelabels are optional clean strings (≤ 64 chars);heightfield.sizeeach dimension16–1024;data.length == (nx+1) * (nz+1); heights finite and|h| ≤ 10000.spawnPoints1–64, coordinates finite.movementvalues finite; speeds0.1–100;gravity-30–0;jumpSpeed0–50;airControl0–1;acceleration0.1–500.inputMapping1–16entries; names match the key-name rule above; axes declarerange[min, max], finite,min < max. The movement model has fixed semantics:moveXandmoveZmust be declared as axes (steering), and the reserved namesjump/sprintmust be actions if present; other declared names are whitelisted and validated but inert in v0 (reserved for future template semantics).inputsPerSecond— optional int1–240, defaulttick.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)
{ "type": "input", "moveX": 0.5, "moveZ": -1, "jump": false, "sprint": true }- Only fields declared in
inputMappingare accepted (plustype); unknown fields, wrong types (axis not a number, action not a bool) and non-finite numbers reject the whole message. Missing fields default: axes0, actionsfalse— 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:
inputsPerSecondper 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. jumpis 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
{ "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.
Generated from docs/room_templates.md (embedded above via markdown
import).