Skip to main content

06 - Open Match & Nakama: Building with Open-Source Game Backends

Nakama handles accounts, data and authoritative matches; Open Match only matchmaking. What you write yourself and why open-source Nakama cannot cluster.

What do you think about this article?

At a Glance

Nakama handles accounts, data and authoritative matches; Open Match only matchmaking. What you write yourself and why open-source Nakama cannot cluster.

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.

InitModule: registering the match and an RPC call
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.

Movement and attack: the client proposes, the server decides
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.

matchLoop: validate, update, close
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 Run method: it receives a MatchProfile and streams back the match proposals
  • Backend: exposes FetchMatches and AssignTickets to 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: from ticket to assigned match
// 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.

Grouping by MMR: fair or fast
// 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.

Nakama RPC: a ticket on Open Match
/// <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

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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects