← All documentation

Building a client against the platform

For anyone writing a front end — a web app, a Flutter app, anything — that talks to the platform directly.

If you are building a game, you do not need this page. A game bundle has no network access at all, and your match handler reaches the platform through its dispatcher. See writing the server rules. This page is for the layer around games: the catalogue, the wallet, joining a match.


The three ways anything talks to this platform

Knowing which one you are in prevents most of the confusion:

You are writing How it talks to the server What it may do
A game bundle postMessage to the page hosting it. No network. Render, report intents
A match handler Nakama's dispatcher, and a signal the platform pulls Decide outcomes
A client HTTP RPCs and a match socket Everything a player can do

Only the third talks to the server in the sense this page means.


Credentials, and the one that must never ship

Nakama has three credentials and they are not interchangeable. Getting this wrong is the single most expensive mistake available here.

Credential Where it belongs What it can do
Server key In a client, as HTTP Basic on authentication calls Authenticate and register accounts. Nothing else
Session token In a client, after sign-in Everything that player can do
HTTP key Server-side only. Never in a client. Every server-policy RPC

The HTTP key authorises GrantRole, PaymentCallback, ScorePoolEntry and SeedRoleGroups. Anyone holding it can make themselves an operator, confirm a deposit that never happened, or set their own score in a pool.

A client binary is not a secret. An app can be decompiled and a web bundle can be read with the browser's own tools. A key compiled into either is published, not hidden. If you find one in a client, treat it as leaked and rotate it — not as a thing to remove in the next release.

The previous Crazy8s client shipped its HTTP key as a string constant, alongside the host and the server key, and changed environments by editing the constant and committing it. That is the shape of mistake this table exists to prevent.

Where the config should come from

Not a constant in the client. The platform's own web front end never learns Nakama's host at all: requests go through nginx at /nakama, and the browser talks to the site it is already on. A mobile client should take its base URL from build configuration, not from a literal in a service file.


Authenticating

Ordinary Nakama email authentication. The server key goes in an Authorization: Basic header, base64 of <server-key>: — note the trailing colon and empty password.

POST /v2/account/authenticate/email?create=false
Authorization: Basic <base64 of "server-key:">
Content-Type: application/json

{ "email": "player@example.com", "password": "…" }

Returns { "token": "…", "refresh_token": "…" }.

create is a decision, not a default. With create=true a typo in an email address silently makes a second account, and the player then cannot find their balance. Registration and sign-in are different screens and should be different calls.

To carry a referral through registration, pass session vars:

{ "email": "…", "password": "…", "vars": { "referrer_code": "ABC123" } }

The account-creation hook reads that, assigns the new player their own code, and links them to whoever referred them. A code that does not exist is logged and skipped — losing a referral link is bad, losing a signup is worse.


Calling an RPC

Every RPC is a POST, with the caller's session token as a bearer token:

POST /v2/rpc/GetWalletBalance
Authorization: Bearer <session token>
Content-Type: application/json

""

The double encoding, which catches everybody

Nakama's RPC transport carries the payload as a JSON string, not as a JSON object. So a request body is JSON-encoded twice, and a response is decoded twice.

Sending { "gameId": "chess-blitz" }:

  1. JSON.stringify({ gameId: "chess-blitz" })   →  {"gameId":"chess-blitz"}
  2. JSON.stringify(that string)                 →  "{\"gameId\":\"chess-blitz\"}"
                                                     ↑ this is the request body
Receiving:

  { "payload": "{\"currency\":\"szl\",\"balanceMinor\":4000}" }
    ↑ the envelope         ↑ your actual result, as a string

  JSON.parse(response).payload  →  a string
  JSON.parse(that)              →  { currency: "szl", balanceMinor: 4000 }

Sending an object directly does not fail at the transport. It fails inside the handler with a parse error, which is a confusing place to debug it from.

An RPC that takes no arguments still needs a body: the two-character string "".

Errors

A failure comes back as { "code": …, "error": {…}, "message": "…" }. The message is prefixed with the JavaScript error name and has a stack frame appended:

"Error: your balance is not enough for that withdrawal at rpcStartWithdrawal (index.js:1874:46(103))"

Strip both before showing it to a player. The platform's own client does this in one place rather than at every call site.


Money

Balances are integer minor units. 4000 is E40.00. Never parse a balance into a float: a fractional cent is a rounding dispute waiting to happen, and the ledger has none.

Two behaviours will look like bugs if you do not expect them:

A deposit does not credit immediately. StartDeposit returns pending. The player approves it on their handset, the provider calls back, and only then does the balance move. A client that optimistically adds the amount will be wrong every time a player declines.

A withdrawal debits immediately, and needs the password. StartWithdrawal requires an approval from ApproveWithdrawal, which takes the account password — a session token alone cannot move money off the platform. Ask for the password on the withdrawal screen; do not cache it.


Playing a match

Three steps, in this order:

1. Join. JoinMatch with a gameId and a stakeMinor that the game offers. This is what takes the stake. It returns a matchId and the connection descriptor for the game's own runtime.

2. Open a socket. Nakama's realtime socket, with the session token in the query string — browsers cannot set headers on a WebSocket handshake:

wss://<the origin your page is on>/nakama/ws?lang=en&status=false&token=<session token>

Connect to the origin your page came from, not to a configured host. The platform's own site does this, and the reverse proxy forwards /nakama/ to Nakama with the WebSocket upgrade headers set.

A host in configuration is right for exactly one person — whoever set it. Reach the same site through a forwarded port, an SSH tunnel, a phone on the same network or a real domain, and localhost:7350 is either nothing at all or somebody else's machine. The failure looks like this, and gives no hint that the address is the problem:

the socket could not be opened — check the session token

If you are talking to Nakama directly rather than through a proxy, the path is /ws.

Then send {"cid":"1","match_join":{"match_id":"<matchId>"}}. The cid correlates a request with its reply.

3. Exchange match data. Payloads are base64-encoded on the wire, in both directions.

Opcodes

Code Direction Meaning
1 server → client The state this player may see
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

Opcode 1 is a view, not the state. The server sends each player only what they may see — a card game gives you your own hand and the count of everyone else's. Do not expect to reconstruct the full game from it; you are not meant to be able to.

Opcode 4 is not a payment. It tells you the match ended and who won. Settlement happens afterwards, on the platform node. Tell the player their winnings are on the way rather than showing a balance you have guessed.

Match states

If you are building your own client, use the platform's phases rather than inventing your own — connecting, waiting, playing, ended, failed. MATCH_PHASE_TEXT in @crazy8s/game-sdk carries the wording, so a player sees the same words in your client as in the platform's own.

Derive the caption from the current phase. Assigning it once and updating it by hand is how a player ends up watching "waiting for an opponent" through a whole match, and still reading it after somebody has won.

A game may also send a status message of its own — a short line about whose turn it is. Show it during playing and clear it when the match ends; it is detail within the match, never a match state.

A game frame is not listening when you think it is

Only relevant if you are hosting a game bundle yourself rather than using packages/game-host. It cost us a real bug, and it is invisible when it happens.

A postMessage into an iframe whose script has not run yet is discarded. No error, no delivery, nothing in the console. The frame exists, contentWindow is there, the call returns normally, and the message is gone.

That matters here because of the order events actually arrive in. You mount the frame, it starts fetching a megabyte of game, and meanwhile the second player joins and the server broadcasts the opening position. If it lands before the bundle's script has installed its listener, the player's game never learns the match started. The match is running, their turn clock is counting down on the server, and their screen says it is waiting for the match to begin. They find out when they are timed out.

The rule: nothing may be posted to a bundle until it has answered ready. Queue everything else and flush it in order when it does.

if (!ready && message.type !== "init" && message.type !== "start") {
  queued.push(message);          // and flush, in order, on `ready`
  return;
}
frame.contentWindow?.postMessage(message, "*");

Three details that are easy to get wrong:

  • init and start must bypass the queue. init is what makes a bundle ready, and start is the reply to ready. Queueing either deadlocks the game on a loading screen.
  • Order is not a nicety. A refusal refers to the position before it. Delivered backwards, the player is told a move was refused against a board they were never shown.
  • Cap the queue, or a frame that never becomes ready grows it for ever. Drop the oldest position when you do — a superseded view loses nothing, and a result must never be dropped.

The server half of this trap is separate and also real: do not broadcast from inside matchJoin. See writing the server rules. Fixing one does not fix the other, and they produce the same symptom — so a developer who has dealt with the handler side can still meet this.

Disconnections

A dropped socket does not void a match. Rejoin with the same matchId and the handler resends your view. If the match genuinely never completes, the stake is refunded automatically by a sweep — but that takes thirty minutes, so reconnecting is much better than starting again.


Lobby counts: there is nothing for you to do

The platform shows how many players are online, how many are in a match, and how many are waiting at each stake of each game. Games appear in those numbers automatically. A game bundle contains no code for this, and none is added at upload.

That is worth stating plainly because the platform this replaced worked the other way, and the difference is instructive rather than cosmetic.

How the old platform did it

Its server kept a Nakama presence stream per game per bet tier — STREAM_C8_1, STREAM_C8_5, STREAM_DR_10 and so on — and counted them. For any of it to be true, the client had to:

  • call JoinOnlinePlayersStream once, after connecting;
  • call JoinMatchmakingStream with the right game and bet when the player started queuing;
  • call LeaveMatchmakingStream with the same arguments when they stopped — every exit path, including closing the app;
  • call JoinMatchmakingStatsStream to receive updates.

Four calls, in order, with join and leave correctly paired. Miss a Leave and that tier shows a player who is not there, permanently, until the socket drops.

In the shipped client, the whole of that is commented out — matchmaking_service.dart is 629 lines, 312 of them commented, and every presence call is among them. The server RPCs are still registered and still work. Nothing calls them. A feature that needs mandatory client code is a feature that stops working the first time somebody comments out the awkward part, and nothing fails loudly when they do.

How this platform does it

Neither number is collected from the game or from the game's client:

  • Online is the play portal's own heartbeat, sent from the root layout on every page. It counts a person using the platform, which is what the word means — not a person who is connected to a particular game.
  • Waiting at each tier is read from queue:<gameId>:<stakeMinor>, the index the platform writes when JoinMatch seats somebody. The lobby asks the same function the seating path asks, so it can only ever show tiers a player is genuinely able to join.

Your game is never consulted for either, and could not be: a bundle runs sandboxed and speaks only the two permitted APIs, so it has no access to Nakama streams at all. There is no hook to implement, no call to remember, and no leave to pair.

The one thing that follows for you: a stake tier only shows a queue if it is declared in your manifest and published. That is the same list players pick from, so if a tier is missing from the lobby, it is missing from the game.

See ADR 0019 for why presence is a heartbeat rather than a stream here.


What to build on top

packages/nakama-client in this repository is the platform's own implementation of all of the above: the RPC envelope, the socket, and session handling. It is small enough to read as a specification if you are porting to another language.


Every RPC

Server API reference — all of them, with the policy each requires. Generated from the authorisation manifest, so it cannot drift from what the server actually exposes.


Related