/dev / build

Start here — building a game for b3.games

Read this before you write code. It's the mental model + traps. The contract spec is the canonical reference for multiplayer; this page is the why-and-shape on top.

1 · What you're building

A single self-contained HTML file. One index.html with inline <style> + <script> — no bundler, no imports of your own modules, no asset folder. The file gets uploaded to Supabase storage and served inside an iframe on b3.games.

  • 25MB cap on the HTML payload. Plenty of room for embedded base64 sprite sheets, textures, or a few inline GLTF models. Code split is impossible by design — single file, no bundler.
  • Canvas-based gameplay is expected. <canvas id="stage"> + requestAnimationFrame game loop is the conventional shape.
  • External CDNs are allowed for libraries (Three.js via jsdelivr/unpkg). They count against your wall-clock budget but not your 25MB.

2 · How your game runs (the iframe shell)

Your HTML doesn't load at the top frame. The b3.games shell wraps it in an iframe:

b3.games (top frame, Next.js shell)
  └── <iframe src="/api/games/<your-slug>/html?room=ABCDE">
        └── your HTML lives here

Practical implications:

  • Read URL params from your iframe's own URL, not the parent. The shell forwards ?room=ABCDE to your iframe so new URL(location.href).searchParams.get('room') works.
  • Don't use postMessage to talk to the parent shell. The shell doesn't listen. Communicate via the multiplayer SDK (which abstracts WS to the backend) or just stay self-contained.
  • Cookies + localStorage belong to your iframe origin (the API route serving the HTML), not the b3.games top origin. Save player progress via the platform's save APIs, not ad-hoc localStorage you may not see again.

3 · Single-player first, multiplayer as a layer

Even if multiplayer is the goal, build solo first. The platform's upload validator passes any single-player HTML+canvas game; it only enforces the multiplayer contract when you opt in via the <meta name="b3-multiplayer"> tag.

The canonical 3-layer pattern (used by the reference templates):

  1. Solo / MP toggle (start screen) — "Play solo" vs "Play multiplayer"
  2. Lobby (only on MP path) — host code, join code, roster, start button
  3. Gameplay — same canvas + game loop for both paths; MP path also reads from the shared state via the SDK

Build (1) and (3) first as a fully working solo game. Then add (2) by following the contract. This gives you a fallback on every page open: even if multiplayer breaks, solo still works.

4 · Loading patterns (script tag traps)

  • /mp-runtime.js must load as a module:
    <script type="module" src="/mp-runtime.js"></script>
    It uses top-level export, so a classic <script src> is a SyntaxError and window.B3.ctx never installs.
  • Shared browser helpers (/touch-controls.js,/dictionaries/*.js) load as classic scripts:
    <script src="/touch-controls.js"></script>
    They're IIFEs that attach to window.B3.<ns>. Don't add type="module".
  • Three.js + WebGL games are slow on no-GPU runners. If you're building a 3D game, expect ~20s extra boot time on headless Linux Chromium (CI test runners). Design your UI to show a load spinner; don't assume the game is interactive within 3 seconds.
  • No eval / new Function anywhere. Cloudflare Workers blocks them at runtime, and the upload validator flags them statically. Same for dynamic import() of your own code (you don't have a bundler anyway).

5 · Build it MP-ready (even if you skip MP for now)

Pre-MP design choices that make adding multiplayer later trivial vs painful:

  • Pure functions for game-state transitions: tick(state, dtMs, inputs) → new state, no side effects. The MP runtime calls this on every client; if it's a closure over DOM refs or timers, it won't replay deterministically.
  • No Math.random() / Date.now() in game logic. Use ctx.random() + ctx.now — the MP runtime injects these as seeded/synced values. The validator hard-blocks Math.random() in tick paths.
  • Team derivation by index, never by role: in N-team modes, your code is team = playerIndex % teamCount. Never use conn.getMyRole() for team membership — that's host vs joiner, which can flip on disconnect. (We've been bitten by this twice; chess Real PvP regression in CR.18 was the second.)
  • All real-time inputs go through ctx.emit('input', payload). Movement, shoot, pickup — every action rides the input frame channel. No out-of-band channels, no parent-frame postMessage.
  • Single-flight async patterns: if you have a recurring trigger (setInterval polling, etc.) that fires an async request, guard with if (pending != null) return. Otherwise a 2nd fire arriving before the 1st reply causes state-capture races.
  • Handle ?room=CODE on direct page open. Real users click share links; if your game opens cold with that param it should auto-join the room. Reference: see mp-game-template/index.html for the canonical pattern.

6 · Common traps (worth a re-read before submitting)

  • Don't assume your iframe loads from the same origin every time. b3.games geo-redirects EU IPs to es.b3.games (Vercel), but US IPs stay on b3.games (Cloudflare). Both serve identical content, but don't hard-code an origin in any of your fetch URLs. Use relative paths or the SDK; never fetch('https://b3.games/...').
  • Validator runs at upload-time only. Static-parse rules check for Math.random, missing meta tags, missing 5 SDK functions, etc. It does not run your code. Bugs in your game-logic still ship; you'll find them by playing.
  • The validator's failures come with contractAnchor deeplinks back to the relevant rule on the contract page. If something fails on upload, click the "Copy AI-fix prompt" button — it bundles the failure + anchor link + your code into a clipboard payload you paste into your AI session.
  • Console errors fail E2E tests but not real users. Production has heavy console noise (Three.js warnings, WebGL probes, font preloads). The platform's allow-list filter ignores those. Don't ship code with console.log spam, but don't panic over Three.js warnings.

7 · Quick reference

  • /dev/multiplayer/contract — canonical AI-consumption MP spec (5 functions, ctx surface, 6 modes, 3 genre examples)
  • /dev/multiplayer — MP dev tools landing (test harness, reference games, upload validator)
  • /dev/multiplayer/upload — upload your HTML; runs the contract validator + returns per-rule pass/fail with copyable AI-fix prompts
  • /dev/multiplayer/test — React reference harness for the WS protocol; useful when debugging connection issues
  • /games/mp-game-template/ — canonical 3-layer reference (Solo + MP)
  • /games/mp-game-template-turn-duel/ — turn-based 1v1 reference
  • /games/mp-game-template-turn-party/ — turn-based N-player reference

8 · Suggested workflow (humans + AI)

  1. Read this page (you're here)
  2. Decide: solo-only, or MP from day-one, or solo-now-MP-later? If solo-only or solo-now, skip the contract and just build an HTML+canvas game.
  3. If MP: open the contract; copy its URL into your AI session along with your single-player HTML and ask "rewrite this to follow the b3.games multiplayer contract."
  4. Test locally if possible (open the HTML directly in your browser). For MP, use the test harness or two browser tabs against a deployed copy.
  5. Upload via /dev/multiplayer/upload. If the validator flags issues, click "Copy AI-fix prompt" on each failure card and paste back into your AI session. Iterate until it passes.
  6. Play the deployed game with a friend on a different network. Real-world latency surfaces races your local tabs hide.

Last updated: 2026-05-06. Synthesized from accumulated CR.16-CR.21 lessons + memory entries. If something on this page contradicts the contract, the contract wins.