← All documentation

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.data yourself. It is an ArrayBuffer, not a string, and passing it to JSON.parse stringifies 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. readIntent handles 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 postMessage into 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 answers ready; 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

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.