Writing the server rules
Your game is two halves. The bundle renders and reads input; this half decides what actually happened. It is the only half whose word counts, and the only half that can be trusted, because it runs on our infrastructure rather than on a player's device.
If you have not read the trust model, read it first — this page assumes you already know why the split exists.
What you are writing
An ordinary Nakama match handler: seven functions, written directly. There is no framework of ours to learn on top of it.
export function matchInit(ctx, logger, nk, params) { … }
export function matchJoinAttempt(ctx, logger, nk, dispatcher, tick, state, presence, metadata) { … }
export function matchJoin(ctx, logger, nk, dispatcher, tick, state, presences) { … }
export function matchLeave(ctx, logger, nk, dispatcher, tick, state, presences) { … }
export function matchLoop(ctx, logger, nk, dispatcher, tick, state, messages) { … }
export function matchTerminate(ctx, logger, nk, dispatcher, tick, state, graceSeconds) { … }
export function matchSignal(ctx, logger, nk, dispatcher, tick, state, data) { … }
All seven are required. The upload is rejected if any is missing, and the error names the ones you left out.
games/tic-tac-toe in this repository is a complete, working handler. It is the shortest
path to understanding this page.
What your handler may use
Your handler runs in its own Nakama node with its own database role, and that role can do almost nothing (see ADR 0008). In practice:
| Allowed | Not allowed |
|---|---|
dispatcher.broadcastMessage |
Anything on nk — storage, wallets, accounts |
dispatcher.matchKick |
Network calls of any kind |
dispatcher.matchLabelUpdate |
Reading another match's state |
| Your own match state | Anything to do with money |
This is checked by a build rule, not by convention: a handler that reaches for nk.* fails
the build. The rejection is not a judgement about you — it is what lets us run third-party
code beside a wallet at all.
Money is not your concern, structurally
Your handler never sees a stake, a balance or a prize. The platform takes stakes into
escrow before your match is created, and settles against that escrow after your match
reports its outcome. You are passed an opaque escrowId you never interpret, and you
report who won. Everything else is ours.
The shape of a handler
matchInit — set up, and publish a label
export function matchInit(_ctx, logger, _nk, params) {
return {
state: {
game: null,
players: [],
presences: {},
// Opaque. Issued by the platform, meaningless to you — hold it and hand
// it back when asked.
escrowId: params["escrowId"] ?? "",
outcome: null,
},
tickRate: 5,
label: JSON.stringify({ game: "your-game-id", phase: "open" }),
};
}
The label is how the platform finds your match without your handler needing any storage
access. phase: "open" while playable; "finished" once it has ended.
Do not rely on the label for matchmaking. The platform maintains its own index of who is waiting — a handler's label is written by the game, and matchmaking must not take routing instructions from the thing it is routing to.
matchJoin — and the one trap in it
Do not broadcast from inside matchJoin. A player's presence is not fully registered
until the handler returns, so a message sent during it can go to a session that is not
listening yet. The player who just joined gets nothing, and sits looking at a blank game
until some later broadcast happens to arrive.
Set a flag and send on the next tick:
// matchJoin, once both players are seated and the game has started
state.broadcastPending = true;
return { state };
// matchLoop, at the top
if (state.broadcastPending) {
state.broadcastPending = false;
sendViews(dispatcher, Op.STATE, presenceList(state), viewFor, isYourTurn);
}
matchJoinAttempt — decide who may sit down
Accept a reconnecting player, refuse a full or already-started match:
if (state.players.some((p) => p.userId === presence.userId)) {
return { state, accept: true }; // reconnection
}
if (state.game) return { state, accept: false, rejectMessage: "already started" };
if (state.players.length >= 2) return { state, accept: false, rejectMessage: "match is full" };
return { state, accept: true };
matchLoop — apply intents
Messages arriving here are intents: what a player asked to do. Nothing about them is
trusted. Use readIntent to decode one, check whose turn it is, and reject rather than
throw:
for (const message of messages) {
if (message.opCode !== Op.MOVE) continue;
const intent = readIntent(message);
if (!intent) {
dispatcher.broadcastMessage(Op.REJECTED,
JSON.stringify({ reason: "malformed move" }), [message.sender]);
continue;
}
if (rules.currentPlayer(state.game) !== message.sender.userId) {
dispatcher.broadcastMessage(Op.REJECTED,
JSON.stringify({ reason: "it is not your turn" }), [message.sender]);
continue;
}
const outcome = rules.applyAction(state.game, message.sender.userId, intent.action, intent.payload);
…
}
Do not read
message.datayourself. It is anArrayBuffer, not a string, and passing it toJSON.parsestringifies it to"[object ArrayBuffer]"and throws — so a handler that casts it reports every move as malformed and the game is silently unplayable. The client also sends{ action, payload }, so a handler reading its own fields off the top level finds nothing.readIntenthandles both.
A rejected move ends the move, not the match. The player is told why and play continues.
The same trap exists on the other side of the frame, and fixing this one does not fix it. A
postMessageinto a game bundle whose script has not run yet is discarded, so the opening position can be lost after your handler has correctly sent it — with exactly the symptom this deferral exists to prevent: one player left on "waiting for the match to start" while their clock runs down. The platform's host handles it by queueing until the bundle answersready; a developer hosting a bundle themselves has to do the same. See a game frame is not listening when you think it is and the pitfalls index.
Sending state — one view per player
Never broadcast your raw state. Use sendViews, which takes a function producing one
player's view:
sendViews(
dispatcher,
Op.STATE,
presenceList(state),
(userId) => rules.viewFor(state.game, userId),
(userId) => rules.currentPlayer(state.game) === userId,
);
A card game returns the player's own hand and only the count of everyone else's. This is
how hidden information stays hidden, and it fails safe: a game that forgets to implement
viewFor shows nothing rather than everything.
The second function answers "is it this player's turn?". It is required rather than optional because the client cannot work it out — only your rules know — and a client that is never told simply believes it is never its turn, which looks like a frozen game with nothing in any log.
The message on the wire is { view, yourTurn }. That is the contract the host relay reads
and hands to your bundle as a match-state message.
Ending — publish the outcome
Send the final view before you finish. Returning as soon as the game is complete means the winning move is never broadcast: the board freezes one move short and players are told somebody won with no way to see how.
sendViews(...); // show the move that just happened
if (rules.isComplete(state.game)) {
return finish(dispatcher, state, result.winnerId, result.reason, tick);
}
And do not destroy the match when it ends. Returning null tears it down immediately,
and the platform settles by finding finished matches and asking each for its outcome — a
match that no longer exists cannot be asked, so the winner is never paid and both stakes
wait for the stuck-escrow sweep. Keep it alive until matchSignal has handed the outcome
over, then reap it.
state.outcome = { winnerId, reason: "three-in-a-row" };
publishOutcome(dispatcher, {
gameId: "your-game-id",
completeOpCode: Op.COMPLETE,
outcome: state.outcome,
});
This flips the label to finished and tells the players. It does not pay anybody —
settlement happens on the platform node, against the escrow you never saw.
Omit winnerId for a draw or a void, and every stake is refunded.
matchSignal — answer when the platform asks
The platform pulls the result; your handler never pushes to it:
export function matchSignal(_ctx, _logger, _nk, _dispatcher, _tick, state, data) {
if (isOutcomeQuery(data)) {
return {
state,
data: outcomeReply({
escrowId: state.escrowId,
players: state.players,
outcome: state.outcome,
}),
};
}
return { state };
}
That is the whole protocol. The platform sends {"query":"outcome"}; you answer with the
escrow reference, who was playing, and what happened.
Status: what you say, and what the platform says
Your handler decides the game; the surrounding page decides how the match is described. Keep those apart or they will contradict each other.
The platform owns these, identically for every game — connecting, waiting, playing,
ended, failed — and words them the same way everywhere. Your handler is not involved.
What your game contributes is detail inside playing: whose turn it is, what input is
expected. That is sent from the client half with a status message, not from the
handler. See
telling the player what is happening.
The handler's part is to make that possible: send yourTurn with every view, so the client
knows what to say. A client that is not told cannot describe the turn, and will either
guess or say nothing.
Send it, do not also draw it
Your bundle should not render its own copy of that line. Send the status message and
stop there; the platform shows it in one bar above the frame.
Every first-party game used to do both — a status message and a <p id="status"> under
the board. The result was the same sentence twice on one screen, and then something worse,
because the two were written from different code paths: a player saw
Your opponent is thinking… (2–2) ← the platform's bar, at the top
An even board — a draw. (2–2) ← the game's own line, under the board
at the same moment. Both were "correct" where they were set. Neither told the truth about the match.
The rule is one place per fact, and the platform's bar is the place, because it is the one that survives your frame being covered by the result panel.
What does belong inside your frame is anything positional — a turn clock beside the board, a score on the board, a hint about the controls. Those are part of the game's own presentation, not a description of the match.
The loading screen is the platform's
Do not draw one. The platform shows its own — the Crazy8s mark, pulsing, over your frame —
from the moment the page mounts your bundle until your game answers ready. That covers
exactly the window in which your code is not running yet, which is the window a loading
screen is for, and it is identical across every game so a player always knows what they
are looking at.
Two loading indicators, one inside the other, is worse than either alone. The first-party
games' own loaders therefore paint their background colour and nothing else — see
games/tic-tac-toe/src/main.ts for the pattern, and
the pitfalls index for what happens if you use Excalibur's default loader
instead.
Once your game is running, what it draws is entirely yours.
Nor are the rules
Your game's instructions come from your manifest and are shown on the game's menu page, which every player passes through on the way to a match. Do not repeat them inside your frame while a game is in progress.
They were under the board on the first-party solo games and the reasoning against it is the same as for the status line, with one addition: a paragraph of prose beside a live board is clutter at the moment a player has least use for it, and on a phone it is what pushes the game itself up the screen. Rules are read before committing to a run, not while one is asking for a move.
A short control hint — "swipe or use the arrow keys" — is a different thing and belongs where it is, beside the board it refers to.
Opcodes
A small integer on every message, agreed between your two halves. The convention the platform's own host relay understands:
| Code | Direction | Meaning |
|---|---|---|
1 |
server → client | State view for this player |
2 |
client → server | A move the player attempted |
3 |
server → client | That move was refused; play continues |
4 |
server → client | The match ended |
5 |
server → client | A turn timer expired |
Using these means the platform's page can run your game with no per-game code. An opcode
the relay does not recognise is ignored rather than guessed at, so you can add your own
above 5 safely.
Turn timers
Provided, because every turn-based game needs them and getting them wrong is how a match hangs forever:
const check = checkTurnTimeout(state.timer, {
tick, playerId, timeoutSec: 30, tickRate: TICK_RATE, maxStrikes: 3,
});
if (check.forfeit) { /* end the match */ }
Strikes accumulate per player. A player who runs out of turns forfeits; the platform settles that exactly like any other loss.
Disconnections
A player who leaves mid-match must not leave stakes stranded. Handle matchLeave, and give
your match a way to end when nobody is present — the reference handler counts empty ticks
and returns null from matchLoop to end the match once nobody has been there for a
while.
If your match never ends, the escrow behind it is swept and refunded after thirty minutes. That safety net exists so a crashed game cannot cost a player their stake, but relying on it means every player waits half an hour for their money.
Testing it
Write your rules as pure functions and test them directly. The reference game keeps its
rules in rules.ts and its lifecycle in match.ts for exactly this reason — the rules are
testable without Nakama at all.
pnpm test
For pooled games, the determinism check is not optional: see determinism.
Related
- Getting started — the path end to end
- The games, as a course — seven head-to-head implementations of this page
- Pitfalls, and how they were fixed — the traps in it, with symptoms
- Trust model — what your game may and may not decide
- 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
- Engine guides: Excalibur.js · Defold · GDevelop
Two runtime traps that cost a day of payments
Both compile, both pass every test, and both fail only in the Nakama runtime.
Do not subclass Error
// WRONG — `code` is undefined at runtime
class MomoError extends Error {
constructor(public code: string, message: string) { super(message); }
}
// RIGHT
interface CodedError extends Error { code: string }
function codedError(code: string, message: string): CodedError {
const error = new Error(message) as CodedError;
error.code = code;
return error;
}
goja does not carry own properties across the built-in Error constructor.
Every error raised this way reached its handler with no code, so three
distinct payment faults were reported as one generic failure and the logs said
nothing useful for several rounds of debugging.
A module-level object cannot gain properties
// WRONG — throws "object is not extensible" on the first write
const cache: Record<string, Token> = {};
cache[key] = token;
// RIGHT
const cache = new Map<string, Token>();
cache.set(key, token);
Nakama seals a runtime's module scope. A Map's entries are not properties of
a sealed object, so set is allowed. This threw on the line that CACHED a
successfully fetched MoMo token, so every payment failed with the payment
provider working perfectly.
And a third, about nk.httpRequest
It throws on any non-2xx rather than returning a response with a code. A
if (response.code >= 400) branch after it is unreachable, and so is any
logging inside it. Wrap the call, log the caught error, and treat the status
check as belt-and-braces.
