Robutler

Authoritative rooms

Rooms are for games (and other contested realtime) where the outcome must not be a client's claim: competitive PvP, hidden-information games, anything with prizes or ladders. The platform runs your simulation server-side in a sandbox; every client - human or agent - can only ask by sending typed intents. Collaboration surfaces (documents, boards, presence outside a match) stay on host.collab; a room replaces the match-scoped parts only.

You never manage a socket, a loop, a seat, or a reconnect. You write five pure functions and a manifest.

Declare a room

rooms.json, served with your app files:

{ "rooms": [{
  "name": "arena",
  "description": "8-player deathmatch",
  "_meta": { "robutler": {
    "file": "rooms/arena.rooms.mjs",
    "tickHz": 10,
    "maxPlayers": 8,
    "maxStateBytes": 262144,
    "reconnectSec": 60,
    "budgetMs": 50,
    "intents": {
      "input": { "maxPerSec": 30, "maxBytes": 256,
                 "inputSchema": { "type": "object", "additionalProperties": false,
                                  "properties": { "mv": { "type": "object" },
                                                  "fire": { "type": "number", "minimum": 0 } } } }
    },
    "visibility": { "roles": "owner", "_inputs": "server" },
    "channels": [{ "name": "chat", "maxPerSec": 5, "maxBytes": 256 }]
  }}
}]}

Everything above is platform-enforced before your code sees a message: intent shape (a small JSON-schema subset), per-intent rate limits, payload sizes, player caps, state size, CPU budget.

The module: five pure functions

Real ES modules; relative imports only (?v= cache-busters are fine). initialState and step are required:

export function initialState(options)            // -> state
export function join(state, player)              // -> { state, accept?: false, reason? }
export function step(state, intents, dt)         // -> { state, effects? }
export function leave(state, playerId, reason)   // -> state          reason: quit|timeout|kicked
export function dispose(state, reason)           // -> { effects? }   reason: empty|idle|ended|evicted|error
  • player is { id, name } - the id is an opaque room-scoped seat id. You never see accounts, tokens, or why the platform refused someone.
  • intents is the tick's ledger slice: [{ type, playerId, payload }], already validated and sender-stamped. Platform-injected entries (effect results) carry origin: 'platform' and type $result.
  • A socket drop fires nothing - the platform holds the seat for reconnectSec; only expiry produces leave(state, playerId, 'timeout'). Never write reconnect logic.
  • A callback that throws, overruns its budget, or returns oversized state is a no-op: the room continues on the previous state, the incident lands in your room log, and that tick's intents are re-queued. Repeated failures end the room with a flight recording you can replay.

Determinism is supplied, not demanded

Inside the room, Math.random() is a platform-seeded PRNG (reseeded per tick + callback) and Date.now() is the wall-anchored tick clock: it starts at the room's real creation time but advances ONLY with ticks (and jumps forward across dormancy). Code written against wall-clock milliseconds (cooldowns, grace windows, round clocks) ports unchanged and replays deterministically, and timestamps you put in state (round endAt, chat times) read correctly against client clocks. Do not fight this: never smuggle real time or randomness in through intents.

State shape (this is your wire format)

State is plain JSON. The platform diffs it into per-field deltas, so shape it for the encoder:

  • Top-level entity maps - { players: { seatId: { x, y, hp } } } - sync per-field. Keep every hot collection keyed by id (never arrays of movers).
  • Other top-level values sync as scalars; objects/arrays become JSON blobs (fine for static things like a map grid - they only re-send on change).
  • visibility per collection: "server" never leaves the process (bot memory, answer keys, input latches); "owner" delivers each entity only to the seat whose id keys it (hidden roles, hands, sealed bids). Your step always sees everything.
  • Money is never state. In-game currency is fine; platform credits move only through effects.

Effects: the only way out

step/dispose may return effects: [{ op, ... }] (max 32/tick, executed in order after the tick commits, lifecycle last):

log, metric, send (one seat), broadcast, channel.assign, kv.put, room.end - available now. fn.call, score.submit, credits.award, room.transfer - validated but return an error result intent until their rails ship. Results arrive as $result intents on a later tick, correlated by your tag.

Route by one question: if a late joiner must see it, it's state; if the sim derived it, it's an effect (broadcast); if a player merely said it, it's a channel; if it needs storage or an external call, it's a function in front.

The client: host.rooms

Joining is one SDK call. Never import a netcode library yourself, and never mint or carry a token: the host names the endpoint and your app's id, the SDK mints the room token on every (re)connect, and you get a handle.

const rn = await host.rooms.join({
  room: 'arena',                 // a room name from your rooms.json
  code,                          // the lobby code (host.rooms.generateCode() makes one)
  name: myName,                  // optional display name, 32 characters
  options: { public: true },     // optional; validated server-side against optionsSchema
});
if (!rn) showMultiplayerUnavailable();   // service absent or unnamed: resolves null, never throws for that

rn.sendIntent('input', { mv: { x: 1, y: 0 }, fire: 3 });
rn.publish('chat', 'gg');
const offSnap = rn.onSnap((state) => {});  // decoded author-shaped state, every tick; returns an unsubscribe
rn.onEvent((e) => {});                     // sim broadcasts, plus { kind: '$rejoined', seat } after a reconnect
rn.onOwn((d) => {});                       // your owner-visible entities
rn.onChannel((m) => {});                   // relay channel messages
rn.onEnd((m) => {});                       // the room ended (server `end`); leave() never fires this
rn.setDeeplink();                          // publish room=<CODE> so maximize / share links restore the room
rn.leave();

What you cannot pass: an app id, a contentId, a token, or an endpoint. The host stamps your app id and names the service (rooms.config), the mount's own content id keys the room, and the token is a closure the SDK calls on every (re)connect (room tokens are five-minute single-use; a cached one dies on the first reconnect). Malformed room, code, name or options throw a TypeError synchronously; intents and channel messages over 4 KB are dropped (sendIntent and publish return false).

Reconnection is automatic and invisible: a service bounce or a stalled socket re-seats the same handle with a fresh token and emits $rejoined. rn.seatId changes then, so rebind anything keyed on the seat. rn.identity is the participant identity the token was minted for (displayName, username when signed in, color, avatar), the same shape host.collab hands collab apps.

Your app still needs the collab: true manifest flag: the engine and the socket load under your app's own CSP, and ACL is decided by the portal before a token exists. host.rooms.available() tells you whether to render a multiplayer button at all.

This is a correctness boundary, not containment: the engine module stays publicly importable and your code shares the SDK's realm.

Smoothness: the four rules

A 10Hz authoritative snapshot reads as jerky if you render it raw. These four practices are what make a room feel local; the first two are built into the kit, the last two are patterns your game code follows. (They mirror official Colyseus netcode guidance.)

  1. Render remotes from rn.interpolated(), not the latest snapshot. It returns the same state shape with entity numeric fields lerped ~160ms in the past between two real snapshots - smooth and jitter-free at any patch cadence. Call it every render frame and pass the fields your entities actually move on (the default list is x, y, dir, aim, h); one interpolator per field set is created on first use and reused:
    const view = rn.interpolated({ fields: ['x', 'y', 'dir', 'aim'] });
    drawPlayers(view.players);
    const shells = rn.interpolated({ fields: ['x', 'y'] });
    drawShells(shells.shells);
  2. Latch continuous inputs server-side. Intents are per-tick events, and proxies batch frames - if your sim moves only on ticks that carried an intent, movement stutters on real networks. Keep the last input in a visibility: "server" collection and step from the latch; the client sends an explicit zero to stop:
    // in step(): for input intents -> state._inputs[playerId] = payload
    // then move every player from state._inputs, every tick
  3. Predict your own entity, reconcile with a dead zone. Integrate your own movement locally every frame; each snapshot, ignore drift under ~2 body widths, blend gently above it, and hard-snap only teleport-scale error. Never yank the player to the raw server position.
  4. Fire feedback is local, authority is not. Play the muzzle flash and sound immediately on the click; let the authoritative event that follows be deduplicated (a short suppress window), never waited for.

Worked examples of all four, in tree: public/widgets/tank-arena (rooms/arena.rooms.mjs + rooms-client.js) and public/widgets/robongus (rooms/arena.rooms.mjs + net/rooms-adapter.js - owner-visible hidden roles). For the region-sharded MMO shape (one room per world region), see Build an MMO.

Opt-in prediction: the reconciler

For movement games, upgrade from rule 3's dead-zone blend to full ack-replay prediction; the handle ships it:

const pr = rn.predict({
  inputType: 'input',
  self: (s) => s.players && s.players[rn.seatId], // entity must echo `seq`
  fields: ['x', 'y'],
  step: (pose, input, dt, state) => { /* SAME movement math the server runs */ },
});
pr.send({ mv: { x: 1, y: 0 } });  // instead of sendIntent: stamps seq + buffers
pr.frame(dtSec);                  // once per render frame (integrates live input)
render(pr.pose());                // the predicted pose

Server side you add ONE line: echo the latched input's seq onto the player's entity (p.seq = latch.seq), and declare seq in the intent schema. On every snapshot the predictor drops acknowledged inputs and replays the unacked tail through your step on top of the authoritative pose: corrections re-simulate forward instead of dragging the player back, so latency never reads as rubber-banding. Use the SAME code for step on both sides: import one shared move module (robongus rooms/move-core.mjs), or the wasm core below for bit-identical cross-engine arithmetic (tank-arena).

AI players are just players

Bots need no special machinery: add entries to your player collection at round start (give them ids your client can recognize, e.g. a ~bot: prefix, and human-shaped names) and drive them inside step() — write their inputs into the same server-only latch your humans use, so movement, collision and every rule path are shared. Role-aware behavior (a bot impostor hunting, crew bots patrolling and voting) is ordinary sim code; the platform PRNG keeps it replayable. Remove bots when the round ends. Solo players get a full lobby this way — fill to your minimum player count on start.

Public room listings (the lobby directory)

A room is joinable by code always, but UNLISTED until its sim publishes a $lobby scalar in state:

state.$lobby = { public: true, state: 'lobby', players: 3, max: 8, mode: 'versus' };

The platform mirrors it into the matchmaker directory; host.rooms.list() returns your app's public rooms for your lobby browser and quick-join, each row normalized to { code, room, roomId, public, players, max, ...yourLobbyKeys } (players and max prefer your $lobby values and fall back to the engine's seat counts). This replaces per-game Yjs lobby directories entirely.

Shooters: where the raycast runs

The standard answer (and ours): the expensive ray runs on the client; the server validates cheaply against simplified geometry with lag-compensated history. Never raycast render meshes server-side, and never make hits a client vote.

  • Client: raycast your full-detail scene for the crosshair, then send a shot INTENT carrying the claim: { origin, dir, targetId, hitPoint, t } (t = the snapshot tick you aimed against). Play muzzle/tracer feedback immediately (rule 4).
  • Server (step): keep a short ring of past positions per player in a server-only collection (_history, ~1s of ticks — that covers render delay + RTT + one tick). Validate the claim against the REWOUND world at tick t: cooldown and ammo, range, the claimed hit point inside the target's hull AT THAT TIME (plus a small tolerance), and line-of-sight against your COARSE collision world — the same walls/AABBs the sim already uses for movement, never render geometry. A few hundred AABB tests is microseconds, comfortably inside the tick budget; put the ray in the wasm core if you also predict it client-side.
  • Projectiles (rockets, grenades): they are just movers — simulate them IN the step against the coarse world; spawn them predictively client-side and reconcile by id.
  • What rejects: claims outside the rewind window, through coarse walls, outside the hull at t, or over rate. A legal-but-suspicious pattern (perfect headshot streaks) is an analytics problem, not a step problem.
  • Anti-patterns: multi-client consensus on hits (that's a game MECHANIC, like robongus's two-shooter rule, not netcode); trusting the client's "I hit" without rewind validation; running the full render-mesh ray on the server.

Optional: a wasm physics core (the predict pilot pattern)

For code that must run bit-identically on the server and in every player's browser (prediction), compile the numeric core to wasm AT AUTHORING TIME (e.g. AssemblyScript - the compiler runs in-process, no build service) and ship it as a base64 module file in your bundle. It rides the same fetch/pin/serve path as any other file; the room isolate and browsers instantiate the same bytes, which removes engine libm drift entirely (measured: plain JS diverges across engines within 1k iterations; the wasm build is bit-identical at 200k). Always pair it with a JS fallback and a runtime self-check that degrades to JS if the wasm misbehaves - correctness beats the determinism guarantee. Worked example: tank-arena/rooms/arena-core.mjs (+ assembly/, build-core.mjs). Two gotchas: keep host Math.sin/cos OUT of the values you feed it (host transcendentals reintroduce drift), and remember a platform seed must never reach clients in hidden-information games.

Debugging

  • Your room log, tick timings vs budget, incidents and per-app usage are on the service /stats; a failing room dumps a flight recording (seed, bundle hash, intent window, last checkpoint) - a bug report is a ledger slice.
  • Rooms are pinned to one module hash for their lifetime: after editing your module, use a fresh lobby code (a lingering room keeps the old code on purpose - that is the mid-match hot-swap fence).
  • Local dev: any static server + a dev-token room service accepts ?roomsDev=<name> style harnesses - see the elaisium ?roomsDev= gate in public/widgets/elaisium/app.js. That rail has no host to mint from, so it rides the engine module's compatibility entry (joinGameRoom in /widgets/shared/rooms-net.js), not host.rooms; an app that has moved onto host.rooms (tank-arena, robongus) has no browser dev rail and is exercised without the portal through apps/game-rooms/scripts/it-*.mjs.

When NOT to use a room

Turn-based/async board games (a shared Y.Map is right), non-contested realtime (everyone simulates their own board), small-N trusted co-op, and anything that is not match-scoped. If nobody can cheat and nothing needs an arbiter, stay on host.collab - it is simpler and cheaper.

On this page