Skip to content

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

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

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

{
"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" }
  • publish writes players[<senderId>][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

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

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

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

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