Nakama and Open Match: what they really give you, and what you have to supply
If you are choosing the backend for a multiplayer game, you have three roads: write everything yourself, buy a managed service, or start from an open-source project. Nakama and Open Match are the two names that come up most often on the third road, and they are often presented as a single solution. They are not. Nakama (by Heroic Labs) is a complete game server: accounts, a store for player data, friends, chat, leaderboards and an engine for real-time matches. Open Match (by Google) does one thing only, matchmaking, and knows nothing about accounts or matches.
The difference that decides everything else is this: the open-source version of Nakama runs as a single node. Clustering is a feature of the Enterprise version. Whoever reads "scales horizontally on Kubernetes" and puts three replicas behind a service is building something the documentation does not describe. This article starts from there: what each project does, the code of an authoritative match that you can run without a server, the way the two connect, and where the teams that pick them for the wrong reason get hurt.
Prerequisite: the authoritative client-server model explained in Anatomy of a multiplayer game backend. Matchmaking as a problem (queues, ratings, waiting times) is covered in Matchmaking system design with Elo, Glicko-2 and queues.
What is inside Nakama
Nakama is a single Go program that exposes a gRPC and HTTP API and an administration console. Its
store is a relational database: the official configuration page states that it requires a CockroachDB
instance, with default address root@localhost:26257, and that it creates and manages its own
nakama database.
- Identity: authentication with email, device identifier and social providers, plus sessions
- Collection-based storage: per-player JSON objects with separate read and write permissions
- Social: friends, groups, real-time chat, notifications
- Competitive: leaderboards and tournaments
- Multiplayer: relay matches (the server forwards messages), authoritative matches (the server computes the state) and a built-in matchmaker
The list is in order of importance for whoever is choosing: identity and storage are what almost every game with accounts must have anyway, and Nakama gives them ready-made. Authoritative matches are the part that lets you apply the idea of server authority against cheating. The built-in matchmaker exists: Open Match is for when its logic is not enough, not a condition for having matchmaking at all.
How it is extended: a TypeScript runtime that is not Node
To add logic to Nakama you write a runtime module in Go, TypeScript/JavaScript or Lua. Here is the first
trap. The JavaScript runtime documentation says it runs on goja, a JavaScript virtual machine
written in Go, which supports the ES5 specification, with no libraries that require Node, browser
APIs or native code. There is no Deno, and what you typically take for granted in modern TypeScript
(Map, Math.sign, Array.from) must be checked before you use it. In addition, the
functions passed to the Initializer must live in the global scope, and InitModule is the
entry point.
Not to trust my memory, I did this: I downloaded the official types (nakama-runtime, from the
heroiclabs/nakama-common repository), wrote the module with "target": "ES5" and
"lib": ["ES5"] in tsconfig.json, and let tsc in strict
mode reject everything that does not exist. Then I loaded the generated JavaScript into a Node
vm context (a single global scope, as in goja) and drove the functions with a fake
nk. It is not Nakama: it is a teaching model that proves the logic, not the integration with the server.
The first block is the entry point. registerMatch binds to the name game_match the seven
lifecycle callbacks of a match, and registerRpc exposes a function that the client can
call. If a callback is missing, tsc reports it: the MatchHandler interface requires all
seven.
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
initializer.registerMatch<GameState>('game_match', {
matchInit: matchInit, matchJoinAttempt: matchJoinAttempt, matchJoin: matchJoin,
matchLeave: matchLeave, matchLoop: matchLoop, matchTerminate: matchTerminate, matchSignal: matchSignal,
});
initializer.registerRpc('start_matchmaking', startMatchmakingRpc);
logger.info('runtime loaded');
}
// Output:
// [log] runtime loaded
// registered: [ 'game_match' ] [ 'start_matchmaking' ]
The output shows that after loading, one match and one RPC are registered. If you remove the
registerRpc line the second list stays empty and the client has no function to call.
The authoritative match, step by step
An authoritative match is a loop that runs on the server at a fixed frequency. The tickRate you
return from matchInit is the number of calls to matchLoop per second; with 10, the
documentation explains that each cycle starts 100 ms after the start of the previous one. At every call you receive the list
of messages that arrived from the clients since the last tick: you validate them, update the state and, if you want, broadcast.
The state lives on a single node, the one that hosts the match: that is why no distributed
synchronization is needed, and why a match does not "move" to another replica.
In the code of the first block, the client proposes a move and the server decides. applyMove discards
non-finite numbers, limits each axis to MAX_STEP units and keeps the player inside the map.
applyAttack reads nothing from the message except who is attacking: the damage is the constant
ATTACK_DAMAGE. This is a security choice, not a convenience. If the client could send
damage, every player would become a target with a single message.
function applyMove(p: Player, dx: number, dy: number): void {
// ignore non-finite numbers and clamp the step: the client proposes, the server decides
if (!isFinite(dx) || !isFinite(dy)) { return; }
p.x = clamp(p.x + sign(dx) * Math.min(Math.abs(dx), MAX_STEP), 0, MAP_SIZE);
p.y = clamp(p.y + sign(dy) * Math.min(Math.abs(dy), MAX_STEP), 0, MAP_SIZE);
}
function applyAttack(state: GameState, attackerId: string): void {
var a = state.players[attackerId];
if (!a || !a.alive) { return; }
Object.keys(state.players).forEach(function (id) {
var t = state.players[id];
if (id === attackerId || !t.alive) { return; }
var dist = Math.sqrt(Math.pow(t.x - a.x, 2) + Math.pow(t.y - a.y, 2));
if (dist <= ATTACK_RANGE) {
t.health = Math.max(0, t.health - ATTACK_DAMAGE);
if (t.health === 0) { t.alive = false; a.score += 100; }
}
});
}
// Output:
// A after dx=9999, dy=-3: {"x":15,"y":47,"health":30,"score":0,"alive":true}
// A after 3 steps of 5: {"x":30,"y":47,"health":30,"score":0,"alive":true} | B: {"x":30,"y":50,"health":30,"score":0,"alive":true}
// B after an attack with damage=9999 in the payload: {"x":30,"y":50,"health":20,"score":0,"alive":true}
The three output lines prove the three defences. A message with dx=9999 moves the player by 5
units (from 10 to 15), three steps bring them to 30, and an attack whose content declares damage: 9999 still
removes 10 points: the target's health goes from 30 to 20. Note also the case you do not see: a move with a missing
dx produces NaN, and isFinite discards it instead of corrupting the position.
The real loop does four things, in the order in which they matter. It decodes every message with
nk.binaryToString (the data field is an ArrayBuffer, not a string) inside a
try, because the content comes from the network and may not be JSON. It applies only the messages of living players
and only in the playing phase. It computes the next phase. It returns { state }, or
null to close the match: the shape of the returned object is fixed by the official types, and if you return the bare state the compiler flags it immediately.
var matchLoop: nkruntime.MatchLoopFunction<GameState> = function (ctx, logger, nk, dispatcher, tick, state, messages) {
messages.forEach(function (m) {
var p = state.players[m.sender.userId];
if (!p || !p.alive || state.phase !== 'playing') { return; }
var data: { dx?: number; dy?: number };
try { data = JSON.parse(nk.binaryToString(m.data)); } catch (e) { return; } // malformed payload: discarded
if (m.opCode === OP_MOVE) { applyMove(p, Number(data.dx), Number(data.dy)); }
if (m.opCode === OP_ATTACK) { applyAttack(state, m.sender.userId); }
});
var ids = Object.keys(state.players);
var alive = ids.filter(function (id) { return state.players[id].alive; });
if (state.phase === 'waiting' && alive.length >= 2) { state.phase = 'playing'; }
if (state.phase === 'playing' && (alive.length <= 1 || tick >= state.maxTicks)) {
state.phase = 'ended';
dispatcher.broadcastMessage(OP_RESULT, JSON.stringify(state.players), null, null, true);
return null; // returning null closes the match
}
return { state: state };
};
var matchTerminate: nkruntime.MatchTerminateFunction<GameState> = function (ctx, logger, nk, dispatcher, tick, state, grace) { return { state: state }; };
var matchSignal: nkruntime.MatchSignalFunction<GameState> = function (ctx, logger, nk, dispatcher, tick, state, data) { return { state: state, data: data }; };
// Output:
// malformed payload: no exception, phase = playing
// match closed at tick 9 - final messages: 1 op 99
In the test, the payload not json raises no exceptions and the phase stays playing. Then
player A attacks until B dies: with 30 health points and 10 damage it takes three hits, and the match
closes at tick 9 with a final message of code 99. Time is measured in ticks and not with
Date.now(): this way the match is reproducible and a test does not depend on the clock.
The maximum duration, TICK_RATE * 30 ticks, is 30 seconds at 10 ticks per second.
When it is not the right choice
If the game is slow turn-based or cooperative and has no incentive to cheat, a relay match or data storage alone is enough and costs less. The authoritative match has a real cost: server CPU for every tick and the work of writing the physics twice, in the client for responsiveness and in the server for truth.
How you take it to production without the cluster
The page on authoritative matches is explicit: the match and its state live on one node, which becomes its host, and only the Enterprise version replicates presences across nodes and balances matches automatically. In the configuration page the cluster section is marked "Nakama Enterprise Only". For a team on the open-source version, this means three concrete things.
- One instance per environment: a single replica of the process, with automatic restart, not a Deployment with three replicas
- Vertical scaling: more CPU and memory on the node, and a separate database that can withstand write peaks
- More independent instances: if you need more capacity, separate instances with separate databases, each with its own players (per region, for example), and not a single distributed instance
The third item is the one that changes the architecture: if the players of two instances must meet, matchmaking and identity have to sit above them, and that is where an external layer comes into play. I do not publish a Kubernetes manifest here: I cannot run it, and the most common pattern, with three replicas and an autoscaler, promises a behaviour that this edition does not have. The boundary between what Nakama solves and what stays yours can also be found in Dedicated game servers: GameLift and Agones, which covers the orchestration of game servers separately.
Open Match: matchmaking in pieces that you write
Open Match is a Go framework under the Apache 2.0 license, designed for Kubernetes. The official documentation is at release 1.8.0 and has a separate area for Open Match 2. It is not a ready-made matchmaker: it gives you the control plane (ticket collection, querying, synchronization) and asks you to write two components.
- Frontend: receives tickets from players, via gRPC or HTTP (port 51504 in the guide's example)
- Query: lets the match function read the tickets that satisfy the filters of a profile
- Match function (yours): a gRPC or HTTP service with a
Runmethod: it receives aMatchProfileand streams back the match proposals - Backend: exposes
FetchMatchesandAssignTicketsto your Director - Director (yours): a process that asks for matches, obtains a game server and assigns the tickets
- Evaluator and Synchronizer: resolve conflicting proposals, when the same ticket ends up in two matches; a default evaluator is provided
The Director is not a service that Open Match hands you: the guide presents it as the process that asks for matches and assigns servers, and you write it. Writing the Director and the match function is therefore the job, and this means that Open Match does not free you from designing the matching algorithm: it frees you from managing the state of tickets at scale.
The Director, in the guide's Go version, is a loop that calls FetchMatches with the address of the
match function, receives the matches as a stream, obtains the server's connection string from your allocation system
and associates it with the tickets through AssignTickets. I did not run it, because Go is not installed
on the machine where I worked: the method and message names are those of the Director page, but
treat it as a schema to adapt and to compile in your own project.
// Director (Go): schema to adapt, NOT executed (Go is not installed on the working machine)
package main
import (
"context"
"io"
"open-match.dev/open-match/pkg/pb"
)
func fetchAndAssign(ctx context.Context, be pb.BackendServiceClient, profile *pb.MatchProfile) error {
stream, err := be.FetchMatches(ctx, &pb.FetchMatchesRequest{
Config: &pb.FunctionConfig{Host: "matchfunction", Port: 50502, Type: pb.FunctionConfig_GRPC},
Profile: profile,
})
if err != nil {
return err
}
for {
resp, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
conn := allocateGameServer(resp.GetMatch()) // up to you: Agones, an allocation API, your own pool
ids := make([]string, 0, len(resp.GetMatch().GetTickets()))
for _, t := range resp.GetMatch().GetTickets() {
ids = append(ids, t.GetId())
}
_, err = be.AssignTickets(ctx, &pb.AssignTicketsRequest{
Assignments: []*pb.AssignmentGroup{{TicketIds: ids, Assignment: &pb.Assignment{Connection: conn}}},
})
if err != nil {
return err
}
}
}
The piece that is not a detail is allocateGameServer: Open Match neither creates nor finds game servers.
The guide says the Director can take one from the allocation system and set it as the assignment on the
tickets. In practice that system is Agones, a service of your own or a fixed pool: you see it in the article
on orchestration linked above. An assignment error leaves the tickets waiting: the function returns
the error and the caller must decide whether to retry.
The matching logic: what a match function does
The match function receives the profile with its pools (groups of tickets that satisfy a filter, for
example an MMR range) and proposes matches. The real task is a compromise between quality and waiting. With
a narrow maximum rating spread the matches are fair but somebody waits; with a wide one nobody waits but
the matches are unbalanced. The guide suggests retrieving the tickets with the
QueryPools library and streaming the proposals. Since I do not run Go, I rewrote only the grouping
logic in JavaScript, with a fixed seed, as a teaching model.
// Teaching model of a match function's logic: group tickets with close MMR.
function seeded(seed) { let s = seed; return () => (s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296; }
function proposeMatches(tickets, size, maxSpread) {
const sorted = [...tickets].sort((a, b) => a.mmr - b.mmr);
const proposals = [];
let i = 0;
while (i + size <= sorted.length) {
const group = sorted.slice(i, i + size);
const spread = group[size - 1].mmr - group[0].mmr;
if (spread <= maxSpread) { proposals.push({ tickets: group.map(t => t.id), spread }); i += size; }
else { i += 1; } // the lowest ticket does not fit this group: try from the next one
}
const used = new Set(proposals.flatMap(p => p.tickets));
return { proposals, left: sorted.filter(t => !used.has(t.id)).map(t => t.id) };
}
const rnd = seeded(7);
const tickets = Array.from({ length: 20 }, (_, n) => ({ id: 't' + n, mmr: Math.round(800 + rnd() * 1200) }));
console.log('mmr:', tickets.map(t => t.mmr).sort((a, b) => a - b).join(' '));
for (const spread of [200, 400]) {
const r = proposeMatches(tickets, 4, spread);
console.log(`max spread ${spread}: ${r.proposals.length} matches, ${r.left.length} tickets waiting`,
r.proposals.map(p => p.spread));
}
// Output:
// mmr: 859 864 883 1087 1233 1302 1342 1369 1438 1457 1526 1535 1733 1736 1823 1827 1896 1912 1967 1981
// max spread 200: 4 matches, 4 tickets waiting [ 136, 97, 94, 85 ]
// max spread 400: 5 matches, 0 tickets waiting [ 228, 136, 97, 94, 85 ]
With 20 synthetic players and matches of 4, a maximum spread of 200 MMR points produces 4 matches and leaves 4 tickets waiting; with 400, 5 matches are formed and nobody waits, but the first puts together players with a 228-point gap. The figures come from this generator, not from a real case: they serve to show that the parameter has no absolute right value. The choice depends on how much traffic you have: with few active queues a narrow spread leaves players waiting forever, so in production the spread grows with the time spent in the queue. How the rating is computed, with Glicko-2 for example, is a different topic: here the rating is a given number.
Making Nakama and Open Match talk
The two systems are joined by a thin bridge. The client asks Nakama to look for a match; a Nakama RPC
reads the rating from the store, creates a ticket on the Open Match frontend and saves the ticket
identifier for the player. When the Director assigns the server, the client finds out by querying the ticket (the frontend guide lists a GET on
/v1/frontendservice/tickets/{ticket_id} and a WatchAssignments stream) and
connects. The frontend path for creating a ticket is /v1/frontendservice/tickets and the body has
a ticket field: that is how it is in the frontend guide.
/// <reference path="../node_modules/nakama-runtime/index.d.ts" />
var OPEN_MATCH_FRONTEND = 'http://open-match-frontend:51504/v1/frontendservice/tickets';
var startMatchmakingRpc: nkruntime.RpcFunction = function (ctx, logger, nk, payload) {
if (!ctx.userId) { throw new Error('an authenticated session is required'); }
var userId = ctx.userId;
var req = JSON.parse(payload) as { mode: string; region: string };
var rows = nk.storageRead([{ collection: 'player_stats', key: 'rating', userId: userId }]);
var mmr = rows.length > 0 ? (rows[0].value as { mmr: number }).mmr : 1000;
var body = JSON.stringify({
ticket: { search_fields: {
double_args: { mmr: mmr },
string_args: { mode: req.mode, region: req.region },
tags: ['player.' + userId],
} },
});
var res = nk.httpRequest(OPEN_MATCH_FRONTEND, 'post', { 'Content-Type': 'application/json' }, body, 5000);
if (res.code !== 200) { throw new Error('Open Match answered ' + res.code); }
var ticketId = (JSON.parse(res.body) as { id: string }).id;
nk.storageWrite([{ collection: 'matchmaking', key: 'current_ticket', userId: userId,
value: { ticket_id: ticketId, mode: req.mode }, permissionRead: 1, permissionWrite: 0 }]);
return JSON.stringify({ ticket_id: ticketId, status: 'searching' });
};
// Output:
// POST http://open-match-frontend:51504/v1/frontendservice/tickets
// {"ticket":{"search_fields":{"double_args":{"mmr":1480},"string_args":{"mode":"deathmatch","region":"eu"},"tags":["player.A"]}}}
// {"ticket_id":"t-42","status":"searching"}
// saved: {"ticket_id":"t-42","mode":"deathmatch"}
// without session: an authenticated session is required
The output demonstrates the shape of the request and the handling of the case without a session. Three lines of the code deserve
an explanation. search_fields contains the fields on which the profile's filters search: the rating as
double_args, mode and region as string_args. The 5000 ms timeout inside
httpRequest keeps an RPC from hanging if the frontend does not answer. And if Open Match does not answer
with 200, the function throws an error instead of saving a ticket that does not exist. I could not run the
call against a real Open Match (no server or cluster on this machine): the test uses a fake
httpRequest, so the JSON shape is the one from the guide, not an observed response.
Open source or managed service: the honest choice
A table of costs per million active users, like the many that circulate, does not hold up: it depends on how much engineers' time counts and on how you value waiting. Better to reason with three questions, which tell you what you are really buying.
- Who manages the database and the peaks? With open-source Nakama: you do, and a single instance is a single point of failure
- How much do you need to change the rules? If matchmaking and match logic are your game, having the source code available matters more than any ready-made feature
- Where does the data live? With your own installation you decide the region and the retention; with a managed service the provider's contract applies, and it must be read
If you answer "I do not want to manage a database and a production node" to just one of the three, a managed service is honest and quick. If you answer "the rules are my product" to just one, the operational cost of Nakama and Open Match is a reasonable price. A middle road that works: start with a managed service to validate the game and migrate later, keeping data access behind an interface of your own. Live operations (events, feature flags) are in the next article in the series.
Frequently asked questions
Can open-source Nakama be clustered?
No, according to the official documentation: the cluster section and the replication of presences across nodes are marked "Nakama Enterprise Only". With the open-source version you scale vertically or with independent instances.
Does Nakama's TypeScript runtime use Node or Deno?
Neither: it runs on goja, a JavaScript virtual machine in Go that supports ES5. No libraries that require Node or browser APIs, and the functions passed to the Initializer must live in the global scope.
Is Open Match a ready-to-use matchmaker?
No. It provides frontend, backend and query; you write the match function and the Director. It takes away the burden of ticket state at scale, not the design of the matching algorithm.
What you can do now
You can tell what Nakama covers (accounts, storage, chat, authoritative matches) from what Open Match adds on matchmaking alone, and you know why the open-source version is not a cluster. You have a match module written for ES5, checked by the official types and tested with a fake server, in which the client proposes and the server decides. You know how a ticket is born from an RPC and how the assignment gets back to the player. Before committing, test your own case with three real matches on a single instance: it will tell you more than any comparison table.
Sources
- Nakama: TypeScript Runtime - goja, ES5, absence of Node libraries and functions in the global scope, consulted on 2026-11-12
- Nakama: Authoritative Multiplayer - match lifecycle, tick rate, match on a single node, replication of presences only in Enterprise, consulted on 2026-11-12
- Nakama: Configuration - required CockroachDB database and default address, cluster section Enterprise only, consulted on 2026-11-12
- heroiclabs/nakama-common (nakama-runtime package) - the type definitions used to compile the module, consulted on 2026-11-12
- Open Match: Director - FetchMatches, AssignTickets and the role of the Director, consulted on 2026-11-12
- Open Match: Game Frontend - HTTP paths to create, read and watch a ticket, consulted on 2026-11-12
- Open Match: Match Function - the Run method, QueryPools and streamed proposals, consulted on 2026-11-12
- googleforgames/open-match - Apache 2.0 license and development status of the project, consulted on 2026-11-12
The TypeScript module was compiled with tsc 5 (target ES5, strict) against the nakama-runtime types and run in a Node 24 vm context with a fake nk; the MMR grouping is a teaching model with a fixed seed. The Go Director is a schema to adapt, not executed. No calls to a real Nakama or Open Match.
Commenti
Caricamento commenti...
Sign in to comment