← All documentation

Build your first game: Tic-Tac-Toe, start to finish

A complete walkthrough. By the end you will have a real, published game that two strangers can play for real money — and you will understand why every piece is where it is.

Nothing here is skipped. If a command needs running, it is written out. If a file needs creating, its whole contents are here. The finished game is in this repository at games/tic-tac-toe (the client) and server/nakama/src/games/tic-tac-toe (the rules), so you can compare at any point.

Time: about an hour if you are new to this, twenty minutes if you are not.


What you are building

A two-player tic-tac-toe game. One player is X, the other O, and they take turns on a 3×3 board until someone gets three in a row.

That sounds trivial, and the rules are. Almost everything in this tutorial is about the part that is not trivial: making a game that cannot be cheated when there is money on it.


The one idea that everything follows from

Your game is two halves that do not trust each other.

   ┌──────────────────────────┐         ┌───────────────────────────┐
   │  The client bundle       │         │  The server rules         │
   │  (runs in the browser)   │         │  (runs on our servers)    │
   │                          │         │                           │
   │  • draws the board       │  ────▶  │  • whose turn is it       │
   │  • notices taps          │ intent  │  • is that move legal     │
   │  • shows what it is told │  ◀────  │  • who has won            │
   │                          │  view   │                           │
   │  Believed about nothing  │         │  The only thing that      │
   │                          │         │  counts                   │
   └──────────────────────────┘         └───────────────────────────┘

The client says "the player tapped square 4." The server decides whether that was a legal move, what the board now looks like, and eventually who won.

Why it has to be this way. The client runs on hardware the player owns. They can modify it, replace it, or write their own. If the client decided anything that mattered, the first person to open the developer tools would win every match. So it decides nothing.

The practical consequence for you: you will write the rules twice in your head and once in code. The rules live only on the server. The client is a drawing program with a message queue.

A useful test while you work: if a player replaced my client bundle with a hostile one, what is the worst they could do? The answer should always be "send moves the server refuses".


Before you start

What you need installed

Tool Version Why
Node.js 22 or newer Runs the build and the tests
pnpm 9 or newer The package manager this repository uses
Docker Desktop any current Runs the platform locally
zip any Packaging the bundle. Already on macOS and Linux

Check them:

node --version     # v22.x or higher
pnpm --version     # 9.x or higher
docker --version   # any

If pnpm is missing: npm install -g pnpm.

Get the platform running

git clone <the repository>
cd crazy8s-platform
pnpm install
cp .env.example .env
pnpm dev

The first run builds containers and takes a few minutes. When it settles you have:

What Where
Player site http://localhost:8080
Developer portal http://localhost:8081
Operations console http://localhost:8082

Leave pnpm dev running in that terminal and open a second one for everything below.

Full detail, including what to do when it will not start: running the platform locally.

Get yourself a developer account

Two steps, and the order matters.

First, register on the player site at http://localhost:8080/register. Any email will do locally. A developer account is a player account with a role added, so this one comes first — and it means you can play your own game through the path a player takes.

Then open the developer portal at http://localhost:8081, choose Create one under the sign-in form, and enter the same email and password with the name you want on your games. That is the whole of it: no application, and nobody to wait for.

You are asked for a legal name, contact address, country and payout account later, in the portal. They are required before you can submit a game for review — not before you can start building one.

For this tutorial you also want the operator role, because you are about to be both the developer submitting a game and the operator reviewing it. That one is not self-service:

# Create the role groups. Safe to run repeatedly; needed once per environment.
pnpm roles:seed

# Use the display name you registered with.
node scripts/grant-role.mjs --user <your-display-name> --role operator

Why the operator role is a script and not a button. That command needs the platform's HTTP key, which means access to the server's secrets. If an operator could grant the operator role from a web page, stealing one operator's session would be enough to take over the platform. A developer account can reach none of that, which is why it does not need the same ceremony. See granting a role.

Sign in at http://localhost:8082 for the operations console. If you get a 404 there, the operator role did not apply — sign out and in again, since your browser is holding a session from before the grant.


Start from a template, or from nothing

You can follow this tutorial by typing everything out — it is written so that you can, and you will understand the result better for it. If you would rather start from something that already runs, download a starter:

Template What it gives you Download
Nakama match handler The server half: the seven match functions, a rules skeleton, and tests you can run without a server. Every game needs one, whichever engine draws it. crazy8s-nakama-match-handler.zip
Excalibur.js client A complete buildable client — platform bridge, working game, bundler, and a local harness. The fastest route to something playable. crazy8s-excalibur.zip
Defold client The Lua bridge, HTML5 template and a local harness, and how to wire them into a project you create in the Defold editor. crazy8s-defold.zip
GDevelop client The JavaScript bridge, events wiring and a local harness, and what GDevelop gives you that a staked game must not use. crazy8s-gdevelop.zip

Take two: a client and the handler. They are separate downloads because they are separate halves — the handler is the same whichever engine you pick, and a developer switching engines keeps it.

The Excalibur and Nakama templates work together out of the box. Unzip both, upload them, and you have a playable game before you have written anything — which is worth doing once, so that when something breaks later you know it worked at some point.

Each template's README.md is written to be read first. Every one of them covers:

  • What you need installed, with versions and where to get it
  • The local development loop — how to run and re-run your game while you build it
  • Testing, and what to check before you upload
  • How to upload a finished game, field by field
  • Reference links — this platform's documentation, and the engine's own

Developing without uploading

Every client template ships harness.html: a single self-contained page that stands in for the platform on your own machine. It loads your bundle in the same kind of sandboxed frame the real host uses, speaks the same postMessage protocol, and gives you buttons for each thing the platform can send — a match state you type by hand, a rejected move, a win, a loss, a draw, an abort — with every message in both directions logged in order.

It cannot run your rules, because there is no server in it. That is the point: you type the views yourself, so when the board draws wrongly you know the bug is in the client and not in the handler.

The handler template goes the other way: src/rules.test.ts runs your rules with no browser, no Nakama and no network.

node --test --experimental-strip-types src/rules.test.ts

Between the two you can build and check both halves before anything is uploaded, which is worth doing — a bug found in a harness takes a minute, and the same bug found on staging takes an upload, a deploy and two test accounts.


Step 1 — Create the project

Your game is an ordinary Node project. Make a directory for it:

mkdir -p games/my-tic-tac-toe/src
cd games/my-tic-tac-toe

package.json

{
  "name": "@crazy8s/game-my-tic-tac-toe",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "node build.mjs",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@crazy8s/game-sdk": "workspace:*",
    "excalibur": "^0.30.0"
  },
  "devDependencies": {
    "esbuild": "^0.24.0",
    "typescript": "^5.6.3"
  }
}

What each dependency is for.

  • @crazy8s/game-sdk — the platform's contract. It gives you the message types your two halves exchange, and the seeded random generator. You will import types from it, not behaviour: it is small on purpose.
  • excalibur — the game engine that draws the board. Nothing about the platform requires Excalibur; it is used here because it is code-first, which makes determinism something you write rather than something you fight. Other engines work too.
  • esbuild — bundles your TypeScript into one JavaScript file. The platform will not fetch anything at runtime, so everything must be in the bundle.

Install:

pnpm install

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true,
    "noEmit": true,
    "lib": ["ES2020", "DOM"],
    "types": []
  },
  "include": ["src/**/*.ts"]
}

strict and noUncheckedIndexedAccess are worth keeping. Board code is full of array indexing, and noUncheckedIndexedAccess is what makes TypeScript remind you that board[9] might not exist.


Step 2 — The manifest

Every bundle has a manifest.json at its root. This is how the platform knows what your game is before it runs any of it.

Create manifest.json:

{
  "id": "my-tic-tac-toe",
  "name": "My Tic-Tac-Toe",
  "category": "realtime-authoritative",
  "protocolVersion": 1,
  "entry": "index.html",
  "tickRate": 5,
  "config": {}
}

Field by field:

Field What it means
id Lower-case, hyphenated. Permanent — it cannot change after your first submission
name What players see
category How the game is played and who decides the result. See below
protocolVersion The message contract you implement. 1 today
entry The HTML document that starts your game
tickRate How many times a second the server runs your match loop
config Anything your game wants at startup. Never money-related

Choosing a category

Category Shape Who decides
realtime-authoritative Live, turn-based. The server holds the board The server, entirely
async-pool Solo against a seeded puzzle; players pooled and ranked The server replays your input log
realtime-simulated Live physics on both clients Both simulate; the server compares

Tic-tac-toe is realtime-authoritative: two people, taking turns, with the server holding the truth.

What is deliberately not in the manifest

Stakes, the rake, and the payout split. Those are the platform's, identical for every game: stakes of E5, E10 and E20, a 10% rake, and for pooled games 50/30/10 to the top three. A manifest that declares a payoutSplit is rejected, not ignored — if you set one deliberately, you should be told it is not yours to set rather than left believing it took effect.

tickRate of 5 is deliberate here. Tic-tac-toe has nothing to animate on the server; it just needs to notice moves. Five ticks a second is plenty and costs almost nothing. A physics game might want 30 or 60.


Step 3 — Write the rules

This is the heart of your game, and it runs on the server.

Create src/rules.ts. Keep the rules as pure functions — data in, data out, no clock, no randomness of their own, no Nakama. That is not an aesthetic preference: it is what makes them testable without a server, and what makes their behaviour reproducible.

/**
 * The rules of tic-tac-toe.
 *
 * Pure functions: state in, state out. Nothing here reads the clock, generates
 * its own randomness, or touches the network. That is what makes these rules
 * testable on their own and reproducible when a result is disputed.
 */

/** A board square. Index 0-8, row-major: 0,1,2 is the top row. */
export type Cell = "" | "X" | "O";

export interface TicTacToeState {
  board: Cell[];
  /** Which player has which mark, by user id. */
  marks: Record<string, Cell>;
  /** Whose turn it is, by user id. */
  turn: string;
  moves: number;
  winner?: string;
  drawn: boolean;
  /** The three cells that won, so the client can highlight them. */
  winningLine?: number[];
}

/** What the platform gives you when a match starts. */
export interface RulesContext {
  matchId: string;
  /** Issued by the platform. Never generate your own. */
  seed: number;
  players: { userId: string; username: string }[];
}

/** A seeded generator, supplied by the platform. */
export interface SeededRandom {
  shuffle<T>(items: T[]): T[];
}

/** What happens to an attempted move. */
export type ActionOutcome =
  | { kind: "accepted"; state: TicTacToeState }
  /** Illegal, but play continues. */
  | { kind: "rejected"; reason: string }
  /** Bad enough to end the match. */
  | { kind: "violation"; state: TicTacToeState; offenderId: string; reason: string };

const LINES: number[][] = [
  [0, 1, 2], [3, 4, 5], [6, 7, 8],   // rows
  [0, 3, 6], [1, 4, 7], [2, 5, 8],   // columns
  [0, 4, 8], [2, 4, 6],              // diagonals
];

function findLine(board: Cell[]): { mark: Cell; line: number[] } | null {
  for (const line of LINES) {
    const [a, b, c] = line as [number, number, number];
    const first = board[a];
    if (first && first === board[b] && first === board[c]) return { mark: first, line };
  }
  return null;
}

export const ticTacToe = {
  id: "my-tic-tac-toe",

  /** Build the starting board. */
  init(context: RulesContext, prng: SeededRandom): TicTacToeState {
    // Who goes first comes from the platform's seed, not from join order.
    // Moving first is a real advantage, so awarding it to whoever connected
    // fastest would be unfair — and using the seed proves it reached the rules.
    const order = prng.shuffle([...context.players]);
    const first = order[0]!.userId;
    const second = order[1]!.userId;

    return {
      board: ["", "", "", "", "", "", "", "", ""],
      marks: { [first]: "X", [second]: "O" },
      turn: first,
      moves: 0,
      drawn: false,
    };
  },

  /** Whose turn it is, or null when the match is over. */
  currentPlayer(state: TicTacToeState): string | null {
    if (state.winner || state.drawn) return null;
    return state.turn;
  },

  /**
   * Apply an attempted move.
   *
   * Everything arriving here is untrusted: it came from a client. Check it all,
   * and prefer "rejected" to "violation" — a malformed message is far more
   * likely to be a bug in someone's client than an attempt to cheat.
   */
  applyAction(
    state: TicTacToeState,
    playerId: string,
    action: string,
    payload: unknown,
  ): ActionOutcome {
    if (action !== "place") {
      return { kind: "rejected", reason: `unknown action "${action}"` };
    }

    const cell = (payload as { cell?: unknown }).cell;

    if (typeof cell !== "number" || !Number.isInteger(cell) || cell < 0 || cell > 8) {
      return { kind: "rejected", reason: "cell must be a whole number from 0 to 8" };
    }

    if (state.board[cell] !== "") {
      return { kind: "rejected", reason: "that square is already taken" };
    }

    const mark = state.marks[playerId];
    if (!mark) {
      // Not a player in this match at all. The platform checks turn order; a
      // participant check belongs to the rules.
      return {
        kind: "violation",
        state,
        offenderId: playerId,
        reason: "acted in a match they are not part of",
      };
    }

    const board = [...state.board];
    board[cell] = mark;

    const next: TicTacToeState = {
      ...state,
      board,
      moves: state.moves + 1,
      turn: Object.keys(state.marks).filter((id) => id !== playerId)[0] ?? playerId,
    };

    const line = findLine(board);
    if (line) {
      next.winner = Object.keys(state.marks).filter((id) => state.marks[id] === line.mark)[0]!;
      next.winningLine = line.line;
    } else if (next.moves === 9) {
      next.drawn = true;
    }

    return { kind: "accepted", state: next };
  },

  isComplete(state: TicTacToeState): boolean {
    return state.winner !== undefined || state.drawn;
  },

  /** How the match ended. A statement of fact, not an instruction to pay. */
  result(state: TicTacToeState): { winnerId?: string; reason: string } {
    if (state.winner) return { winnerId: state.winner, reason: "three-in-a-row" };
    // No winner on a draw, so the platform refunds both stakes. Neither player
    // lost, so neither should pay.
    return { reason: "draw" };
  },

  /**
   * What one player is allowed to see.
   *
   * Tic-tac-toe hides nothing, so this returns the whole board. It still exists,
   * and you should still use it, because a card game that forgets this leaks
   * every hand — and the habit is what protects you when your next game does
   * have secrets.
   */
  viewFor(state: TicTacToeState, playerId: string): unknown {
    return {
      board: state.board,
      yourMark: state.marks[playerId] ?? null,
      moves: state.moves,
      ...(state.winningLine ? { winningLine: state.winningLine } : {}),
      ...(state.drawn ? { drawn: true } : {}),
    };
  },
};

Three things worth pausing on

1. init takes a seed and uses it. Certification checks that you draw from the seed. A game that ignores it gives every match the same starting conditions, which for a pooled game means every player gets the same puzzle — and for this game means the same person always goes first.

2. result() returns no winner for a draw. That is how you get both stakes refunded. There is no "refund" call for you to make; the absence of a winner is the refund.

3. Nothing here knows what the match is worth. No stake, no balance, no prize. If you find yourself wanting that number, something has gone wrong in the design — the platform deliberately does not give it to you, because a rule that depends on the stake is a rule that can be gamed by choosing a stake.


Step 4 — Write the match handler

The rules say what is legal. The match handler runs the match: who joined, whose turn it is, what to send to whom, and when it is over.

This is an ordinary Nakama match handler: seven functions, written directly. There is no framework of ours on top of it.

Create src/match.ts:

/**
 * The match handler.
 *
 * Note what this file cannot do: it never sees a stake, a balance or a prize,
 * and imports nothing that could move money. The platform took the stakes into
 * escrow before this match was created, and will settle against that escrow
 * once this match reports its outcome.
 *
 * That is enforced, not merely intended — a build check fails if this file
 * reaches for the platform's economy, and the database role this runs under
 * would refuse the write anyway.
 */

import { Prng } from "../../lib/prng-adapter.ts";
import {
  publishLabel,
  publishOutcome,
  isOutcomeQuery,
  outcomeReply,
  newTurnTimer,
  turnBegan,
  checkTurnTimeout,
  sendViews,
  readIntent,
  type GamePlayer,
  type MatchOutcome,
  type TurnTimer,
} from "../../match/game-kit.ts";
import { ticTacToe, type TicTacToeState } from "./rules.ts";

/**
 * Message opcodes.
 *
 * These five are the platform's convention, and using them means the player's
 * page can run your game with no code specific to it. Anything above 5 is
 * yours; the host ignores opcodes it does not recognise rather than guessing.
 */
export const Op = {
  STATE: 1,      // server → client: this player's view
  MOVE: 2,       // client → server: an attempted move
  REJECTED: 3,   // server → client: that move was refused
  COMPLETE: 4,   // server → client: the match ended
  TIMEOUT: 5,    // server → client: a turn ran out
} as const;

const TICK_RATE = 5;
const TURN_TIMEOUT_SEC = 30;
const MAX_STRIKES = 3;
/** Ticks with nobody present before the match gives up. */
const MAX_EMPTY_TICKS = 150;

interface State {
  game: TicTacToeState | null;
  players: GamePlayer[];
  presences: Record<string, nkruntime.Presence>;
  timer: TurnTimer;
  /** Opaque. Issued by the platform; meaningless to this handler. */
  escrowId: string;
  matchId: string;
  outcome: MatchOutcome | null;
  emptyTicks: number;
}

const presenceList = (state: State): nkruntime.Presence[] =>
  Object.keys(state.presences).map((id) => state.presences[id]!);

/** Send every present player their own view. */
function broadcastViews(dispatcher: nkruntime.MatchDispatcher, state: State): void {
  sendViews(
    dispatcher,
    Op.STATE,
    presenceList(state),
    (userId) => ticTacToe.viewFor(state.game!, userId),
    (userId) => ticTacToe.currentPlayer(state.game!) === userId,
  );
}

// 1 ────────────────────────────────────────────────────────────────────────
export function matchInit(
  _ctx: nkruntime.Context,
  logger: nkruntime.Logger,
  _nk: nkruntime.Nakama,
  params: { [key: string]: string },
): { state: State; tickRate: number; label: string } {
  return {
    state: {
      game: null,
      players: [],
      presences: {},
      timer: newTurnTimer(0),
      // Hold it, hand it back when asked, never interpret it.
      escrowId: params["escrowId"] ?? "",
      matchId: params["matchId"] ?? "my-tic-tac-toe",
      outcome: null,
      emptyTicks: 0,
    },
    tickRate: TICK_RATE,
    // The label is how the platform finds this match to settle it, without this
    // handler needing any storage access at all.
    label: JSON.stringify({ game: "my-tic-tac-toe", phase: "open" }),
  };
}

// 2 ────────────────────────────────────────────────────────────────────────
export function matchJoinAttempt(
  _ctx: nkruntime.Context,
  _logger: nkruntime.Logger,
  _nk: nkruntime.Nakama,
  _dispatcher: nkruntime.MatchDispatcher,
  _tick: number,
  state: State,
  presence: nkruntime.Presence,
  _metadata: { [key: string]: any },
): { state: State; accept: boolean; rejectMessage?: string } {
  // Someone reconnecting after a dropped connection.
  if (state.players.some((p) => p.userId === presence.userId)) {
    return { state, accept: true };
  }
  if (state.game) return { state, accept: false, rejectMessage: "this match has already started" };
  if (state.players.length >= 2) return { state, accept: false, rejectMessage: "this match is full" };
  return { state, accept: true };
}

// 3 ────────────────────────────────────────────────────────────────────────
export function matchJoin(
  _ctx: nkruntime.Context,
  _logger: nkruntime.Logger,
  _nk: nkruntime.Nakama,
  dispatcher: nkruntime.MatchDispatcher,
  tick: number,
  state: State,
  presences: nkruntime.Presence[],
): { state: State } {
  for (const presence of presences) {
    state.presences[presence.userId] = presence;
    if (!state.players.some((p) => p.userId === presence.userId)) {
      state.players.push({
        userId: presence.userId,
        username: presence.username ?? presence.userId,
      });
    }
  }

  // Already running: this is a reconnection. Resend their view and carry on.
  if (state.game) {
    broadcastViews(dispatcher, state);
    return { state };
  }

  // Wait for both players.
  if (state.players.length < 2) return { state };

  // Start. The seed is the platform's; the rules use it to decide who is X.
  const seed = Math.floor(Math.random() * 0x7fffffff);
  state.game = ticTacToe.init(
    { matchId: state.matchId, seed, players: state.players },
    new Prng(seed),
  );
  state.timer = newTurnTimer(tick);
  broadcastViews(dispatcher, state);
  return { state };
}

// 4 ────────────────────────────────────────────────────────────────────────
export function matchLoop(
  _ctx: nkruntime.Context,
  logger: nkruntime.Logger,
  _nk: nkruntime.Nakama,
  dispatcher: nkruntime.MatchDispatcher,
  tick: number,
  state: State,
  messages: nkruntime.MatchMessage[],
): { state: State } | null {
  // Nobody here and no game started: give up eventually rather than running for
  // ever. Returning null ends the match.
  if (!state.game) {
    state.emptyTicks++;
    return state.emptyTicks > MAX_EMPTY_TICKS ? null : { state };
  }

  for (const message of messages) {
    if (message.opCode !== Op.MOVE) continue;

    const sender = message.sender.userId;

    // `readIntent` decodes the message and unwraps `{ action, payload }`.
    // Do not read `message.data` yourself: it is an ArrayBuffer, and casting it
    // to a string makes every move look malformed.
    const intent = readIntent(message);
    if (!intent) {
      dispatcher.broadcastMessage(
        Op.REJECTED, JSON.stringify({ reason: "malformed move" }), [message.sender],
      );
      continue;
    }

    // Turn order, checked here rather than buried in the rules.
    if (ticTacToe.currentPlayer(state.game) !== sender) {
      dispatcher.broadcastMessage(
        Op.REJECTED, JSON.stringify({ reason: "it is not your turn" }), [message.sender],
      );
      continue;
    }

    const outcome = ticTacToe.applyAction(state.game, sender, intent.action, intent.payload);

    if (outcome.kind === "rejected") {
      dispatcher.broadcastMessage(
        Op.REJECTED, JSON.stringify({ reason: outcome.reason }), [message.sender],
      );
      continue;
    }

    if (outcome.kind === "violation") {
      state.game = outcome.state;
      const other = state.players.filter((p) => p.userId !== outcome.offenderId)[0];
      state.outcome = { winnerId: other?.userId, reason: outcome.reason };
      publishOutcome(dispatcher, {
        gameId: "my-tic-tac-toe",
        completeOpCode: Op.COMPLETE,
        outcome: state.outcome,
      });
      return { state };
    }

    state.game = outcome.state;
    state.timer = turnBegan(state.timer, tick, ticTacToe.currentPlayer(state.game) ?? "");
    broadcastViews(dispatcher, state);

    if (ticTacToe.isComplete(state.game)) {
      const result = ticTacToe.result(state.game);
      state.outcome = { ...(result.winnerId ? { winnerId: result.winnerId } : {}), reason: result.reason };
      publishOutcome(dispatcher, {
        gameId: "my-tic-tac-toe",
        completeOpCode: Op.COMPLETE,
        outcome: state.outcome,
      });
      return { state };
    }
  }

  // Turn timers. A player who stalls forfeits eventually, so a match cannot
  // hang for ever with someone's stake held in it.
  const onTurn = ticTacToe.currentPlayer(state.game);
  if (onTurn) {
    const check = checkTurnTimeout(state.timer, {
      tick, playerId: onTurn, timeoutSec: TURN_TIMEOUT_SEC,
      tickRate: TICK_RATE, maxStrikes: MAX_STRIKES,
    });
    if (check.timedOut) {
      state.timer = check.timer;
      if (check.forfeit) {
        const other = state.players.filter((p) => p.userId !== onTurn)[0];
        state.outcome = { winnerId: other?.userId, reason: "timeout-limit" };
        publishOutcome(dispatcher, {
          gameId: "my-tic-tac-toe", completeOpCode: Op.COMPLETE, outcome: state.outcome,
        });
        return { state };
      }
      dispatcher.broadcastMessage(
        Op.TIMEOUT, JSON.stringify({ reason: "your turn ran out", strikes: check.strikes }),
      );
    }
  }

  return { state };
}

// 5 ────────────────────────────────────────────────────────────────────────
export function matchLeave(
  _ctx: nkruntime.Context,
  _logger: nkruntime.Logger,
  _nk: nkruntime.Nakama,
  dispatcher: nkruntime.MatchDispatcher,
  _tick: number,
  state: State,
  presences: nkruntime.Presence[],
): { state: State } {
  for (const presence of presences) delete state.presences[presence.userId];

  // Someone left mid-match. Their opponent wins rather than the stakes hanging.
  if (state.game && !ticTacToe.isComplete(state.game) && Object.keys(state.presences).length < 2) {
    const remaining = Object.keys(state.presences)[0];
    state.outcome = { ...(remaining ? { winnerId: remaining } : {}), reason: "opponent-left" };
    publishOutcome(dispatcher, {
      gameId: "my-tic-tac-toe", completeOpCode: Op.COMPLETE, outcome: state.outcome,
    });
  }
  return { state };
}

// 6 ────────────────────────────────────────────────────────────────────────
export function matchTerminate(
  _ctx: nkruntime.Context,
  _logger: nkruntime.Logger,
  _nk: nkruntime.Nakama,
  _dispatcher: nkruntime.MatchDispatcher,
  _tick: number,
  state: State,
  _graceSeconds: number,
): { state: State } {
  return { state };
}

// 7 ────────────────────────────────────────────────────────────────────────
export function matchSignal(
  _ctx: nkruntime.Context,
  _logger: nkruntime.Logger,
  _nk: nkruntime.Nakama,
  _dispatcher: nkruntime.MatchDispatcher,
  _tick: number,
  state: State,
  data: string,
): { state: State; data?: string } {
  // The platform asks for the outcome when it is ready to settle. It pulls;
  // your handler never pushes to platform systems.
  if (isOutcomeQuery(data)) {
    return {
      state,
      data: outcomeReply({
        escrowId: state.escrowId,
        players: state.players,
        outcome: state.outcome,
      }),
    };
  }
  return { state };
}

All seven are required

The upload is rejected if any is missing, and the error names the ones you left out. Even matchTerminate, which here does nothing, has to exist — Nakama calls it, and a handler without it fails to register.

What your handler is allowed to touch

Allowed Not allowed
dispatcher.broadcastMessage Anything on nk — storage, wallets, accounts
dispatcher.matchKick Network calls of any kind
dispatcher.matchLabelUpdate Another match's state
Your own match state Anything to do with money

This is checked by a build rule, not by convention. It is what makes it safe to run third-party code on a platform that holds player balances.


Step 5 — Write the client

Now the part players see. Create src/main.ts.

/**
 * The client.
 *
 * It draws the board and reports where the player tapped. It does not know the
 * rules, does not decide whose turn it is, and does not decide who won.
 *
 * This file could be rewritten by an attacker and the worst it could do is send
 * moves the server refuses.
 */

import * as ex from "excalibur";
import { PROTOCOL_VERSION, type HostMessage } from "@crazy8s/game-sdk";

const BOARD_SIZE = 3;
const CELL = 120;
const GAP = 8;

const COLOR = {
  cell: ex.Color.fromHex("#1a3a5c"),
  cellHover: ex.Color.fromHex("#245080"),
  win: ex.Color.fromHex("#2f6a3f"),
  mark: ex.Color.fromHex("#f5b01a"),
  markOpponent: ex.Color.fromHex("#6ea8e8"),
};

/** The server's view for this player. Whatever `viewFor` returned. */
interface BoardView {
  board: string[];
  yourMark: string | null;
  moves: number;
  winningLine?: number[];
  drawn?: boolean;
}

let view: BoardView | null = null;
let yourTurn = false;
let finished = false;

const statusEl = document.getElementById("status")!;

/**
 * Send a message to the host page.
 *
 * `"*"` as the target origin is safe here only because nothing secret is ever
 * sent: your game has no secrets to leak, because it is never given any.
 */
function send(message: unknown): void {
  parent.postMessage(message, "*");
}

/** One square. Excalibur handles the input; the server handles the meaning. */
class Cell extends ex.Actor {
  private readonly index: number;
  private tile: ex.Rectangle;
  private label: ex.Text;

  constructor(index: number) {
    const row = Math.floor(index / BOARD_SIZE);
    const col = index % BOARD_SIZE;
    super({
      x: GAP + col * (CELL + GAP) + CELL / 2,
      y: GAP + row * (CELL + GAP) + CELL / 2,
      width: CELL,
      height: CELL,
    });

    this.index = index;

    // Keep a reference to the graphic you intend to recolour. Passing
    // `color:` to the constructor draws the square once and assigning
    // `this.color` afterwards does not reliably repaint it — see "the board
    // never changes colour" below.
    this.tile = new ex.Rectangle({ width: CELL, height: CELL, color: COLOR.cell });
    this.graphics.use(this.tile);

    this.label = new ex.Text({
      text: "",
      font: new ex.Font({ size: 64, family: "system-ui", bold: true }),
      color: COLOR.mark,
    });
    const labelActor = new ex.Actor({ x: 0, y: 0 });
    labelActor.graphics.use(this.label);
    this.addChild(labelActor);
  }

  override onInitialize(): void {
    this.on("pointerup", () => this.tap());
    this.on("pointerenter", () => { if (this.playable()) this.tile.color = COLOR.cellHover; });
    this.on("pointerleave", () => { if (this.playable()) this.tile.color = COLOR.cell; });
  }

  private playable(): boolean {
    return !finished && yourTurn && view?.board[this.index] === "";
  }

  private tap(): void {
    if (!this.playable()) return;

    // An intent, not a move. The server decides whether it happened.
    send({ type: "intent", action: "place", payload: { cell: this.index } });

    // Deliberately no optimistic update. Drawing a mark the server might refuse
    // shows the player a board that does not exist, and taking it back a moment
    // later is worse than a brief wait.
    yourTurn = false;
    render();
  }

  /** Redraw from the server's view — the only source of truth. */
  refresh(): void {
    const mark = view?.board[this.index] ?? "";
    this.label.text = mark;
    this.label.color = mark === view?.yourMark ? COLOR.mark : COLOR.markOpponent;
    const won = view?.winningLine?.includes(this.index) ?? false;
    this.tile.color = won ? COLOR.win : COLOR.cell;
  }
}

const engine = new ex.Engine({
  canvasElementId: "game",
  width: BOARD_SIZE * CELL + GAP * (BOARD_SIZE + 1),
  height: BOARD_SIZE * CELL + GAP * (BOARD_SIZE + 1),
  backgroundColor: ex.Color.fromHex("#0c1b2e"),
  displayMode: ex.DisplayMode.FitScreen,
  suppressPlayButton: true,
});

const cells: Cell[] = [];
for (let i = 0; i < 9; i++) {
  const cell = new Cell(i);
  cells.push(cell);
  engine.add(cell);
}

function render(): void {
  for (const cell of cells) cell.refresh();
  if (!view) { statusEl.textContent = "Waiting for the match to start…"; return; }
  if (finished) return;
  statusEl.textContent = yourTurn
    ? `Your turn — you are ${view.yourMark}`
    : "Waiting for your opponent…";
}

window.addEventListener("message", (event: MessageEvent) => {
  // Only the host may drive us, and the host is our parent.
  if (event.source !== parent) return;

  const message = event.data as HostMessage;
  if (!message || typeof message !== "object") return;

  switch (message.type) {
    case "init":
      send({ type: "ready", protocolVersion: PROTOCOL_VERSION });
      return;

    case "start":
      void engine.start();
      render();
      return;

    case "match-state":
      view = message.view as BoardView;
      yourTurn = message.yourTurn;
      finished = false;
      render();
      return;

    case "move-rejected":
      statusEl.textContent = message.reason;
      yourTurn = true;   // they may try again
      render();
      return;

    case "match-complete":
      finished = true;
      for (const cell of cells) cell.refresh();
      statusEl.textContent =
        message.reason === "draw" ? "A draw."
          : message.youWon ? "You win." : "You lose.";
      return;

    case "abort":
      finished = true;
      statusEl.textContent = "The match ended unexpectedly.";
      return;
  }
});

// Tell the host we exist, in case `init` arrived before this script ran.
send({ type: "ready", protocolVersion: PROTOCOL_VERSION });

Telling the player what is happening

Two different things get said to a player during a match, and keeping them apart is what stops a page contradicting itself.

The platform owns the match states. They are the same for every game on the platform, so a player moving between games does not have to learn new words for the same situation:

Phase What the player is told When
connecting "Connecting to the match…" Opening the connection
waiting "Waiting for another player…" Seated, not enough players yet
playing (the game's own line, if it sends one) The match is running
ended (the outcome panel: you won / you lost / a draw) An outcome was reported
failed The reason it failed Something went wrong

You do not send these and you cannot change them. A game that says "waiting for an opponent" in its own words creates a second caption that will eventually disagree with the first — and when they disagree, the player believes neither.

Your game owns the detail inside playing. Whose turn it is, what they are being asked to do. Send it with a status message:

function publishStatus(text: string): void {
  send({ type: "status", text });
}

// Whenever the board changes:
publishStatus(yourTurn ? `Your turn — you are ${view.yourMark}` : "Your opponent is thinking…");

// And when the match ends — the platform announces the result from here:
publishStatus("");

The rules:

  • Short. It renders on one line beside your game; the platform trims it and caps it at 120 characters.
  • Only what the platform cannot know. Turn order, phase of play, what input you are waiting for. Never connection state, never the result.
  • Clear it when the match ends. Whatever you were saying about whose turn it is stopped being true.
  • Optional. A game that sends none is not broken; the player simply sees your game without a caption.

Your game can also draw its own status inside its frame, as this one does — that is your canvas and yours to use. Keep the two in step by sending the same text to both, as render() does above. Two captions saying different things is worse than one.

The handshake, in order

   your bundle loads
        │
        ├──▶  ready          "I exist and I speak protocol v1"
        │
        ◀──   start          "begin"
        │
        ◀──   match-state    { view, yourTurn }   ← every time the board changes
        │
        ├──▶  intent         { action, payload }  ← every time the player taps
        │
        ◀──   move-rejected  "it is not your turn"
        │
        ◀──   match-complete { youWon, reason }

Send ready twice, as this file does — once when your script runs, and again if init arrives. You do not control whether the host is listening before your script executes, and a missed ready means a game that never starts.

match-complete is not a payment. It tells you the match ended. Settlement happens afterwards, on the platform. Do not show a balance you have calculated yourself.


Step 6 — The entry document and the build

index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Tic-Tac-Toe</title>
<style>
  :root { color-scheme: dark; }
  * { box-sizing: border-box; }
  body {
    margin: 0; min-height: 100vh;
    display: grid; place-content: center; gap: 1rem;
    background: #0c1b2e; color: #eef4fb;
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    padding: 1rem; user-select: none;
  }
  #wrap { width: min(92vw, 26rem); aspect-ratio: 1; }
  canvas { width: 100%; height: 100%; display: block; border-radius: 0.75rem; }
  #status { text-align: center; min-height: 1.5rem; color: #b9cfe6; margin: 0; }
</style>
</head>
<body>
  <div id="wrap"><canvas id="game"></canvas></div>
  <p id="status">Loading…</p>
  <script src="game.js"></script>
</body>
</html>

Size it to the space it is given, not to the screen. Your game runs in a frame whose size the platform chooses, and that frame is different on a phone and a laptop. min(92vw, 26rem) and aspect-ratio: 1 keep the board square and inside its box. A fixed pixel size gets clipped on small screens.

build.mjs

/**
 * Bundle the client.
 *
 * One static file: nothing fetched at runtime, nothing loaded lazily. That is
 * what the sandbox permits and what certification measures.
 */
import { build } from "esbuild";
import { copyFileSync } from "node:fs";

await build({
  entryPoints: ["src/main.ts"],
  outfile: "game.js",
  bundle: true,
  format: "iife",
  target: "es2020",
  platform: "browser",
  // Unminified so the certification scan can read it. Minifying is allowed, but
  // then the scan cannot help you and the determinism tests carry the weight.
  minify: false,
  logLevel: "info",
});

copyFileSync("index.template.html", "index.html");
console.log("built my-tic-tac-toe");

Save your HTML as index.template.html, then:

pnpm build

You should see game.js and index.html appear. game.js will be around 1 MB — most of that is Excalibur.

Check the size. The limit is 10 MB expanded, not compressed. du -h game.js tells you where you stand. Excalibur costs about 1 MB before you add anything.


Step 7 — Make the store assets

Players see these before they ever play. You need a thumbnail; a hero image is optional.

Asset Size Max file size Format Required
Thumbnail exactly 512×512 512 KB PNG or WebP Yes
Hero exactly 1280×720 1 MB PNG or WebP No
Screenshots exactly 1280×720 1 MB each, 6 max PNG or WebP No

Every one of those sizes is exact. An image a single pixel out is rejected — I got the hero wrong the first time writing this tutorial and the platform told me must be exactly 1280×720, but this file is 1280×320.

The dimensions are measured from the file, not taken from the form. A 500×500 image is rejected with must be exactly 512×512, but this file is 500×500. So is a file that claims to be a PNG and is not — the type is decided by reading the bytes, because a browser will happily send image/png for an HTML document, and serving that back is how an "image" upload becomes a security problem.

Any tool works. If you want one with no dependencies, this Python script produces the thumbnail used by this tutorial:

python3 scripts/make-game-thumbnail.py games/my-tic-tac-toe/store

Step 8 — Package the bundle

Everything the browser needs, zipped from the root of your game — no wrapping folder:

cd games/my-tic-tac-toe
zip -r ../../my-tic-tac-toe.zip index.html game.js manifest.json

Check what you made:

unzip -l ../../my-tic-tac-toe.zip

You want to see index.html at the top level, not my-tic-tac-toe/index.html. If the paths have a folder prefix, the platform cannot find your entry document.

Why the archive is expanded on upload. A browser cannot load a game out of a zip, so the platform unpacks it and serves the files. Paths are checked before anything is written: an archive containing ../../etc/passwd is refused, and so is one that claims a file is smaller than it is.


Step 9 — Upload

Sign in to the developer portal at http://localhost:8081 and choose New game.

Field What to put Notes
Game id my-tic-tac-toe Permanent. Must match your manifest
Title My Tic-Tac-Toe What players see
Tagline Three in a row, head to head One line, on the storefront card
Description A short paragraph What the game is
Instructions How to play, in a sentence or two Players read this before staking
Category realtime-authoritative Matches your manifest
Thumbnail store/thumbnail.png Exactly 512×512
Bundle my-tic-tac-toe.zip The archive from step 8
Entry index.html The document inside the archive
Handler module src/match.ts Your match handler
Handler name my_tic_tac_toe Underscores, not hyphens
Offer free practice Yes Lets people try it without staking

Press Submit for review.

What the platform checks, and what the errors mean

Message What happened
must be exactly 512×512, but this file is 500×500 Thumbnail is the wrong size. It is measured, not declared
must be a PNG or WebP image The bytes are not an image, whatever the filename says
expands to 14.2MB, over the 10MB limit The limit is on the expanded bundle. Compressing harder will not help
the archive has no file named "index.html" Your zip has a folder prefix. Re-zip from inside the directory
contains paths that escape the bundle root Something in the archive points outside it
missing exports: matchTerminate Your handler is short a function. All seven are required
runtime.stakes contains 5000 You set your own stakes. They are the platform's

Every one of these is measured from your files. None of them can be talked around by changing a form field, which is the point.


Step 10 — Review and publish

You submitted as a developer. Now put on the operator hat and open http://localhost:8082Submissions.

Your game is submitted and frozen — you cannot edit it while it is under review, so the reviewer is always looking at the same bytes.

Move it through the states:

  1. Deploy to stagingstaged. Real infrastructure, play money only.
  2. Approveapproved. Reviewed and accepted, not yet visible.
  3. Publishlive. In the catalogue, taking real stakes.

approved and live are separate on purpose: approval is a judgement about the game, publishing is a scheduling decision, and they can be made by different people.

Publishing puts your game in the catalogue immediately. No deploy, no release. Open http://localhost:8080/games and it is there.

What a reviewer actually does

Not just reading code. On a real submission they will:

  • play it, at least twice, with two accounts
  • check the balances move by the amounts they expect
  • disconnect mid-match to see whether stakes get stuck
  • try to make it pay out when it should not — reload, open the console, send unexpected messages
  • read your handler

The question is not "does it work". It is "can a player make it pay out when it should not". A game that works perfectly and can be cheated is worse than one that crashes.


Step 11 — Play it

http://localhost:8080/play/my-tic-tac-toe, choose Play for free, and open the same page in a second browser (or a private window, signed in as a second account). When both are connected the board appears and one of you is X.

If something is wrong

What you see Where to look
"Waiting for the match to start…" for ever Only one player has connected. A match needs both
The board renders but clicks do nothing Your playable() check, or yourTurn never became true
Every move says "malformed move" You are reading message.data directly. Use readIntent
"It is not your turn" when it is Your view is missing yourTurn. Pass the turn predicate to sendViews
The game never loads at all Open the browser console. A bundle that throws on startup never sends ready, and so does one an extension has blocked
It loads but the bottom is cut off Your layout assumes a screen size. Size to the frame you are given
The second player sees nothing until the first moves You broadcast from inside matchJoin
The winning move is never drawn You returned before sending the final view
The match ends but nobody is paid Your handler destroyed the match before it could be settled
The loser is only told "match over" Your completion message did not name the winner
Two tabs will not match against each other They are the same account. Cookies are per browser, not per tab

Each of these is explained in full, with the fix, in things that will catch you out.


Things that will catch you out

Every one of these was hit while building the game in this tutorial. None produced an error message, a stack trace, or a line in any log — each one just made the game quietly wrong. They are listed symptom first, because that is how you will arrive.


"The second player sees nothing until the first one moves"

Cause: you broadcast the opening view from inside matchJoin.

A player's presence is not fully registered until matchJoin returns, so a message sent during it can go to a session that is not listening yet. The first player is already connected and gets it; the one who just joined does not. They then sit looking at "waiting" until some later broadcast — the first player's move — happens to arrive.

Fix: set a flag and send on the next tick.

// In matchJoin, after starting the game:
state.broadcastPending = true;
return { state };

// At the top of matchLoop:
if (state.broadcastPending) {
  state.broadcastPending = false;
  sendViews(dispatcher, Op.STATE, presenceList(state), viewFor, isYourTurn);
}

A fifth of a second later, and it always works.


"The winning move is never drawn"

Cause: you returned as soon as the game was complete.

// Wrong — the final move is never sent
if (rules.isComplete(state.game)) {
  return finish(dispatcher, state, result.winnerId, result.reason, tick);
}
sendViews(...);          // never reached on the winning move

Players watch the board freeze one move short and are then told somebody won, with no way to see how. It looks like the game lost the last move.

Fix: broadcast first, then finish.

sendViews(...);          // show what just happened

if (rules.isComplete(state.game)) {
  return finish(dispatcher, state, result.winnerId, result.reason, tick);
}

"Every move comes back as malformed"

Cause: you read message.data yourself.

It is an ArrayBuffer, not a string. JSON.parse stringifies it to "[object ArrayBuffer]" and throws, so every move in every match is rejected. A TypeScript cast silences the error that would have caught it.

The client also sends { action, payload }, so a handler reading its own fields off the top level finds nothing even after decoding.

Fix: use readIntent(message). It does both.


"It says it is not my turn when it is"

Cause: the state message did not carry yourTurn.

sendViews takes a turn predicate as a required argument. Only your rules know whose turn it is — the platform does not read them. A client that is never told assumes it is not its turn, so no move is ever legal and the game appears frozen for everyone.

sendViews(
  dispatcher, Op.STATE, presenceList(state),
  (userId) => rules.viewFor(state.game, userId),
  (userId) => rules.currentPlayer(state.game) === userId,   // ← this one
);

"The match ends but nobody gets paid"

Cause: your handler destroyed the match when it finished.

Returning null tells Nakama to tear the match down immediately. But the platform settles by finding finished matches and asking each one for its outcome — and a match that no longer exists cannot be asked. Both stakes then sit in escrow until the stuck-escrow sweep refunds them half an hour later. The winner is never paid.

Fix: keep the match alive until the platform has taken the outcome.

// finish() returns { state }, not null
state.outcome = { winnerId, reason };
publishOutcome(dispatcher, { gameId, completeOpCode: Op.COMPLETE, outcome: state.outcome });
return { state };

// matchSignal records that the platform has it
if (isOutcomeQuery(data)) {
  state.outcomeCollected = true;
  return { state, data: outcomeReply({ ... }) };
}

// matchLoop reaps it afterwards
if (state.outcome) {
  if (state.outcomeCollected) return null;
  if (tick - state.finishedTick > SETTLEMENT_GRACE_TICKS) return null;  // and log loudly
  return { state };
}

The grace period matters: without it, a match whose outcome is never collected lives for ever.


"The loser is only told the match ended"

Cause: your completion message did not name the winner.

publishOutcome sends winnerId, and the host compares it with the player it is running for to work out youWon. Omit it and every player gets the same neutral message — the loser cannot tell a loss from a draw from an opponent walking out.

Name the winner, and give the reason in words a player understands. timeout-limit is for your ledger; "You ran out of time too many times" is for the person who just lost money.


"It works on my machine but not for one of my players"

Cause: a content blocker.

Privacy and ad-blocking extensions cancel scripts inside null-origin frames, which is exactly what your game runs in. The browser reports ERR_BLOCKED_BY_CLIENT in the console and nothing else happens: your entry document loads, your script never runs, and the platform waits for a ready that will never come.

You cannot prevent this from inside your game. What you can do:

  • Test with your extensions enabled, not only in a private window. If you can only make your game work in incognito, that is a finding, not a workaround.
  • Keep your bundle to plain, boring filenames. Anything resembling advertising or tracking infrastructure attracts blocklists.
  • Expect it in reports. The platform now tells the player it may be an extension, but they will still describe it to you as "the game does not load".

"It renders on my laptop and is cut off on a phone"

Cause: your layout assumed a screen size.

Your game runs in a frame whose dimensions the platform chooses, and they differ by device. A fixed pixel size, or a vh unit measured against the whole screen, overflows the frame — and an iframe clips silently, with no scrollbar to hint at it. The bottom of your board simply is not there.

Fix: size to the space you are given.

#wrap { width: min(92vw, 26rem); aspect-ratio: 1; }

Check it at a phone width before you submit. This is the single most common visual problem.


"The status line is wrong, and stays wrong after the game ends"

Cause: the status was stored when the page loaded instead of derived from the current state.

One player sat under "Waiting for an opponent — 1 of 2 seated" for a whole match while the other read "Playing.", and both captions were still there after somebody had won. The text was computed once, from a snapshot of how many players were seated at that moment, and never recomputed.

Fix: derive it. If the status is computed from the current phase every time it is rendered, there is nowhere for a stale value to live:

let status = $derived(
  phase === "failed" ? failure
    : phase === "playing" ? gameStatus
      : MATCH_PHASE_TEXT[phase],
);

This applies to your own in-frame caption too. Anything you assign once and update by hand will eventually be wrong in a state you did not think about.


"The board never changes colour"

Cause: you recoloured an actor instead of the graphic it draws.

Excalibur's color: constructor shorthand builds a graphic for you, and assigning this.color later does not necessarily repaint what is already on screen. Text updates, opacity updates, and the colour silently does not — so marks appear, hover does nothing, and the winning line is never highlighted.

Measured on this game before it was fixed: with a winningLine of [0, 1, 2] and this.color = COLOR.win, all three squares sampled #163b5f, the ordinary cell colour. The same board with the fix below sampled #22c55e.

Fix: hold the graphic and recolour that.

// In the constructor — no `color:` in the super() call.
this.tile = new ex.Rectangle({ width: CELL, height: CELL, color: COLOR.cell });
this.graphics.use(this.tile);

// Anywhere later.
this.tile.color = won ? COLOR.win : COLOR.cell;

The general form of this, in any engine: if a change is not visible, check that you changed the thing being drawn rather than an object that was only used to create it once.


"The canvas is blank and the console says nothing useful"

Cause: your bundle is fetching its own files, and the sandbox gives it an opaque origin.

Your game runs in an iframe with sandbox="allow-scripts" and deliberately without allow-same-origin, so it cannot reach the page hosting it. The side effect catches people out: an opaque origin makes a request for a file sitting next to your own index.html a cross-origin request. XMLHttpRequest and fetch both fail unless the server sends Access-Control-Allow-Origin.

This is the normal loading path for Defold (its .arcd archives and .wasm) and GDevelop (its resources and data.js), and it will hit an Excalibur game the moment you load an asset at runtime instead of inlining it. The failure is quiet — a blank canvas, and a console message that never uses the word CORS.

Verified in Chrome: the same bundle, on the same paths, fails without the header and works with it.

Fix, locally: serve with a header. The Defold and GDevelop templates ship serve.py, which is python3 -m http.server plus one line:

class CorsHandler(SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        super().end_headers()

On the platform: already handled — the CDN serving /game-assets/ sends the header, so a bundle that loads under serve.py loads in production. If you host a preview yourself, you have to send it too.

Or avoid it: inline what you can. The Excalibur template's build inlines everything into one game.js, which is why it never meets this, and it also makes your game start faster. <script src> and <img> do not need CORS — only XHR, fetch, and anything built on them.


"Two browser tabs will not match against each other"

Cause: they are the same player.

Cookies are per browser, not per tab, so two tabs are one signed-in account — and the platform will not seat a player against themselves. Use two browsers, or one plus a private window, with two different accounts.

You need two real accounts to test a real match. There is no way around it, and it is worth setting up properly at the start.


The checklist before you submit anything real

  • All seven match functions exported
  • Rules are pure functions, tested on their own
  • init draws from the platform's seed
  • Nothing in your game reads a stake, balance or prize
  • viewFor returns only what that player may see
  • Turn timers, so a stalled match cannot hold a stake for ever
  • matchLeave ends the match rather than leaving stakes hanging
  • The bundle is under 10 MB expanded
  • Thumbnail is exactly 512×512
  • The zip has no folder prefix
  • You have played a whole match, twice, with two accounts
  • You have tried to cheat your own game and failed

And the ones that fail silently, from things that will catch you out:

  • The opening view is sent on a tick, not from inside matchJoin
  • The winning move is broadcast before the match is declared over
  • sendViews is given the turn predicate
  • Moves are read with readIntent, never off message.data
  • finish() keeps the match alive until the platform has taken the outcome
  • The completion names the winner, so the loser can be told they lost
  • You have opened it at a phone width and seen the whole board
  • You have loaded it once with your ad blocker on
  • Your status line is derived, not assigned once
  • Your game says nothing about connection state or the result — those are the platform's

Where to go next

You have built game zero. The other eight are a course, each teaching one new problem and assuming the ones before it:

  • The games, as a course — start with Connect Four, which adds a board with gravity and the habit of sending the client what it would otherwise recompute.
  • Pitfalls, and how they were fixed — everything that went wrong building every game, in symptom order. Worth skimming before you start your own game and reading properly when something breaks.

And the reference material:

Related