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.jstells 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/passwdis 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:8082 → Submissions.
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:
- Deploy to staging →
staged. Real infrastructure, play money only. - Approve →
approved. Reviewed and accepted, not yet visible. - Publish →
live. 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
-
initdraws from the platform's seed - Nothing in your game reads a stake, balance or prize
-
viewForreturns only what that player may see - Turn timers, so a stalled match cannot hold a stake for ever
-
matchLeaveends 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
-
sendViewsis given the turn predicate - Moves are read with
readIntent, never offmessage.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:
- Trust model — the reasoning behind the split, in depth
- Writing the server rules — the handler reference
- Determinism — required for pooled games
- Publishing lifecycle — updates, review, revenue
- Building a client — if you want your own front end too
- Engine guides: Excalibur.js · Defold · GDevelop
Related
- Getting started — the path end to end
- Trust model — what your game may and may not decide
- Writing the server rules — the half that decides outcomes
- Determinism — proving your rules are reproducible
- Publishing lifecycle — review, updates, revenue
- Building a client — for a front end, not a game
- Server API reference — every RPC, and who may call it
