Multiplayer contract

Canonical AI-consumption spec for b3.games multiplayer. 5 functions + 1 context object. Send this URL to a fresh Claude Code session along with your single-player game; the page below is everything Claude needs.

Solo (non-multiplayer) uploads — read this first if MP isn't your goal

Most of this page is about multiplayer. If you're uploading a normal single-player HTML game (no rooms, no other players), you don't need any of it. Skip everything below this section. Specifically:

  • Don't add the <meta name="b3-multiplayer" content="true"> tag. That tag is what tells the validator to apply MP rules. Without it, your file is treated as a regular HTML upload — none of the 14 MP rules apply.
  • Don't import /mp-client.js or load /mp-runtime.js. Those are the lobby SDK + determinism runtime; they only matter for MP games.
  • Don't install window.B3Multiplayer. The 5-function contract is for MP only.

What the platform requires for solo uploads:

  • Single self-contained HTML file served at /<slug> (where slug is auto-derived from your file name). All JS + CSS inline; no external <script src> / <link> calls (Cloudflare Workers blocks runtime eval — see memory cf_workers_blocks_eval_at_upload_time).
  • Viewport meta + 480×360 canvas convention: <meta name="viewport" content="width=device-width, initial-scale=1"> in <head>; canvas/play area sized 480×360 (scales via CSS in the platform iframe).
  • localStorage works per-origin. Use freely for high-scores, settings, etc. Cleared if the user clears site data; not synced across devices.
  • No prompt() / alert() / confirm(). The platform iframe sandbox is allow-scripts allow-same-origin allow-pointer-lock — modal APIs are silently blocked. Use in-frame <input> elements instead. (This rule is technically only validator-enforced for MP games, but the sandbox blocks them for solo too.)
  • Mobile-friendly: include touch fallbacks for keyboard-driven games.@media (max-width: 640px) at minimum; buttons min-height: 44px. (MP games have validator rules for these; for solo it's a soft requirement.)

Pause + restart on visibility change is your responsibility: document.addEventListener('visibilitychange', ...) → freeze your requestAnimationFrame loop when document.hidden === true. Mobile browsers will throttle/kill rAF on background tabs anyway, so handling this prevents game-state drift.

That's it. Solo uploads are deliberately hands-off — no platform contract beyond "valid HTML + the constraints above." The rest of this page is multiplayer-only.

Choose your game shape (decide first)

Multiplayer games come in shapes that determine architecture. The shape is a contract-level declaration, not just configuration: it constrains player count and the state-update pattern your code follows. Pick before you write code.

modePlayersPacingExamplesState pattern
turn-duel2Async (your-turn / their-turn)Chess, Reversi, Connect FourState updates only on input arrival
turn-party3-NAsync (round-robin)Mario Party, RiskSame as turn-duel, but N players
realtime-duel2Continuous (every tick)Pong, fighting gamesTick consumes queued inputs
realtime-party3-NContinuous (every tick)Mario Kart, party brawlerSame as realtime-duel, but N players
turn-team4-NAsync (team turn rotation)Team Risk, team Connect FourEach player has a TEAM + within-team SEAT. Requires b3-mp-teams meta.
realtime-team4-NContinuous (every tick)CTF, MOBA, team-deathmatch shootersRealtime-party + team derivation. Requires b3-mp-teams meta.
coop2-NEitherDrawing games, escape roomsPlayers cooperate against AI / scenario

Declare your choice via 3 meta tags in <head>. The validator requires all three. The platform server uses min-players / max-players to enforce caps — connection N+1 to a full room gets a room_full error. No implicit defaults — declare your design.

<meta name="b3-mp-mode" content="turn-duel">
<meta name="b3-mp-min-players" content="2">
<meta name="b3-mp-max-players" content="2">

Team modes need one extra tag. If you pick turn-team or realtime-team, also add <meta name="b3-mp-teams" content="N"> with N ≥ 2. Validator rule mp-teams-required-for-team-modes enforces this. Players are split across teams via memberIndex % teamCount. Within-team seat is floor(memberIndex / teamCount).

<meta name="b3-mp-mode" content="realtime-team">
<meta name="b3-mp-min-players" content="4">
<meta name="b3-mp-max-players" content="8">
<meta name="b3-mp-teams" content="2">

Per-mode implementation playbook (CR.14 E2)

Each mp-mode needs a different "who can do what when" rule. The 5-function shape (init/tick/onInput/getState/setState) stays constant — the click handler, broadcast pattern, and side-derivation change per mode. Pick your mode's subsection and copy the skeleton.

turn-duel — the chess pattern (host vs joiner, alternating turns)

Side mapping is anchored on connection.getMyRole() (room creator = side A; joiner = side B) with an index-fallback for legacy/standalone paths. Use B3MP.pickByRole so the SDK does the role→side map for you.

// Define your two sides as constants.
const SIDE_A = 0  // chess: GOLD, X-O: X, etc.
const SIDE_B = 1  // chess: SILVER, X-O: O

window.B3Multiplayer = {
  init({ players, myId }) {
    // CR.14 E2.1 — single source of truth for mySide.
    // Role-first; index-fallback only when role is unavailable.
    const role = connection.getMyRole?.()  // requires mp-client.js
    const indexFallback = (players.findIndex(p => p.id === myId) === 0) ? SIDE_A : SIDE_B
    state.mySide = B3MP.pickByRole(role, {
      host:    SIDE_A,
      joiner:  SIDE_B,
      fallback: indexFallback,
    })
    state.turn = SIDE_A  // first move always belongs to side A
    state.myId = myId
  },
  // ...
}

Click handler gates on TWO conditions: my piece, my turn. Both must be true. Then broadcast via ctx.emit AND apply locally — the server doesn't echo your own input back to you (memory of pain: this is a real footgun if you skip the local apply).

function onPieceClick(piece) {
  if (piece.side !== state.mySide) return       // not my piece
  if (state.turn !== state.mySide) return       // not my turn
  // ... show legal-move highlights ...
}

function onSquareClick(targetGx, targetGz) {
  const move = { from: state.selected, to: { gx: targetGx, gz: targetGz } }

  // 1. Broadcast to the other client.
  B3.ctx.emit('input', { t: 'move', from: move.from, to: move.to, fromSide: state.mySide })

  // 2. Apply LOCALLY (server fan-out skips sender).
  applyMove(move, /*fromNet*/ false)
}

Tick drain processes remote inputs. Each client buffers incoming inputs in a b3Intent map keyed by playerId; tick drains and applies them via the same apply function (with a from-net flag to suppress re-broadcast).

onInput(playerId, input) {
  // Don't apply immediately — buffer for tick to drain in deterministic order.
  if (!b3Intent[playerId]) b3Intent[playerId] = {}
  b3Intent[playerId].pendingMove = input
},

tick(dtMs) {
  // Process queued remote moves in playerId-sorted order (deterministic).
  for (const playerId of Object.keys(b3Intent).sort()) {
    const pending = b3Intent[playerId].pendingMove
    if (!pending) continue
    delete b3Intent[playerId].pendingMove
    applyMove(pending, /*fromNet*/ true)  // _fromNet=true suppresses echo
  }
},

Anti-patterns to avoid:

  • Don't re-derive mySide in init from member-index alone. If host bounces and reconnects, joinedAt sort puts joiner first → side flips mid-game. Always anchor on getMyRole() first.
  • Don't bypass your apply function (e.g., ctx.emit without local-apply). The server uses broadcastExcept(sender); you won't see your own input echoed back. The local state stays stale and subsequent peer inputs get rejected by your turn gate.
  • Don't forget the from-net flag. Without it, the apply triggers another ctx.emit → infinite loop.

realtime-duel — the pong pattern (2 players, every-tick input)

Side mapping uses the same role-anchor pattern as turn-duel — but there's no turn gate. Both players send input every tick (paddle position, fighter movement, ship thrust). The server fans inputs each tick; both clients apply ALL inputs in deterministic order.

const SIDE_LEFT = 0
const SIDE_RIGHT = 1

window.B3Multiplayer = {
  init({ players, myId }) {
    const role = connection.getMyRole?.()
    state.mySide = B3MP.pickByRole(role, {
      host:    SIDE_LEFT,
      joiner:  SIDE_RIGHT,
      fallback: (players.findIndex(p => p.id === myId) === 0) ? SIDE_LEFT : SIDE_RIGHT,
    })
    state.paddles = { [SIDE_LEFT]: { y: 0 }, [SIDE_RIGHT]: { y: 0 } }
    state.myId = myId
  },
  // ...
}

Input is captured on every keydown/keyup and broadcast via ctx.emit. There's NO gate on whose turn it is — both players always control their own paddle.

document.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowUp')   B3.ctx.emit('input', { dir: 'up',   phase: 'down' })
  if (e.key === 'ArrowDown') B3.ctx.emit('input', { dir: 'down', phase: 'down' })
})
document.addEventListener('keyup', (e) => {
  if (e.key === 'ArrowUp')   B3.ctx.emit('input', { dir: 'up',   phase: 'up' })
  if (e.key === 'ArrowDown') B3.ctx.emit('input', { dir: 'down', phase: 'up' })
})

Tick + onInput apply EACH player's input to THEIR OWN side. Use the playerId in onInput to look up that player's side (members[].role tells you who is host vs joiner; or just compare playerId to members[0].id for the host).

onInput(playerId, input) {
  const side = (playerId === hostMemberId) ? SIDE_LEFT : SIDE_RIGHT
  intentByPlayer[playerId] = { side, dir: input.dir, phase: input.phase }
},

tick(dtMs) {
  // Apply each player's intent to their paddle.
  for (const playerId of Object.keys(intentByPlayer).sort()) {
    const i = intentByPlayer[playerId]
    if (!i) continue
    const speed = i.phase === 'down' ? 200 : 0
    state.paddles[i.side].y += (i.dir === 'up' ? -speed : speed) * dtMs / 1000
  }
  // Advance ball, check collisions, etc.
}

Anti-patterns to avoid: same as turn-duel for side-derivation. Plus:

  • Don't gate input on a turn variable. realtime games are concurrent; both players always have agency. The "turn" concept doesn't apply.
  • Don't read live keyboard state in tick. Inputs must flow through ctx.emit so the OPPONENT receives them too. Direct keyboard reads desync.

turn-party — N-player round-robin (Mario Party, Risk)

Side mapping is by member-index, not role. 3-8 players each control a single avatar; role only tells you who CREATED the room (for "Host can start" UI). Use members[] position for fine-grained side.

window.B3Multiplayer = {
  init({ players, myId }) {
    // Index in the authoritative roster IS your seat number.
    state.mySeat = players.findIndex(p => p.id === myId)
    state.players = players.map((p, i) => ({ ...p, seat: i, score: 0 }))
    state.currentSeat = 0  // round-robin pointer; first player goes first
    state.myId = myId
  },
  // ...
}

Click handler gates on state.currentSeat === state.mySeat. After your turn ends, currentSeat = (currentSeat + 1) % players.length.

Use connection.getMyRole() ONLY for the "you are the room creator, you can kick the start button" affordance — NOT for game-mechanic side mapping.

realtime-party — N-player concurrent (Mario Kart, brawler)

Mix of realtime-duel + turn-party: side mapping by member-index (each player controls their own car/character), no turn gate (everyone plays simultaneously). N can grow to 8.

Full skeleton — covers init, tick, onInput, AND the input-emit side. Movement uses the same held-key dedupe pattern as realtime-duel; each player's input applies to their own avatar via playerId match.

// Layer 3 — gameplay state-machine for realtime-party
window.B3Multiplayer = {
  init({ players, myId, seed }) {
    const palette = ['#fb7185', '#60a5fa', '#34d399', '#facc15', '#a78bfa', '#fb923c']
    const playersById = {}
    players.forEach((p, i) => {
      const startX = 60 + (i % 4) * 100
      const startY = 60 + Math.floor(i / 4) * 100
      playersById[p.id] = { id: p.id, seat: i, name: p.name,
        color: palette[i % palette.length], x: startX, y: startY }
    })
    state = { tick: 0, players: playersById, myId, gameOver: false, finishTick: 30 * 60 }
  },
  tick(dtMs) {
    if (state.gameOver) return
    state.tick++
    const dt = dtMs / 1000
    // Iterate id-sorted (Pattern P8) for deterministic update order.
    for (const id of Object.keys(state.players).sort()) {
      const p = state.players[id]
      const i = intent[id] || {}
      let vx = 0, vy = 0
      if (i.left) vx -= SPEED
      if (i.right) vx += SPEED
      if (i.up) vy -= SPEED
      if (i.down) vy += SPEED
      p.x = clamp(p.x + vx * dt, RADIUS, ARENA.w - RADIUS)
      p.y = clamp(p.y + vy * dt, RADIUS, ARENA.h - RADIUS)
    }
    // Time-based end (deterministic via tick count, NOT Date.now):
    if (state.tick >= state.finishTick && !state.gameOver) {
      state.gameOver = true
      const scores = scoreByCellOwnership(state)  // see Color Splatter
      ctx.gameOver(scores)
    }
  },
  onInput(playerId, input) {
    if (!input || typeof input !== 'object') return
    if (typeof input.dir !== 'string') return
    setIntent(playerId, input.dir, input.phase !== 'up')
    // No turn gate — every player's input applies on every tick.
  },
  getState() { return state ? JSON.parse(JSON.stringify(state)) : null },
  setState(s) {
    state = s ? JSON.parse(JSON.stringify(s)) : null
    for (const k in intent) delete intent[k]
  },
}

// Input emit side (in your render layer / DOM handlers) — held-key dedupe
// keeps the rate under the 60 msg/sec server cap:
const heldKeys = new Set()
document.addEventListener('keydown', (e) => {
  const dir = KEY_MAP[e.key]; if (!dir || heldKeys.has(e.key)) return
  heldKeys.add(e.key)
  window.B3.ctx.emit('input', { dir, phase: 'down' })
})
document.addEventListener('keyup', (e) => {
  const dir = KEY_MAP[e.key]; if (!dir) return
  heldKeys.delete(e.key)
  window.B3.ctx.emit('input', { dir, phase: 'up' })
})

Win condition patterns: tick-count-based timer (above) is most common; also valid is "first to N" (e.g., first player to reach score >= 10) checked at end of tick.

Anti-patterns: (1) gating input on a turn pointer (locks everyone out in FFA); (2) Date.now() for the finish time (drifts across clients — use state.tick); (3) using DOM event order to disambiguate simultaneous events (browsers diverge — sort by playerId on collision).

coop — players cooperate against AI / scenario (drawing, escape room)

Side mapping doesn't apply in the adversarial sense — all human players are on the SAME team. The "opponent" is the game itself (puzzle state, AI, timer, etc.). Use member-index for affordances like "who's drawing now?" or "who answered the prompt last".

Full skeleton — covers concurrent-input handling (2+ players can submit simultaneously) with deterministic ordering, plus a shared timer.

window.B3Multiplayer = {
  init({ players, myId, seed }) {
    const playersById = {}
    players.forEach((p, i) => {
      playersById[p.id] = { id: p.id, name: p.name, seat: i, contribution: 0 }
    })
    // Seed shared puzzle deterministically using ctx.random() (NOT Math.random).
    state = {
      tick: 0, myId, players: playersById,
      pendingSubmits: [],
      timeRemainingTicks: 60 * 30,    // 60 sec at 30 Hz
      sharedScore: 0,
      gameOver: false, won: false,
      // Example shared puzzle: word chain
      chain: [pickStarterWord()],
      used: new Set([state?.chain?.[0]]),
    }
  },
  tick(dtMs) {
    if (state.gameOver) return
    state.tick++
    state.timeRemainingTicks--

    // CR.16 P0.3 — drain queued submits in DETERMINISTIC ORDER.
    // Sort by [serializedInput, playerId] so identical-tick concurrent
    // submits resolve identically on every client.
    state.pendingSubmits.sort((a, b) =>
      a.key.localeCompare(b.key) || a.playerId.localeCompare(b.playerId))
    for (const { playerId, payload } of state.pendingSubmits) {
      tryApplySubmit(state, playerId, payload)
    }
    state.pendingSubmits = []

    if (state.sharedScore >= GOAL && !state.gameOver) {
      state.gameOver = true; state.won = true
      ctx.gameOver(buildLeaderboard(state))
    } else if (state.timeRemainingTicks <= 0 && !state.gameOver) {
      state.gameOver = true; state.won = false
      ctx.gameOver(buildLeaderboard(state))
    }
  },
  onInput(playerId, input) {
    if (state.gameOver) return
    if (!input || typeof input !== 'object') return
    if (input.t !== 'submit') return
    // QUEUE — don't apply directly. tick() drains in deterministic order.
    const payload = String(input.value || '').toLowerCase().trim()
    if (!payload) return
    state.pendingSubmits.push({ playerId, payload, key: payload })
  },
  getState() {
    if (!state) return null
    // Sets aren't JSON-serializable; convert to array.
    const out = JSON.parse(JSON.stringify({ ...state, used: undefined }))
    out.used = Array.from(state.used)
    return out
  },
  setState(s) {
    if (!s) { state = null; return }
    state = JSON.parse(JSON.stringify(s))
    state.used = new Set(s.used || [])
  },
}

Concurrent-submit handling: the contract layer guarantees that inputs from the same tick arrive in the same frame on every client. Buffer them in state.pendingSubmits (which IS part of state, so getState captures it) and sort BEFORE applying in the next tick. This makes the tiebreak explicit and frame-order-independent.

Anti-patterns: (1) gating input on state.turn === mySide — locks everyone out (coop is free-for-all by design); (2) applying submits inline in onInput without sorting — non-deterministic across clients if frame ordering varies; (3) using a Set in state without an array conversion in getState — JSON.stringify drops Sets silently.

spectator-friendly — 1 to N watchers + a shared sim

Special mode for games with a SINGLE "active" player (or AI-driven sim) and N spectators. Set min-players=1 + max-players=high (e.g., 100 for a stream watch-party). Spectators receive frame broadcasts but can't emit input — gate input on connection.getMyRole() === 'host' (only the room creator plays).

init({ players, myId }) {
  const role = connection.getMyRole?.()
  state.canPlay = role === 'host'  // joiners are spectators
  state.myId = myId
}

document.addEventListener('keydown', (e) => {
  if (!state.canPlay) return  // spectators can't act
  B3.ctx.emit('input', { key: e.key })
})

Note: spectator-friendly is the only mode where min-players=1 makes sense. The host enters alone, plays, and shares the link for spectators to join.

turn-team — N-vs-N adversarial team turn-based (team Risk, team Connect Four)

Two side-mapping levels: each player has a TEAM (red vs blue) and a SEAT WITHIN their team (red-1, red-2, blue-1, blue-2). Team is derived from member-index modulo team_count; within-team seat is the floor-divide. Turn order rotates teams first, then within-team seat — like a standard card-game seating order.

Required meta: <meta name="b3-mp-teams" content="2"> in addition to the standard mode/min/max meta tags. Validator (mp-teams-required-for-team-modes) hard-fails without it.

window.B3Multiplayer = {
  init({ players, myId, teamCount }) {  // teamCount comes from start_game
    state.players = players.map((p, i) => ({
      ...p,
      team: i % teamCount,            // 0=red, 1=blue (or 0=red, 1=blue, 2=green for 3 teams)
      teamSeat: Math.floor(i / teamCount),
    }))
    state.me = state.players.find(p => p.id === myId)
    state.currentTurn = 0  // global pointer; rotates through ALL players
    state.teamScore = Array(teamCount).fill(0)
    state.myId = myId
  },
}

Click handler gates on state.players[state.currentTurn].id === state.myId. After your move, currentTurn = (currentTurn + 1) % players.length.

Win condition is per-team: typically you watch state.teamScore[k] for a target, or check team-wide control of objectives. UI should highlight teammates (same color) distinctly from opponents (different color).

Anti-pattern: don't derive team from connection.getMyRole(). Role is host vs joiner, NOT a team assignment. Roles tell you who allocated the room; team membership is index-based and symmetric across all players.

realtime-team — N-vs-N adversarial team realtime (CTF, MOBA, team-deathmatch)

Combination of realtime-party + team derivation: every player controls their own avatar continuously (no turn gate), but team membership defines spawn points, scoring, and friendly-fire rules. Same i % teamCount derivation as turn-team.

init({ players, myId, teamCount }) {
  state.avatars = players.map((p, i) => ({
    id: p.id,
    team: i % teamCount,
    teamSeat: Math.floor(i / teamCount),
    spawn: SPAWN_POINTS[i % teamCount][Math.floor(i / teamCount) % 4],
    pos: { ...SPAWN_POINTS[i % teamCount][0] },
    hp: 100,
  }))
  state.teamScore = Array(teamCount).fill(0)
  state.myId = myId
}

onInput(playerId, input) {
  // No turn gate — every player's input applies on every tick.
  const me = state.avatars.find(a => a.id === playerId)
  if (!me || me.hp <= 0) return
  if (input.t === 'move') me.pos = clamp(me.pos.x + input.dx, me.pos.y + input.dy)
  if (input.t === 'shoot') spawnBullet(state, me, input.dir)  // friendly-fire check uses me.team
  if (input.t === 'capture') tryCapture(state, me)            // increments state.teamScore[me.team]
}

Fire-and-forget data sync for discrete user-initiated actions (shoot, throw, click-to-place, ability-cast): flow through ctx.emit('input', { t: 'shoot', dir, weapon }). The server fans the input out as a frame, every client's onInput deterministically spawns the bullet. Don't try to communicate via postMessage between iframes — there's no out-of-band channel; discrete actions ride the input frame.

BUT: position-derived events (CR.16 P0.3) — pickup, collision, zone-entry — DO NOT emit as input. They resolve inside tick from the players' current positions. Movement is already an input; deriving the collision in tick is deterministic across clients (same positions + id-sorted iteration → same outcome). Emitting a separate { t: 'pickup' } input would race with the position updates from the same tick and produce desyncs.

Decision rule:

  • User pressed a button / clicked / typed? ctx.emit('input', ...). Examples: shoot, throw, place-marker, submit-word.
  • It happens because two positions overlap? → resolve in tick from state.players[i].x/y. Examples: pickup-by-walking-over, hit-by-projectile, zone-entry, collision.
  • It happens at a fixed time? → check state.tick in tick. Examples: respawn-after-death-timer, item-spawn-every-N-ticks, round-end.

The reference fixture mp-game-template-team demonstrates pattern 2 (coin pickup is a tick-collision, not an emit).

Death + respawn: in deterministic sim, dead-state is just hp <= 0 on the avatar. Reuse the spawn timer in tick(dtMs): when now - me.deathTime > RESPAWN_MS, reset hp + pos. No input needed — purely time-based on the deterministic clock.

Anti-patterns: (1) deriving team from role; (2) using Math.random for spawn jitter (use ctx.random()); (3) sending shoot events faster than your tick rate — the server batches per-tick, and inputs from the same client arrive in order.

Required markers (read this first)

A b3.games multiplayer game is recognized by three required tokens in the file. Miss any of them and the platform won't treat the file as multiplayer — the validator will hard-fail (rule mp-meta-missing) and a fresh uploader will see a vacuous "no checks" pass. These are the entry-bar:

  1. <meta name="b3-multiplayer" content="true"> inside <head> — the declaration. Without this, no MP rules apply.
  2. <script type="module" src="/mp-runtime.js"></script> inside <head> — loads Layer 3's determinism runtime (provides window.B3.ctx). Must be type="module" — classic <script src> SyntaxErrors at parse.
  3. import * as B3MP from '/mp-client.js' inside an inline module script — binds the lobby SDK namespace. All B3MP.* calls (mintGuestSession/hostRoom/joinRoom/connect/ready/onStartGame) require this import. There is no global window.B3MP.

The skeleton in the next section already has all three. Don't strip them out.

Importmap order (G30) — Safari + Firefox blocker. If your game uses bare ES module specifiers (e.g. import * as THREE from 'three') and declares an <script type="importmap">, the importmap MUST appear BEFORE the <script type="module" src="/mp-runtime.js"> tag. Per HTML spec, the document's import map freezes the moment any module loads — later importmap declarations are silently ignored on Safari + Firefox. Symptom: "TypeError: Module name 'three' does not resolve to a valid URL." Chrome is lenient and merges; the other engines strictly reject. Validator rule: mp-importmap-before-runtime.

<head>
  <meta name="b3-multiplayer" content="true">
  <!-- importmap FIRST when using bare specifiers -->
  <script type="importmap">{
    "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.158/build/three.module.js" }
  }</script>
  <!-- mp-runtime AFTER importmap -->
  <script type="module" src="/mp-runtime.js"></script>
</head>

A multiplayer game has 3 layers

The 5-function determinism contract you may have heard about is just Layer 3. It only runs after the user has entered multiplayer mode AND a room has filled. Before that, your game lives on Layer 1 (start screen) or Layer 2 (lobby).

LayerWhat it isSDK usedWhen it runs
1 · Start screenGame name + "Play Solo" / "Play Multiplayer" buttonsNone (pure DOM)On every page load
2 · LobbyHost/join with room code + share-link + member roster/mp-client.jsAfter Multiplayer is picked
3 · GameplayLockstep determinism — your 5-function contract/mp-runtime.jsAfter host calls connection.startGame() — fires onStartGame on every client; pass payload.seed to connection.startSelfDrivenTickLoop.

A game that skips Layer 1 will freeze on direct open (no parent harness = no init = stuck on a blank canvas). The reference mp-game-template implements all 3 layers in a single HTML file — copy that as your starting point.

TL;DR — the 3-layer skeleton

Copy this whole file as the starting point. It's the same shape as mp-game-template — start screen + lobby + gameplay in one file, ~120 lines. Replace the state shape and the tick body with your game's logic; everything else stays.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="b3-multiplayer" content="true">
  <title>Your Game</title>
  <!-- BOTH runtimes load: lobby SDK (Layer 2) + determinism runtime (Layer 3) -->
  <script type="module" src="/mp-runtime.js"></script>
</head>
<body>
  <!-- Layer 1: Start screen — game name + Solo / Multiplayer choice. -->
  <section id="start-screen">
    <h1>Your Game</h1>
    <button id="btn-solo">Play Solo</button>
    <button id="btn-multiplayer">Play Multiplayer</button>
  </section>

  <!-- Layer 2: Lobby — host or join with code, share-link, roster, start, leave. -->
  <section id="lobby" hidden>
    <button id="role-host">Host new room</button>
    <button id="role-join">Join with code</button>
    <div id="room-code"></div>
    <div id="share-link"></div>
    <ul id="roster"></ul>
    <button id="host-start" hidden>Start game</button>
    <button id="leave-room">Leave</button>
  </section>

  <!-- Layer 3: Gameplay — the canvas the runtime drives via B3Multiplayer. -->
  <canvas id="gameplay" width="480" height="360" hidden></canvas>

  <script type="module">
    import * as B3MP from '/mp-client.js'

    // Mode state machine — which layer is visible
    let mode = 'menu' // 'menu' | 'lobby' | 'playing'
    function setMode(m) {
      mode = m
      document.getElementById('start-screen').hidden = m !== 'menu'
      document.getElementById('lobby').hidden = m !== 'lobby'
      document.getElementById('gameplay').hidden = m !== 'playing'
    }

    // Layer 1: Solo path runs gameplay locally, no SDK
    document.getElementById('btn-solo').onclick = () => {
      setMode('playing')
      runSolo() // your local game loop, no postMessage
    }
    document.getElementById('btn-multiplayer').onclick = () => setMode('lobby')

    // Layer 2: lobby host/join/roster (uses /mp-client.js SDK)
    let conn
    document.getElementById('role-host').onclick = async () => {
      const session = await B3MP.mintGuestSession('Player')
      const reg = await B3MP.hostRoom({ gameSlug: 'your-slug', token: session.token })
      document.getElementById('room-code').textContent = reg.code
      // G32 — share link points to the SHELL route /<slug>?room=, NOT
      // /games/<slug>/?room= (which 404s after trailing-slash strip).
      document.getElementById('share-link').textContent =
        location.origin + '/your-slug?room=' + reg.code
      conn = B3MP.connect({ code: reg.code, token: session.token, name: session.name, isGuest: true })
      wireConn(conn)
      B3MP.ready()
    }
    document.getElementById('role-join').onclick = async () => {
      const code = prompt('Room code?')?.trim().toUpperCase()
      if (!code) return
      const session = await B3MP.mintGuestSession('Player')
      await B3MP.joinRoom(code) // throws if room missing
      conn = B3MP.connect({ code, token: session.token, name: session.name, isGuest: true })
      wireConn(conn)
      B3MP.ready()
    }
    function wireConn(c) {
      c.on('connected', e => renderRoster(e.members))
      c.on('member_joined', m => addToRoster(m))
      c.on('member_left', ({ wsId }) => removeFromRoster(wsId))
    }
    // Host's "Start game" button — sends start_game; server broadcasts to all.
    document.getElementById('host-start').onclick = () => conn.startGame()
    // "Leave room" button — closes WS cleanly + drops back to menu.
    document.getElementById('leave-room').onclick = () => { conn?.close(); setMode('menu') }
    // CR.11: auto-join when URL carries ?room=CODE (e.g. share link).
    ;(() => {
      const code = new URL(location.href).searchParams.get('room')
      if (code) {
        setMode('lobby')
        // Reuse the join flow so prompt-or-URL share the same path:
        document.getElementById('role-join').click()
      }
    })()

    // Server start_game broadcast → both host AND joiners arrive here.
    B3MP.onStartGame(payload => {
      setMode('playing')
      // payload.seed is fixed per room; everyone gets the same number.
      conn.startSelfDrivenTickLoop({
        seed: payload.seed,
        players: getRoster(),       // [{id, name, ...}, ...]
        myId: conn.getMyId(),       // your wsId, captured from welcome
      })
    })
    function renderRoster(members) { /* fill #roster, highlight conn.getMyId() */ }
    function addToRoster(m) { /* append <li> for the new member */ }
    function removeFromRoster(wsId) { /* drop the <li> by data-id */ }
    function getRoster() { /* return current roster as [{id, name}, ...] */ }

    // Layer 3: B3Multiplayer 5-function contract — driven by the SDK.
    const intent = Object.create(null)
    let state = null
    window.B3Multiplayer = {
      init({ seed, players, myId }) {
        // Use ctx.random — seeded mulberry32, same on every client
        state = { tick: 0, myId, players: {} /* ... */, gameOver: false }
      },
      tick(dtMs) {
        if (!state || state.gameOver) return
        state.tick++
        // Iterate players in id-sorted order — deterministic across clients
        for (const id of Object.keys(state.players).sort()) {
          /* apply intent[id], integrate position, etc. */
        }
        // Forbidden inside this block: Math.random, Date.now, setTimeout,
        // Math.sin/cos/tan/exp/log. Use ctx.random / ctx.math.* instead.
      },
      onInput(playerId, input) {
        // Called by the SDK with relayed inputs from ALL players (incl. you).
        const p = intent[playerId] || (intent[playerId] = {})
        if (input?.dir) p[input.dir] = input.phase !== 'up'
      },
      getState() { return state ? JSON.parse(JSON.stringify(state)) : null },
      setState(s) {
        state = s ? JSON.parse(JSON.stringify(s)) : null
        for (const k in intent) delete intent[k]
      },
    }

    // Send YOUR input upstream from a key handler (anywhere in your code):
    //   window.B3.ctx.emit('input', { dir: 'left', phase: 'down' })
    // The runtime forwards it through the SDK to the server, which fans it out
    // to every client (including yours) as B3Multiplayer.onInput at tick+3.

    function runSolo() { /* drive your own state + render loop */ }
  </script>
</body>
</html>

Layer 1 — Start screen

Your game opens on a start screen, NOT into multiplayer. Two buttons: Play Solo and Play Multiplayer. Solo runs your game locally with no SDK calls; Multiplayer transitions to Layer 2.

Why this matters: a multiplayer-only game freezes when the user opens the file directly (no parent harness, no init from the platform = stuck). The start screen is the difference between a game that works on first open and one that looks broken.

The validator's mode-toggle-buttons rule (active for hybrid games) checks for "Solo" + "Multiplayer" button text. The skeleton above is the canonical pattern.

Required lobby UI structure (CR.14 E3.6 — mandatory)

The lobby has a fixed shape. AI agents building MP games MUST follow this structure — otherwise users get walls (clicking a button does nothing, modals silently blocked, host abandonment leaves both as joiner). Use the canonical autoRouteFromUrl IIFE + per-layer DOM panels.

Layered structure

LayerPurposeRequired affordances
0 — StartPick Solo or MultiplayerTwo buttons: Solo, Multiplayer. Click MP → Layer 1.
1 — MP chooserPick Host or JoinTwo buttons: Host New Room, Join with Code. Click Host → run host flow → Layer 3. Click Join → Layer 2.
2 — Join inputEnter a 5-letter code<input> field (NEVER prompt()) + Submit + Back. Validate /^[A-Z]{5}$/ in-page; show inline error text on fail.
3 — Lobby roomShow code, share link, roster, startRoom code display + share-link <input readonly> + Copy button + roster <ul> + status text + Start (host-only, gated by roster.length ≥ minPlayers) + Leave.
4 — GameplayThe actual gameWhatever your game needs. B3Multiplayer.init / tick / onInput / getState / setState drive it.

URL hints — autoRouteFromUrl IIFE

On iframe load, your game checks the URL for two hints from the platform shell:

;(function autoRouteFromUrl() {
  const url = new URL(location.href)
  const code = url.searchParams.get('room')
  const action = url.searchParams.get('action')

  if (code) {
    // Joiner path: skip Layer 0/1, pre-fill Layer 2 input, submit.
    setMode('lobby')
    showJoinInput(code.trim().toUpperCase())
    submitJoin()
    return
  }

  if (action === 'host') {
    // Host path: skip Layer 0/1, run host flow directly.
    setMode('lobby')
    beginHost()
  }
})()

?room= wins precedence over ?action=host (joiner takes precedence; if a room exists, don't allocate a new one).

Anti-patterns (DO NOT do these)

  • Don't use prompt()/alert()/confirm(). The platform iframe sandbox is allow-scripts allow-same-origin allow-pointer-lock — modals are silently blocked. Click a button using prompt() → nothing happens → user stuck. Use an in-frame <input> field instead.
  • Don't mint or allocate at the platform shell layer. The shell navigates to ?action=host as intent only; your iframe owns ALL identity. Mint guest, allocate room, connect — all inside the iframe, all in ONE identity. Cross-context minting (shell + iframe both minting) produces two-identity bugs (both browser tabs end up tagged "joiner" because neither matches the recorded host).
  • Don't pass tokens via URL. URL ?token= leaks to browser history + server logs and creates the dual-identity hazard above. Iframe mints its own token; that's the only token in the room's lifecycle.
  • Don't gate predicates on hardcoded "GOLD"/"SILVER" (or any specific side). Use B3MP.pickByRole(connection.getMyRole(), {host: SIDE_A, joiner: SIDE_B}) with an index fallback. Solo→MP migration left footguns like function playerCanAct() { return state.turn === GOLD } that silently lock out Silver players.

Layer 2 — Lobby (host / join / roster / start)

Connection methods (CR.11) — what every host/joiner can call

Quick reference for the Connection object returned by B3MP.connect(). Cycle-7 fresh-agent feedback: agents miss methods buried in prose tables, so this section lists every method explicitly. If a method isn't here, it isn't on Connection.

MethodReturnsNotes
connection.on(event, cb)unsubscribeSubscribe to events (10 types — see next subsection).
connection.off(event, cb)voidManual unsubscribe; or call the function returned by .on().
connection.startGame()voidHost (or any member) signals "begin gameplay". Server broadcasts {t:'start_game', seed} to all members.
connection.getMyId()string | nullThis client's wsId from welcome. Null pre-welcome.
connection.getMyRole()'host' | 'joiner' | nullThis client's role in the room (CR.14 D.4). Anchored on the allocator's host_member_id. Null pre-welcome or when server is pre-CR.14.
connection.getMembers()RoomMember[]Current roster snapshot. Updated on member_joined/left.
connection.getState(){phase, ...}Connection state: connecting / connected / reconnecting / failed.
connection.startSelfDrivenTickLoop(opts)voidHybrid mode entry point. opts: {seed, players, myId}. Drives B3Multiplayer at 30Hz.
connection.stopSelfDrivenTickLoop()voidCleanup. Call when leaving Layer 3.
connection.send(msg)voidLow-level WS send. Prefer the named methods above; this is for advanced/custom protocols. No-op when socket isn't open.
connection.close(reason?)voidClean close (code 1000). Fires 'closed' event, prevents reconnect. Wire to your "Leave room" button.

That's the entire surface. No sendInput (use ctx.emit('input', value) from Layer 3). No leave alias (use close()). No disconnect. If the contract didn't list it above, the SDK doesn't have it.

When the user clicks "Play Multiplayer," show host-or-join UI. Use the /mp-client.js SDK module-level functions:

SDK callPurpose
B3MP.mintGuestSession(name)
{token: string, name: string}
Mint a guest JWT for an unauthenticated player. Returns an object with the JWT and the resolved display name.
B3MP.hostRoom({gameSlug, token})
{code, hostMemberId, gameSlug, expiresAt, ...}
Allocate a fresh room. Returns a 5-letter uppercase code + room metadata. Rejects on auth failure.
B3MP.joinRoom(code)
void (throws if missing)
Existence-check a room code before connecting. Throws if the code is unknown or expired. No useful return value.
B3MP.connect({code, token, name, isGuest})
Connection
Open the WebSocket. Returns a Connection object that emits events (table below).
connection.on(event, cb)Subscribe to events listed in the Connection events table below. Returns an unsubscribe function.
B3MP.ready()Tell the platform your lobby UI is rendered. Required by the validator. Idempotent.
B3MP.onStartGame(cb)Listener for "game started" — fires on every member when ANY member calls connection.startGame(). The callback receives the server payload {t:'start_game', seed:number}; pass seed straight to connection.startSelfDrivenTickLoop. Returns an unsubscribe function.
connection.startGame()Host (or any registered member) signals the room to begin gameplay. Server broadcasts {t:'start_game', seed} to every member, triggering each client's onStartGame listener. Idempotent — re-clicks broadcast the same seed.
connection.getMyId()Returns this client's wsId from the welcome message — same value the server uses in member_joined.member.id. Use it to find your own roster entry, highlight "this is me", or pass to startSelfDrivenTickLoop({myId}). Returns null before welcome arrives.
connection.startSelfDrivenTickLoop({seed, players, myId})Hybrid mode (game + lobby in one HTML file): drives ticks IN THE SAME WINDOW — no child iframe. Calls B3Multiplayer.init/tick/onInput directly at 30Hz. Auto-installs the local-bus runtime so ctx.emit('input', value) reaches the server. Call this from inside your onStartGame callback.
connection.stopSelfDrivenTickLoop()Cleanup hook. Call when leaving Layer 3 or unmounting the game.

Connection events (10 total) — payloads from /mp-client.js

The Connection object returned by B3MP.connect() emits 10 events. Wire whichever your UX needs. The lobby connection STAYS OPEN during gameplay — disconnect events on it are still fired in Layer 3, even though gameplay state transports via the platform's separate postMessage channel.

EventPayloadWhen fired
'connecting'{attempt: number}Fresh socket starts opening. Show "Connecting…" UI.
'connected'{members: RoomMember[]}WS opened AND welcome arrived with current roster. Render lobby; call B3MP.ready() here.
'reconnecting'{attempt: number, nextDelayMs: number}Auto-retry after a transient drop. Show countdown.
'failed'{error: string}Connection abandoned after MAX_RECONNECT_ATTEMPTS (5). Surface as "Connection lost — back to menu."
'closed'Fired exactly once when connection.close() is called or after 'failed'. Cleanup hook.
'member_joined'RoomMemberAnother member entered the room. Append to roster.
'member_left'{wsId: string}A member disconnected. Note the field is wsId, NOT id — easy to get wrong.
'pong'{ts: number}Server reply to a ping. Use for latency measurement if you care.
'server_error'{code: string, received?: string}Protocol-level error from server (rare; bug if you see one).
'start_game'{t:'start_game', seed:number}Server broadcasts after any member calls connection.startGame(). seed is fixed per room and identical for all clients — pass it to startSelfDrivenTickLoop. Mirrors B3MP.onStartGame.

The lobby connection stays open during gameplay. Don't close it on transition to Layer 3 — gameplay's input transport is separate (parent ↔ iframe postMessage), but member-join/leave events keep firing on the lobby connection. Close it when the user navigates away.

Lobby UX requirements: show the room code prominently (big, monospaced), a copyable share-link to /<your-slug>?room=<CODE> (the b3.games SHELL route — G32; do NOT use /games/<slug>/?room= which 308-redirects to no-trailing-slash → 404), a live member roster updated on join/leave, and a Leave-room button. The validator's 7 lobby-UX rules check for these.

?room= URL ownership (G23). When a user opens a share link, the platform shell loads your game iframe with the URL ?room=<CODE> intact — your game receives this query parameter, the shell does NOT strip it. You MUST detect new URLSearchParams(location.search).get('room') on load; if present, auto-enter the join flow with that code prefilled. Otherwise users following a share link land on your "Host or join?" chooser screen — a dead-end UX. The skeleton's autoRouteFromUrl IIFE shows the canonical pattern (handles both?room= for join + ?action=host for shell-button host intent).

Implicit host (G29) — read carefully. The b3.games shell flow allocates the room before your iframe ever loads — when the user clicks "Play multiplayer" on the game page, the shell calls /api/mp/allocate, then redirects to /games/<slug>/?room=<CODE>. Both tabs (host + joiner) see ?room= in their iframe URL on first load. NEITHER tab calls B3MP.hostRoom() from inside the iframe — every iframe enters as a JOINER. Do NOT gate your "Start game" button on a local isHost flag set inside a "Host new room" click handler: that handler never fires in shell-flow, and the button never appears for anyone.

Canonical implicit-host check: the FIRST member in connection.getMembers() (sorted deterministically by joinedAt then id, server-side) is the implicit host. They get the Start button. All clients agree because the server's sort is deterministic. Code:

function isImplicitHost(myId, members) {
  if (!members.length || !myId) return false
  const sorted = [...members].sort((a, b) => {
    const dt = String(a.joinedAt || '').localeCompare(String(b.joinedAt || ''))
    return dt !== 0 ? dt : String(a.id).localeCompare(String(b.id))
  })
  return sorted[0].id === myId
}

// In your roster-render or member-event handler:
const canStart = isImplicitHost(conn.getMyId(), members) && members.length >= 2
startBtn.classList.toggle('hidden', !canStart)

This check works for BOTH flows: in the legacy "user clicked Host new room" flow the first member happens to be the host (they joined first); in the shell-flow there's no explicit host, so first-by-join-order is the deterministic tiebreaker.

Host disconnects = room invalidates. The platform reaps the room when its last member leaves; if a joiner has the code but the host has gone, joiner sees a "room ended" failure on connect. Surface that to the user as "Room expired — try a new link."

Module helpers (CR.14 — patterns for AI-generated games)

Three small helpers cover patterns that fresh-agent feedback flagged as repeated bugs. Use them everywhere — they're convention as much as code.

HelperReturnsWhy
B3MP.getGameSlug()string | nullDerives slug from window.location.pathname (/api/games/SLUG/html). Replaces hardcoded const GAME_SLUG = '...' — copy/paste from templates wouldn't surface the bug, the wrong slug would.
B3MP.generatePlayerName()'PlayerNNNN'Random 4-digit guest name. Guards against the "all-three-players-named-Silver" footgun. Use only for unauth guests; real auth users have a profile name.
B3MP.pickByRole(role, mapping)any | nullSide-aware values: pickByRole(role, {host:'white',joiner:'black'}). Replaces hardcoded labels that show "Click your gold piece" for both clients (G41/G42).

hostRoom() already calls getGameSlug() when you omit gameSlug, so the simplest path is to NEVER pass it — the SDK derives it for you and throws a loud err.code === 'no_slug' if it can't.

'You are X' indicator (CR.14 D.4)

Players need to know who they are at a glance. Cycle-11 chess surfaced both clients showing "Click your gold piece" — neither knew their actual side, both clicked gold pieces, no moves registered.

The pattern: after start_game arrives, derive your side from connection.getMyRole() and render a persistent indicator. This stays on screen during gameplay so the player can re-confirm at any moment.

// After start_game broadcast, in your gameplay init:
const role = connection.getMyRole()    // 'host' | 'joiner' | null

const myColor = B3MP.pickByRole(role, {
  host: 'white',
  joiner: 'black',
  fallback: '?',  // shown briefly pre-welcome; rare but explicit
})

const youAreEl = document.getElementById('you-are')
youAreEl.textContent = `You are ${myColor}`
youAreEl.classList.add(`side-${myColor}`)

// Status text uses myColor too — no "click your gold piece" for everyone:
statusEl.textContent = `Click a ${myColor} piece to move`

// For turn-based games: branch the "your turn" / "opponent's turn" state
const isMyTurn = state.turn === role
turnEl.textContent = isMyTurn ? 'Your turn' : "Opponent's turn"

Why role, not index? For 2-player games (turn-duel, realtime-duel), role is the cleanest mapping — host vs joiner is well-defined and stable. For N-player party modes (turn-party, realtime-party), use members[] index instead so all N sides are distinguishable; role still tells you who started the room.

Handling room_full (CR.14 D.5)

When a 3rd player tries to join a 2-cap room, the platform server returns 403 with a structured payload. The SDK adapter surfaces it as a 'failed' event with code === 'room_full'. Wire this in the lobby: a generic "Lost connection" message confuses users who just want to join their friend's game.

const conn = B3MP.connect({ code, token, name, isGuest: true })

conn.on('failed', (payload) => {
  // payload: { code, message, max?, ... }
  if (payload.code === 'room_full') {
    showLobbyError(
      `Sorry, this room is full (${payload.max} players max). ` +
      `Ask your friend to host a new room.`
    )
    return
  }
  // Other terminal failures (lost connection after retries, etc.):
  showLobbyError(payload.message ?? 'Connection failed')
})

The connection is already closed when 'failed' fires — no reconnect attempts, no spurious noise. Set your "Joining…" UI back to the join input so the user can try a different code.

Other server codes you might see: not_enough_players (host clicked Start before the required min — server emits an 'error' event, not a fatal failure; show the message and wait). rate_limit (rare — flag a warning, the connection survives).

Player leaves during gameplay (CR.16 P0.1, CR.17 O.1)

You have two equally-valid patterns for surfacing peer disconnects to your gameplay code:

  1. B3Multiplayer.onPlayerLeave(playerId) (CR.17 O.1, recommended for new games) — an optional 6th function on the contract, called from inside the runtime at the same scope as tick and onInput. Add it next to the required 5; the platform calls it when a peer's WebSocket closes mid-session.
  2. lobbyConnection.on('member_left', ...) (CR.16, also valid) — a shell-scope listener that mutates a closure-held Set; gameplay reads it inside tick. All canonical fixtures from CR.16 use this pattern; it's not deprecated.

Either way: both fire only for other players (you don't get a self-leave), both are advisory (the leaver's input simply stops arriving regardless), and both are subject to the per-mode response table below.

Pattern A — B3Multiplayer.onPlayerLeave (recommended, CR.17 O.1)

// In Layer 3 init — add as a 6th function next to the required 5:
window.B3Multiplayer = {
  init({ seed, players, myId }) { /* ... */ },
  tick(dtMs)                    { /* ... */ },
  onInput(playerId, input)      { /* ... */ },
  getState()                    { /* ... */ },
  setState(s)                   { /* ... */ },

  // Optional 6th — called when ANOTHER player's WS closes.
  // Self-leave is NOT dispatched (your iframe is closing).
  onPlayerLeave(playerId) {
    // Set a flag; resolve in next tick. Don't call ctx.gameOver here
    // (callback runs outside tick — racing with in-flight ticks).
    state.leftIds = state.leftIds || []
    if (!state.leftIds.includes(playerId)) state.leftIds.push(playerId)
  },
}

Backward-compat: the validator does NOT require onPlayerLeave. Games that don't define it remain valid — the runtime checks typeof before dispatching.

Pattern B — lobbyConnection.on('member_left', ...) (CR.16, also fine)

The original CR.16 pattern: install one listener in lobby setup that mutates a closure-held flag; gameplay reads it inside tick. All canonical fixtures use this approach.

// In Layer 2, right after connection opens:
const leftIds = new Set()  // gameplay can read this on every tick

lobbyConnection.on('member_left', ({ wsId }) => {
  leftIds.add(wsId)
  // Optional: also update Layer 3 HUD for "Opponent disconnected" surface.
})

// In Layer 3 init:
window.B3Multiplayer = {
  init({ players, myId }) {
    state.players = players.map(p => ({ ...p, alive: true }))
    state.myId = myId
  },
  tick(dtMs) {
    if (state.gameOver) return
    state.tick++
    // CR.16 P0.1 — read leftIds at top of tick, mutate state deterministically.
    for (const p of state.players) {
      if (!p.alive) continue
      if (leftIds.has(p.id)) p.alive = false
    }
    handleLeavesPerMode(state)  // ← mode-specific, see table below
  },
}

What to do per mode (the playbook)

The right reaction to a leave depends on mode. The contract intentionally doesn't pick for you — different games want different things. Match your mode below.

ModeRecommended responseWhy
turn-duel / realtime-duelForfeit: remaining player wins immediately.2-player only; nobody to play against. Set state.gameOver=true; call ctx.gameOver(scores) with remaining player as winner.
turn-party / turn-teamSkip the leaver's turn slot in rotation. End early only if all-but-one left.N-player turn-based games keep playing. Don't remove the player from state.players (breaks index-based team derivation); just gate the turn pointer to advance past dead slots.
realtime-partyFreeze leaver's avatar in place; continue. Optional end-game if N drops below min-players.FFA tolerates dropouts. Their last position stays painted/visible; their input simply stops arriving.
realtime-teamFreeze leaver's avatar; the team plays a player down. Optional end-game on team-empty.Asymmetric play is part of the design — opposing team gains a numerical edge. Don't auto-balance; that's a much bigger feature.
coopTheir input stops; remaining players continue. No state change needed.Free-for-all input means a missing player is invisible to the game logic; only the timer / shared world matter.

Anti-patterns

  • Don't roll a heartbeat input. Some authors invent ctx.emit('input', {t:'heartbeat'}) to detect leaves — this works but it's fragile (network jitter triggers false positives) and wasteful (60 ticks/sec × N players = noise). The lobby 'member_left' event is authoritative.
  • Don't remove leavers from state.players. Index-based team derivation (i % teamCount) breaks if you shrink the array; same for member-index identity in turn-party. Mark themalive: false instead.
  • Don't gate input on a "connected" status check. Leavers can't emit input anyway — their socket is closed. Adding an explicit gate just couples gameplay to connection state without benefit.
  • Don't call ctx.gameOver inside the member_left callback. The callback runs outside tick — calls there can race with in-flight ticks. Set a flag in state; resolve in the next tick.

Text chat (CR.17 O.3) — opt-in via meta tag

A simple text chat panel is available opt-in. The platform broadcasts chat through the same WebSocket that carries gameplay; rate-limiting uses a separate sub-bucket so chat spam can never starve gameplay input frames. Chat is NOT part of deterministic state — it never appears in getState, never feeds into the desync-hash, and never rolls back. It's purely a peer-visible message stream layered on top of the lobby connection.

Opt-in: add <meta name="b3-mp-chat" content="true"> to the head and subscribe to connection.on('chat', ...). Without the meta tag the chat broadcast still lands in the connection (it's universal at the server), but the absence of a UI to render it is your "chat is off" state — no work needed to opt out.

Sending: connection.sendChat(msg)

// In Layer 2 lobby setup (or anywhere after connection opens):
const ok = connection.sendChat('hello world')  // returns false if empty/too long/socket closed

// Server validates server-side too:
//   - length 1..200 chars (after trim)
//   - profanity filter (same banlist as guest names)
//   - sub-bucket rate limit: 2 msgs / sec / socket
// Validation failures arrive as 'server_error' events with codes
// 'rate_limit', 'chat_invalid', or 'chat_profane' — show a toast,
// don't disconnect the user.

Receiving: connection.on('chat', ...)

connection.on('chat', ({ wsId, name, msg, ts }) => {
  // wsId   — sender's wsId (matches a member.id in the roster)
  // name   — denormalised from the server's member roster — render directly
  // msg    — server-validated, ≤200 chars, profanity-screened, trimmed
  // ts     — server-stamped Date.now() — for ordering only, NOT deterministic

  // Append to your chat panel UI. Sender sees their OWN message via
  // this same event (server broadcastAlls including sender), so don't
  // local-echo before sendChat returns — you'll get duplicates.
  appendChatLine({ wsId, name, msg, ts })

  // Errors land on 'server_error' with code 'chat_invalid' /
  // 'chat_profane' / 'rate_limit' — handle separately:
})

connection.on('server_error', ({ code }) => {
  if (code === 'chat_profane') showToast('Try different words')
  else if (code === 'chat_invalid') showToast('Message too long or empty')
  else if (code === 'rate_limit') showToast('Slow down a bit')
})

Anti-patterns

  • Don't put chat in state. Chat is shell-scope. Including it in getState would taint the desync hash on every message — every chat would look like a multiplayer divergence.
  • Don't local-echo before send returns. The server broadcasts your own message back to you for consistency. Local-echoing produces duplicates.
  • Don't trust the client's length / profanity filter alone. The server validates and is the source of truth. Client-side checks are immediate UX feedback, not security.
  • Don't share the input rate-limit bucket. They're separate by design. Don't try to "save credits" by routing chat through ctx.emit('input', ...) — chat is not gameplay input, and conflating them defeats both the chat spam-protection and gameplay smoothness.

Layer 3 — Gameplay (the determinism runtime)

When the host calls connection.startGame(), the server broadcasts {t:'start_game', seed} to every member. Your onStartGame handler then calls connection.startSelfDrivenTickLoop({seed, players, myId}) which drives B3Multiplayer.init tick at 30Hz. Your gameplay code lives here.

Sending input upstream. Inside any handler (keydown, button click, touch), call window.B3.ctx.emit('input', value). The runtime forwards your input to the server, which fans it out to every client (including yours) as B3Multiplayer.onInput(playerId, input) at tick+3. This is the only way to make state changes — never mutate state directly from a key handler.

Yes, this works for turn-based games (CR.11). ctx.emit('input', value) can be called from ANY DOM handler — a button click, a touch, a keydown — not just from inside tick(). The forbidden-API guard activates ONLY during a tick call; outside tick (button handlers, init, render, onInput) you can use Math.random, Date.now, etc. freely. Your tick body just reads the queued input and applies it deterministically.

Timing semantics (G25). ctx.emit('input', value) is asynchronous — onInput does NOT fire on this tick. The runtime ships the input to the server; the server relays it back as a frame message targeting tick + 3 (the lockstep lookahead window). Each client's runtime then dispatches B3Multiplayer.onInput(playerId, input) three ticks after the emit. For a 30Hz game that's ~100ms perceived latency. For turn-based games: this means rapid double-clicks may both register; debounce in the click handler if you want one-input-per-turn semantics. For real-time games: design state so delayed input application is OK (intent queues, action prediction). Inputs always arrive in deterministic order across clients — that's the lockstep guarantee.

The 5 functions

Every multiplayer game declares window.B3Multiplayer with exactly these 5 method-shorthand functions:

FunctionWhen calledWhat it does
init(args)Once, after iframe loadInitialize state from {seed, players, myId}. Use ctx.random() for any randomness.
tick(dtMs)Every 33ms (30Hz)Advance state by dtMs. Forbidden: Math.random, Date.now, setTimeout, native trig.
onInput(playerId, input)When ANY player's input arrivesUpdate intent state; consume in next tick. Don't apply physics here.
getState()Every 60 ticks (hash) + on snapshotReturn a JSON-serializable snapshot. Avoid Set/Map. Deep-clone defensively.
setState(s)On reconnect / late-join replayRestore state verbatim from a prior getState() return.

ctx surface (provided by /mp-runtime.js)

The runtime injects window.B3.ctx before your game script runs. Inside your 5 functions, use these instead of native browser APIs:

FieldTypeUse for
ctx.random()() => numberRandom in [0,1) — seeded mulberry32, same on every client.
ctx.ticknumberCurrent tick counter. Multiply by 33 for ms-equivalent at 30Hz.
ctx.math.{sin,cos,tan,exp,log,PI}trig fnsPolynomial trig — bit-exact across V8/JSC. Native trig differs in last bits, breaks lockstep over many ticks.
ctx.emit(type, value)(string, any) => voidSend custom event to platform. type:'input' events fan out to all clients via onInput.
ctx.gameOver(scores)(Record<string, number>) => voidEnd the game; platform broadcasts scores to all clients. scores is keyed by playerId (the wsId from members[]) with numeric values. Example: ctx.gameOver({ 'g_abc123': 34, 'g_def456': 30 }) — broadcasts to all clients; tick continues, so guard with state.gameOver for early-return. Safe to call directly from tick() body — no need to defer via Promise.resolve().then(...).

Math API safety reference (forbidden / engine-divergent / safe)

The runtime guard fires only during tick execution — it patches a fixed list of globals to throw, then restores them after tick returns. init is unguarded; APIs that work in init may still throw in tick. Beyond what the guard enforces, some Math APIs are engine-divergent per ECMA-262 (V8 and JSC produce different last bits) — those won't throw, but they'll desync your game across clients over time. Safe APIs are IEEE-754-bit-exact per spec and free to use.

TierAPIs (NEVER use inside tick)Why / replacement
Forbidden in tick
(runtime-blocked — calls throw)
Math.random
Math.sin, Math.cos, Math.tan
Math.exp, Math.log
Date.now, performance.now
setTimeout, setInterval
requestAnimationFrame
Use ctx.random / ctx.math.* / ctx.tick * 33. The runtime patches these to throwing stubs around every tick() call and restores them after — so you'll get a clear error message at dev time.
Engine-divergent
(NOT runtime-blocked — but libm-implementation-defined per ECMA-262; real desync surface)
Math.atan2, Math.asin, Math.acos, Math.atan
Math.sinh, Math.cosh, Math.tanh
Math.cbrt
Math.log2, Math.log10, Math.expm1, Math.log1p
Math.hypot
Math.pow (non-integer exponents)
These won't throw, but ECMA-262 explicitly allows "implementation-approximated" results — V8 and JSC can differ in last bits. Avoid in tick.
Replacements:
· Math.hypot(a, b) Math.sqrt(a*a + b*b)
· Math.atan2(y, x) → precompute angles in init and store on state (e.g., per-waypoint angle); don't recompute in tick
· Math.pow(x, 2)x*x — integer exponents are safe via multiplication chains
· trig: use ctx.math.sin/cos/tan/exp/log
Safe in tick
(IEEE-754 bit-exact per ECMA-262)
Math.sqrt
Math.abs, Math.sign
Math.floor, Math.ceil, Math.round, Math.trunc, Math.fround
Math.min, Math.max
Math.imul (32-bit)
Math.pow (integer exponents)
Constants: Math.PI, Math.E, Math.LN2, Math.LN10, Math.LOG2E, Math.LOG10E, Math.SQRT2, Math.SQRT1_2
These are IEEE-754-bit-exact specified per ECMA-262 §21.3 — same inputs produce identical outputs across V8, JSC, SpiderMonkey. Use freely.

About init: the forbidden-API guard is NOT active in init — it only fires during tick. Math.random won't throw in init. But you should still use ctx.random in init for cross-client determinism — every client's init must produce the same map/spawn positions/seed-derived state, and only ctx.random is guaranteed to give the same sequence on every machine.

Forbidden APIs (caught by runtime + validator)

❌ Don't (inside tick)✅ Use thisWhy
Math.random()ctx.random()Seed-shared across clients; native Math.random is per-client.
Math.sin/cos/tan/exp/logctx.math.*V8 vs JSC trig differs in last bits → desync over time.
Date.now() / performance.now()ctx.tick * 33Wall-clock differs per client.
setTimeout / setInterval(not needed — tick is the scheduler)Scheduling races break lockstep.
requestAnimationFrameAllowed OUTSIDE tick (render loop)rAF cadence varies (60Hz vs 120Hz refresh, paused tabs).

The runtime replaces these globals with throwing stubs DURING tick and restores them between ticks. Your render loop (separate requestAnimationFrame) can use whatever it wants — only the tick path is locked down.

3 genre patterns

The 5-function shape stays constant; what changes is how state and inputs flow.

Positional (mp-moving-rect, racing, top-down)

// Positional (avatars on a 2D plane — moving-rect, racing, top-down shooter).
// State carries {x, y, vx, vy} per player. Inputs are direction toggles.
state = {
  tick: 0,
  players: { 'A': { x: 100, y: 100, vx: 0, vy: 0 }, /* ... */ },
}

tick(dtMs) {
  state.tick++
  const dt = dtMs / 1000
  for (const id in state.players) {
    const p = state.players[id]
    const i = intent[id] || {}
    p.vx = ((p.vx + (i.right ? 200 : 0) - (i.left ? 200 : 0)) * 0.9)
    p.vy = ((p.vy + (i.down ? 200 : 0) - (i.up ? 200 : 0)) * 0.9)
    p.x += p.vx * dt; p.y += p.vy * dt
  }
}

Turn-based (cards, dice, prompts)

// Turn-based (cards, dice, prompts). State advances only when ALL players
// have submitted this round's intent. tick is mostly idle (advances clock).
state = {
  tick: 0, round: 0, currentPlayer: 'A',
  hands: { 'A': [...], 'B': [...] },
  pendingPlays: {},   // playerId -> chosen card
}

tick(_dtMs) {
  state.tick++
  // Only advance the round when everyone has played:
  const allPlayed = Object.keys(state.hands).every(id => state.pendingPlays[id])
  if (allPlayed) {
    resolveRound(state)        // pure function — no side effects, no time
    state.pendingPlays = {}
    state.round++
  }
}

onInput(playerId, input) {
  if (input.t === 'playCard' && state.hands[playerId].includes(input.card)) {
    state.pendingPlays[playerId] = input.card
  }
}

Score-only (clickers, mash, quiz)

// Score-only (incremental clicker, button-mash, knowledge quiz scoreboard).
// No spatial state. tick is essentially a no-op or just advances tick counter.
state = {
  tick: 0,
  scores: { 'A': 0, 'B': 0 },
  taps:   { 'A': 0, 'B': 0 },   // local tap counters
}

tick(dtMs) {
  state.tick++
  // Drain accumulated taps into scores once per tick (cheap & deterministic).
  for (const id in state.taps) {
    state.scores[id] += state.taps[id]
    state.taps[id] = 0
  }
}

onInput(playerId, input) {
  if (input.t === 'tap') state.taps[playerId] = (state.taps[playerId] || 0) + 1
}

v0.1 boundaries — what's explicitly defined right now

The contract is at v0.1. Some behaviors are deliberately scoped down to ship — they will expand in v0.2. If your game depends on them, code defensively or wait for the next version.

Topicv0.1 behaviorv0.2 (planned)
Late-joinPlayers list is FIXED at init. A client joining mid-game receives a snapshot via setState but doesn't appear as a new entry in state.players. Don't grow the roster mid-game.Add-player event + dynamic roster.
DisconnectNo onPlayerLeave hook. A disconnected player's last state stays in state.players; render them as a ghost (frozen, half-opacity) until the room ends.onPlayerLeave hook + disconnect-aware UX.
Restart / Play-AgainNo in-room replay. To play again, allocate a NEW room (new code + new seed = fresh game). Surface a "Play again? (new room)" CTA after gameOver.In-room re-init protocol.
ctx.gameOver(scores)Call exactly ONCE per session. Tick keeps being called after; early-return on a state.gameOver flag.Idempotent multi-call OK.
Input rate limitServer caps at 60 messages/sec per WebSocket. Use a held-key dedupe pattern (only emit on transitions) — burst-emitting on every keydown will get throttled.Higher cap or per-game tuning.
Intent in getStateSkeleton excludes the local intent map from state (it's transient, overwritten each input). If your game queues inputs, queue them in state — otherwise setState after a snapshot will lose any in-flight intent.Same — this is a discipline, not a platform fix.
Iframe sizingConvention: 480×360 canvas (matches mp-game-template, mp-moving-rect, mp-ready-check). The parent page's CSS scales the iframe to its container — keep the canvas aspect-ratio stable so it scales cleanly. Bigger is fine (up to ~960×720) but mobile users will see it downscaled; design for the smaller bound.Per-game min/max declared in metadata.
State sizeRecommend state size < 100 KB serialized (after JSON.stringify). No platform enforcement, but getState runs every 60 ticks for the hash check — 100 KB × 30 hashes/min = 3 MB/min/client. Move large or local-only data (particles, animations, dead enemies) OUT of state into localUI (see Recommended Patterns).Compression at the wire layer.
Init lifecycleinit is single-call per session. Late-joiners get setState only — init won't fire twice. Don't put one-time setup behind a "first-call" guard inside init; just write it to run once.Same.
Validator rulesStatic analysis at upload time enforces 14 rules (see "Validator rule reference" section below). Run them yourself before upload via the self-test snippet, or paste your HTML into /dev/multiplayer/upload.Auto-repair via Claude (deferred A5C).

Recommended patterns (canonical answers)

Common questions every multiplayer game faces, with the recommended answer pre-baked. These aren't required — they're "do this unless you have a reason not to."

Player colors — id-hash to a small palette

players[] has no color field by design. Hash player.id to one of N palette slots; same outputs on every client because the algorithm is deterministic.

// Pattern P1: deterministic id → color hashing.
// players[] has no `color` field, so every game does this. Simplest form:
function colorForId(id) {
  const palette = ['#2dd4bf', '#facc15', '#fb7185', '#a78bfa', '#60a5fa', '#34d399']
  let h = 0
  for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) | 0
  return palette[Math.abs(h) % palette.length]
}

Iteration order in tick — sort by id

JavaScript object insertion order is consistent on a single machine but can DIFFER across clients in race conditions (e.g., two players join at the same instant on different machines). For tick determinism, always iterate by sorted id.

// Pattern P8: iterate players by id-sorted order, NOT insertion order.
// JS object insertion order can DIFFER across clients (e.g., when two
// players join at the same instant on different machines). Sorted iteration
// is the only path that keeps tick deterministic.
tick(dtMs) {
  const ids = Object.keys(state.players).sort()  // ← critical
  for (const id of ids) {
    /* apply intent[id], advance state.players[id], etc. */
  }
}

Simultaneous-event tiebreaker

When two players cross a threshold on the same tick (e.g., both finish a race in tick 1234), iterate by sorted id and take the first hit. Same idea as above — sorted iteration order is the determinism rule for ALL multi-player resolution.

Per-player vs shared world resources

Default to per-player for collectibles (boost pads, power-ups). Shared = the leader stockpiles everything and grief-locks the field; per-player = everyone gets a fair shot. Track via pad.collectedBy[playerId] = true or similar object map.

Cosmetic effects live OUTSIDE state (in localUI)

Particles, screen-shake, hit-sparkles, hover highlights — none of these affect gameplay. Don't put them in state or they'll bloat snapshots and force every client to agree on coordinates that don't matter. Keep them in a module-local localUI object, driven by the render loop. Each client renders its own — same input event triggers similar-but-not-identical visual flair. Tick stays clean.

// localUI lives outside state — never serialized, never synced.
const localUI = { particles: [], shakeFrames: 0 }

// Render loop reads BOTH state (for game-truth) AND localUI (for flair)
function render() {
  drawState(state)
  for (const p of localUI.particles) drawParticle(p)
  if (localUI.shakeFrames > 0) { /* offset camera */ localUI.shakeFrames-- }
  requestAnimationFrame(render)
}

// Tick can SIGNAL render to spawn local flair, but the spawn happens in render
function tick(dtMs) {
  // ... game-truth state changes here
  if (someEvent) state.flashAt = state.tick  // clients see same tick → same render trigger
}

Co-op vs competitive — pick at architecture time

First architectural fork. Decide before you write a single line of multiplayer code.

StyleMapResourcesWin condition
Co-op (shared)Same map, same enemiesShared gold, shared livesEveryone wins or loses together
Competitive (side-by-side)Each player's own fieldPer-player gold, per-player livesLast standing / highest score

Co-op is simpler (one state map, one set of game-truth values) and matches Coin Collector / mp-game-template. Competitive needs duplicated state per player and split-screen rendering. Reach for co-op unless competitive is the gameplay's whole point.

Toggle vs explicit-value inputs (idempotency)

When two players hit the "speed up" key at the same instant, you'll receive twoonInput calls before the next tick. A toggle-shaped input ({action:'speed-toggle'}) flips state twice → no-op. An explicit-value input ({action:'speed', value:2}) sets state to 2 twice → still 2. The second form is idempotent under duplicate-receive; use it for any shared toggle.

// ❌ Toggle desyncs under duplicate input
ctx.emit('input', { action: 'speed-toggle' })
// onInput: state.speed = state.speed === 1 ? 2 : 1
// Two players → flips twice → ends back at 1, not 2.

// ✅ Explicit-value is idempotent
ctx.emit('input', { action: 'speed', value: 2 })
// onInput: state.speed = input.value
// Two players → sets to 2 twice → 2. Same for {action:'pause', value:true}.

Spectator detection (myId not in args.players)

v0.1 fixes the player roster at init time. A late-joiner receives a setState snapshot but isn't inargs.players. Detect this in your gameplay code and render a "spectator" badge — otherwise their input emits will be ignored silently and they'll be confused why nothing responds.

init({ seed, players, myId }) {
  state = { /* ... */ }
  // Spectator detection: my id wasn't in the roster at room creation.
  state.amSpectator = !players.some(p => p.id === myId)
}

// In render, show a banner if I'm spectating
if (state.amSpectator) drawBanner('Spectator — joined after game started')

Top-level B3Multiplayer install (not IIFE-scoped)

The platform may call init / tick BEFORE the user clicks "Play Solo" — for example, on a late-join replay. Install window.B3Multiplayer = {...} at module load, NOT inside a Solo-button click handler. Otherwise the platform calls into a missing object and your game freezes.

// ❌ Don't wrap the contract in an IIFE that runs only on Solo click
document.getElementById('btn-solo').onclick = () => {
  (function() {
    window.B3Multiplayer = { init() {/*...*/}, tick() {/*...*/}, /*...*/ }
    runGame()
  })()
}
// Platform calls init BEFORE the user clicks → undefined → frozen.

// ✅ Install at module load; Solo click only switches modes
window.B3Multiplayer = { init() {/*...*/}, tick() {/*...*/}, /*...*/ }
document.getElementById('btn-solo').onclick = () => {
  setMode('playing')   // just toggle visibility; gameplay loop already wired
}

Validator rule reference (all 14 rules)

The static validator runs at upload time (POST /api/admin/mp/validate-game, also exposed at /dev/multiplayer/upload) and emits per-rule pass/fail. Rules are scoped: lobby rules only fire when /mp-client.js is loaded, determinism rules only when /mp-runtime.js is loaded, and mode-toggle-buttons only when both (hybrid). The two always-on rules (viewport-meta, sdk-import) fire for every game with the meta marker.

Rule IDScopeFailure mode
viewport-metaAlwaysFails if <meta name="viewport"> is missing.
sdk-importAlwaysFails if neither /mp-client.js nor /mp-runtime.js is loaded.
b3mp-ready-callLobbyFails if B3MP.ready() is never called in the inline script.
b3mp-on-start-gameLobbyFails if B3MP.onStartGame(cb) is never registered.
leave-buttonLobbyFails if no <button> contains "Leave" / "leave" text.
media-query-mobileLobbyFails if no @media query is in any inline style.
touch-target-min-heightLobbyFails if no button-styled selector has min-height >= 44px (WCAG touch target).
forbidden-math-randomDeterminismFails if Math.random appears inside the tick body. Runtime-blocked too — calls throw at runtime.
forbidden-time-refsDeterminismFails if Date.now, performance.now, setTimeout, setInterval, or requestAnimationFrame appear inside tick.
forbidden-native-trigDeterminismFails if Math.sin/cos/tan/exp/log appears inside tick. Replace with ctx.math.*.
b3multiplayer-contract-completeDeterminismFails if window.B3Multiplayer isn't declared OR doesn't expose all 5 functions (init/tick/onInput/getState/setState).
mp-runtime-script-type-moduleDeterminismFails if <script src="/mp-runtime.js"> lacks type="module". Without it the browser SyntaxErrors on the runtime's top-level export and window.B3.ctx never installs.
mode-toggle-buttonsHybrid (lobby + determinism)Fails if no buttons mention both "Solo" / "single-player" AND "Multiplayer" text. Pushes hybrid games toward the start-screen pattern that prevents the "Waiting for game" failure mode.

The validator itself is at src/lib/multiplayer/contract-validator.ts (pure-function, ~430 lines). All 14 rules are unit-tested in contract-validator.spec.ts with surgical-break tests for each (mutate the HTML to break exactly one rule, assert exactly that rule fails). Run npx vitest run src/lib/multiplayer/__tests__/contract-validator.spec.ts to see them all green.

Self-test snippet

Paste this block into your game during dev. Runs deterministically on page load — if your game is internally deterministic, the two hashes match. Open the same URL in Chrome AND Safari to verify cross-engine (V8 vs JSC) determinism — the printed hashes must match byte-for-byte.

<!-- Paste this block into your game during dev. Removes itself when you upload. -->
<script type="module">
  function fnv1a(s) {
    let h = 0x811c9dc5
    for (let i = 0; i < s.length; i++) {
      h ^= s.charCodeAt(i)
      h = Math.imul(h, 0x01000193)
    }
    return (h >>> 0).toString(16).padStart(8, '0')
  }
  // Wait for window.B3Multiplayer to be installed by your game script.
  setTimeout(() => {
    if (!window.B3Multiplayer) { console.error('[self-test] no B3Multiplayer'); return }
    const game = window.B3Multiplayer
    function runOnce(seed, ticks) {
      game.init({ seed, players: [{id:'A',name:'A'}], myId: 'A' })
      for (let t = 0; t < ticks; t++) game.tick(33)
      return fnv1a(JSON.stringify(game.getState()))
    }
    const a = runOnce(12345, 60)
    const b = runOnce(12345, 60)
    if (a === b) console.log('[self-test] deterministic ✓ hash=' + a)
    else console.error('[self-test] FAILED ✗ a=' + a + ' b=' + b + ' (look for Math.random/Date.now/etc inside tick)')
  }, 100)
</script>

Links