Open Match en Nakama: open source game-backend
Het helemaal opnieuw opbouwen van een game-backend is een project dat jaren en tientallen ingenieurs kan duren. SpeelFab, GameSparks, GameLift: beheerde cloudoplossingen bieden alles kant-en-klaar, maar tegen een prijs die voor velen geldt indiestudio's en kleine bedrijven zijn onbetaalbaar, omdat de afhankelijkheid van leveranciers het moeilijk maakt om van richting te veranderen eens ondernomen. Er is een derde manier: open-sourceframeworks.
Nakama door Heroic Labs e Wedstrijd openen Google zijn twee projecten die de spelregels in de sector hebben veranderd. Nakama is een complete sociale gamingserver en realtime multiplayer, met meer dan 1 biljoen verzoeken per maand verwerkt door de community. Open Match is een flexibel matchmaking-framework dat wordt gebruikt door Google Stadia (RIP) en veel AAA's studio's. Beide hebben een Apache 2.0-licentie, zijn geschreven in Go voor maximale prestaties en zijn ontworpen om op Kubernetes te draaien.
In dit artikel zullen we beide diepgaand onderzoeken: interne architectuur, Kubernetes-implementatie, maatwerk met TypeScript/Go, integratie tussen de twee en een complete game-casestudy multiplayer volledig gebouwd op open-sourcestack.
Wat je gaat leren
- Nakama-architectuur: kernfuncties, opslag, realtime, sociaal
- Nakama aangepaste serverlogica in TypeScript en Go (runtime hooks)
- Implementatie Nakama op Kubernetes met CockroachDB en Redis
- Open Match-architectuur: regisseur, matchfunctie, evaluator
- Aangepaste matchfunctie-implementatie met Glicko-2
- Nakama + Open Match-integratie voor volledig systeem
- Vergelijking met beheerde oplossingen: wanneer kies je wat?
- Monitoring en waarneembaarheid van de open-sourcestack
1. Nakama: de open source-server voor sociaal gamen
Nakama werd in 2017 geboren door Heroic Labs met een duidelijk doel: elke studio van een server voorzien game met alle sociale en multiplayer-functies waarvoor voorheen maanden van aangepaste ontwikkeling nodig waren. Het resultaat is een extreem krachtige Go-server met een declaratieve API en systeem runtime-hooks waarmee u elk gedrag kunt aanpassen zonder de kerncode te wijzigen.
Kernfuncties van Nakama
| Categorie | Functies | Detail |
|---|---|---|
| Aut | Autorisatie voor meerdere providers | E-mail, apparaat-ID, Apple, Google, Facebook, Steam, aangepast |
| Opslag | Objectopslag | JSON-winkel voor spelers met gedetailleerde ACL's (eigenaar/openbaar/vrienden) |
| Realtime | Gerelayeerde multiplayer | Wedstrijdestafette met autorisatie aan de serverzijde, max. 64 spelers/wedstrijd |
| Realtime | Gezaghebbende wedstrijden | Gameloop aan de serverzijde aanpasbaar in Go/TypeScript/Lua |
| Sociaal | Vriend systeem | Vrienden toevoegen/verwijderen, blokkeerlijst, aanwezigheid (online/offline/afwezig) |
| Sociaal | Chatten | 1-op-1, groepschat, kamergebaseerd, met berichtpersistentie |
| Competitief | Leaderboards | Globaal, vrienden, seizoensgebonden, met partituureigenaar en metadata |
| Competitief | Toernooien | Getimede toernooien met prijzen, toestemming voor deelname |
| Economie | Portemonnee | Portemonnee voor meerdere valuta's met atomaire transacties |
| Meldingen | In-app-meldingen | Pushmeldingen met persistentie en gelezen/ongelezen status |
2. Nakama Runtime Hooks: maatwerk in TypeScript
Het hooks runtime-systeem is de krachtigste functie van Nakama. Hiermee kunt u alles onderscheppen servergebeurtenis (authenticatie, opslagschrijven, aangepaste RPC-aanroepen) en logica toevoegen gepersonaliseerd. Runtimes worden ondersteund in Go (beste prestaties), TypeScript/JavaScript (via Deno) en Lua (historisch). TypeScript is de aanbevolen keuze voor nieuwe projecten.
// runtime/main.ts - Entry point del Nakama runtime TypeScript
// Importa l'interfaccia di Nakama per type safety
/// <reference types="@heroiclabs/nakama-runtime" />
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger,
nk: nkruntime.Nakama, initializer: nkruntime.Initializer): Error | void {
logger.info("Game runtime initialized - version 2.1.0");
// === REGISTRAZIONE BEFORE HOOKS ===
// Hook prima dell'autenticazione: validazione custom
initializer.registerBeforeAuthenticateEmail(
(ctx, logger, nk, request) => {
// Blocca email da domini blacklistati
const blockedDomains = ['tempmail.com', 'throwaway.email'];
const domain = request.email?.split('@')[1] ?? '';
if (blockedDomains.includes(domain)) {
throw new Error(`Email domain ${domain} is not allowed`);
}
return request; // Passa la richiesta inalterata
}
);
// Hook dopo la creazione account: inizializzazione wallet
initializer.registerAfterAuthenticateEmail(
(ctx, logger, nk, out, request) => {
if (out.created) {
// Nuovo giocatore: assegna valuta iniziale
try {
nk.walletUpdate(ctx.userId!, { coins: 500, gems: 10 },
{ reason: "welcome_bonus" }, true);
logger.info(`Welcome bonus assigned to ${ctx.userId}`);
} catch (e) {
logger.error(`Failed to assign welcome bonus: ${e}`);
}
}
}
);
// === REGISTRAZIONE RPC FUNCTIONS ===
// RPC: recupera statistiche giocatore
initializer.registerRpc('get_player_stats', getPlayerStatsRpc);
// RPC: avvia ricerca matchmaking
initializer.registerRpc('start_matchmaking', startMatchmakingRpc);
// RPC: acquisto item
initializer.registerRpc('purchase_item', purchaseItemRpc);
// === MATCH HANDLER ===
initializer.registerMatch('game_match', {
matchInit: matchInit,
matchJoinAttempt: matchJoinAttempt,
matchJoin: matchJoin,
matchLeave: matchLeave,
matchLoop: matchLoop,
matchSignal: matchSignal,
matchTerminate: matchTerminate
});
}
// RPC: acquisto item con verifica wallet
function purchaseItemRpc(ctx: nkruntime.Context, logger: nkruntime.Logger,
nk: nkruntime.Nakama, payload: string): string {
interface PurchaseRequest { item_id: string; quantity: number; }
const req: PurchaseRequest = JSON.parse(payload);
// Leggi catalogo item dallo storage
const storageObjects = nk.storageRead([{
collection: 'catalog',
key: req.item_id,
userId: ''
}]);
if (storageObjects.length === 0) {
throw new Error(`Item ${req.item_id} not found in catalog`);
}
interface CatalogItem { price_coins: number; type: string; }
const item = JSON.parse(storageObjects[0].value) as CatalogItem;
const totalCost = item.price_coins * req.quantity;
// Transazione atomica: scala wallet e concedi item
try {
nk.walletUpdate(ctx.userId!, { coins: -totalCost }, {
reason: `purchase_${req.item_id}`,
quantity: req.quantity
}, false); // false = errore se saldo insufficiente
// Salva item nell'inventario
nk.storageWrite([{
collection: 'inventory',
key: req.item_id,
userId: ctx.userId!,
value: JSON.stringify({
item_id: req.item_id,
quantity: req.quantity,
purchased_at: Date.now()
}),
permissionRead: 1, // owner only
permissionWrite: 0 // server only
}]);
return JSON.stringify({ success: true, new_quantity: req.quantity });
} catch (e) {
throw new Error(`Purchase failed: ${e}`);
}
}
3. Gezaghebbende match: gameloop aan de serverzijde
Nakama's krachtigste functie voor competitieve multiplayer-games engezaghebbende wedstrijd: een gameloop die volledig aan de serverzijde draait, clientinvoer verwerkt en de status berekent gezaghebbend over het spel. Er is geen native clientvoorspelling aan de Nakama-zijde, maar u kunt deze wel implementeren in uw client en stem deze af met de serverstatus.
// runtime/match_handler.ts - Authoritative match game loop
interface GameState {
players: Map<string, PlayerState>;
phase: 'waiting' | 'playing' | 'ending';
tick: number;
start_time: number;
config: MatchConfig;
}
interface PlayerState {
user_id: string;
position: { x: number; y: number };
health: number;
score: number;
alive: boolean;
}
// Inizializzazione match: configura stato iniziale
function matchInit(ctx: nkruntime.Context, logger: nkruntime.Logger,
nk: nkruntime.Nakama, params: { [key: string]: string }) {
const initialState: GameState = {
players: new Map(),
phase: 'waiting',
tick: 0,
start_time: 0,
config: {
max_players: parseInt(params['max_players'] ?? '8'),
tick_rate: parseInt(params['tick_rate'] ?? '20'), // 20 tick/s
match_duration_sec: parseInt(params['duration'] ?? '300')
}
};
return {
state: initialState,
tickRate: initialState.config.tick_rate,
label: JSON.stringify({ mode: params['mode'] ?? 'deathmatch' })
};
}
// Game loop principale: eseguito ad ogni tick (20 volte/secondo)
function matchLoop(ctx: nkruntime.Context, logger: nkruntime.Logger,
nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher,
tick: number, state: GameState,
messages: nkruntime.MatchMessage[]): GameState | null {
state.tick = tick;
// Processa tutti i messaggi dai client in questo tick
for (const message of messages) {
const userId = message.sender.userId;
const opCode = message.opCode;
const data = JSON.parse(message.data) as Record<string, number>;
switch (opCode) {
case 1: // Movimento
const player = state.players.get(userId);
if (player?.alive) {
// Validazione server-side: max velocità
const dx = Math.min(Math.abs(data.dx), 5) * Math.sign(data.dx);
const dy = Math.min(Math.abs(data.dy), 5) * Math.sign(data.dy);
player.position.x += dx;
player.position.y += dy;
// Clamp dentro i bordi della mappa
player.position.x = Math.max(0, Math.min(100, player.position.x));
player.position.y = Math.max(0, Math.min(100, player.position.y));
}
break;
case 2: // Attacco
if (state.phase === 'playing') {
processAttack(state, userId, data);
}
break;
}
}
// Aggiorna fase di gioco
if (state.phase === 'waiting') {
const readyCount = Array.from(state.players.values())
.filter(p => p.alive).length;
if (readyCount >= 2) {
state.phase = 'playing';
state.start_time = Date.now();
}
} else if (state.phase === 'playing') {
const elapsed = (Date.now() - state.start_time) / 1000;
const aliveCount = Array.from(state.players.values())
.filter(p => p.alive).length;
if (elapsed >= state.config.match_duration_sec || aliveCount <= 1) {
state.phase = 'ending';
// Broadcast risultati finali
const results = buildResults(state);
dispatcher.broadcastMessage(99, JSON.stringify(results), null, null, true);
return null; // Termina il match
}
}
// Broadcast stato a tutti i giocatori ogni 3 tick (67ms) per ridurre bandwidth
if (tick % 3 === 0) {
const snapshot = buildStateSnapshot(state);
dispatcher.broadcastMessage(0, JSON.stringify(snapshot), null, null, false);
}
return state;
}
function processAttack(state: GameState, attackerId: string,
data: Record<string, number>): void {
const attacker = state.players.get(attackerId);
if (!attacker?.alive) return;
// Trova target nel raggio di attacco (5 unita)
for (const [id, player] of state.players.entries()) {
if (id === attackerId || !player.alive) continue;
const dx = player.position.x - attacker.position.x;
const dy = player.position.y - attacker.position.y;
const distance = Math.sqrt(dx*dx + dy*dy);
if (distance <= 5) {
const damage = data.damage ?? 10;
player.health -= damage;
if (player.health <= 0) {
player.alive = false;
player.health = 0;
attacker.score += 100; // Uccisione
}
}
}
}
4. Implementatie van Nakama op Kubernetes
Nakama is ontworpen om horizontaal te schalen op Kubernetes. Gebruik CockroachDB als database gedistribueerd (compatibel met PostgreSQL) voor gegevensconsistentie tussen knooppunten, en Redis voor caching en realtime communicatie tussen knooppunten.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nakama
namespace: game-backend
spec:
replicas: 3 # Scaling orizzontale: ogni replica gestisce match diversi
selector:
matchLabels:
app: nakama
template:
metadata:
labels:
app: nakama
spec:
containers:
- name: nakama
image: heroiclabs/nakama:3.22.0
ports:
- containerPort: 7349 # gRPC
- containerPort: 7350 # HTTP API
- containerPort: 7351 # Console
env:
- name: NAKAMA_DATABASE_ADDRESS
valueFrom:
secretKeyRef:
name: nakama-secrets
key: db-address
- name: NAKAMA_RUNTIME_PATH
value: "/nakama/data/modules"
args:
- "--database.address=$(NAKAMA_DATABASE_ADDRESS)"
- "--cache.address=redis:6379"
- "--runtime.path=$(NAKAMA_RUNTIME_PATH)"
- "--session.token_expiry_sec=86400"
- "--socket.server_key=nakama-server-key-production"
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
volumeMounts:
- name: runtime-modules
mountPath: /nakama/data/modules
volumes:
- name: runtime-modules
configMap:
name: nakama-runtime-js
---
apiVersion: v1
kind: Service
metadata:
name: nakama
namespace: game-backend
spec:
type: LoadBalancer # O NodePort + Ingress nginx
selector:
app: nakama
ports:
- name: grpc
port: 7349
targetPort: 7349
- name: http
port: 7350
targetPort: 7350
---
# HPA: scala da 3 a 20 repliche in base a CPU
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: nakama-hpa
namespace: game-backend
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: nakama
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
5. Open Match: het Matchmaking Framework van Google
Open Match is door Google ontwikkeld als een schaalbaar en modulair matchmakingsysteem voor games met miljoenen spelers. De architectuur is gebaseerd op drie afzonderlijke componenten die communiceren via gRPC, waardoor u alleen het deel kunt aanpassen dat u interesseert (de overeenkomende logica) zonder dat je de hele infrastructuur hoeft te herschrijven.
Open Match-componenten
| Onderdeel | Verantwoordelijkheid | Aanpasbaar |
|---|---|---|
| Frontend | Ontvangt matchmakingverzoeken van klanten (ticketcreatie) | Nee (kern) |
| Backends | Beheert de ophaal-match-toewijzingscyclus van de Director | Nee (kern) |
| Match-functie | Matchingalgoritme: groepeert tickets in wedstrijden | Ja - MOET implementeren |
| Evaluator | Lost conflicten op tussen voorgestelde wedstrijden (één ticket in meerdere wedstrijden) | Ja - optioneel |
| Directeur | Orchestrator: Match-functie oproepen, server toewijzen, melden | Ja - MOET implementeren |
// Director: orchestratore del matchmaking (Go)
package main
import (
"context"
"fmt"
"time"
ompb "open-match.dev/open-match/pkg/pb"
"google.golang.org/grpc"
)
func director(ctx context.Context, be ompb.BackendServiceClient) {
profile := &ompb.MatchProfile{
Name: "deathmatch-8v8",
Pools: []*ompb.Pool{
{
Name: "all-players",
DoubleRangeFilters: []*ompb.DoubleRangeFilter{
{
DoubleArg: "mmr",
Min: 0,
Max: 3000,
},
},
},
},
}
for {
select {
case <-ctx.Done():
return
default:
// Fetcha match proposti dalla match function
stream, err := be.FetchMatches(ctx, &ompb.FetchMatchesRequest{
Config: &ompb.FunctionConfig{
Host: "matchfunction",
Port: 50502,
Type: ompb.FunctionConfig_GRPC,
},
Profile: profile,
})
if err != nil {
fmt.Printf("FetchMatches error: %v\n", err)
time.Sleep(5 * time.Second)
continue
}
for {
resp, err := stream.Recv()
if err != nil {
break
}
// Assegna server di gioco al match
conn, err := assignGameServer(resp.Match)
if err != nil {
fmt.Printf("GameServer assignment error: %v\n", err)
continue
}
// Notifica i ticket del loro assignment
ids := make([]string, len(resp.Match.Tickets))
for i, t := range resp.Match.Tickets {
ids[i] = t.Id
}
be.AssignTickets(ctx, &ompb.AssignTicketsRequest{
Assignments: []*ompb.AssignmentGroup{{
TicketIds: ids,
Assignment: &ompb.Assignment{
Connection: conn, // "game-server-eu-west:7777"
},
}},
})
}
time.Sleep(1 * time.Second) // Polling ogni secondo
}
}
}
// Match Function: logica di matching con Glicko-2
// Raggruppa ticket in match di 8 giocatori con rating simile
func matchFunction(ctx context.Context, req *mmfpb.RunRequest) (
*mmfpb.RunResponse, error) {
tickets := req.Pooled_tickets["all-players"]
var matches []*ompb.Match
// Ordina per MMR per trovare gruppi omogenei
sort.Slice(tickets, func(i, j int) bool {
mmrI := tickets[i].SearchFields.DoubleArgs["mmr"]
mmrJ := tickets[j].SearchFields.DoubleArgs["mmr"]
return mmrI < mmrJ
})
// Raggruppa in match da 8, tolleranza MMR +-200
matchSize := 8
for i := 0; i+matchSize-1 < len(tickets); i += matchSize {
group := tickets[i : i+matchSize]
// Verifica spread MMR accettabile
minMMR := group[0].SearchFields.DoubleArgs["mmr"]
maxMMR := group[matchSize-1].SearchFields.DoubleArgs["mmr"]
if maxMMR-minMMR > 200 {
continue // Spread troppo alto, skip
}
matches = append(matches, &ompb.Match{
MatchId: fmt.Sprintf("dm8v8-%d", time.Now().UnixNano()),
MatchProfile: req.GetProfile().GetName(),
MatchFunction: "glicko2-deathmatch",
Tickets: group,
})
}
return &mmfpb.RunResponse{Proposals: matches}, nil
}
6. Nakama + Open Match-integratie
Nakama en Open Match vullen elkaar perfect aan: Nakama beheert authenticatie, sociale opslag, aanwezigheid en chat, terwijl Open Match schaalbare matchmaking verzorgt. Integratie gebeurt via een Nakama RPC die een Open Match-ticket aanmaakt en een Open Match-webhook die Nakama op de hoogte stelt van de servertoewijzing.
// Nakama RPC: crea ticket di matchmaking su Open Match
function startMatchmakingRpc(ctx: nkruntime.Context,
logger: nkruntime.Logger, nk: nkruntime.Nakama,
payload: string): string {
interface MatchmakingRequest { mode: string; region: string; }
const req: MatchmakingRequest = JSON.parse(payload);
// Recupera MMR del giocatore dallo storage
const statsObjs = nk.storageRead([{
collection: 'player_stats',
key: 'rating',
userId: ctx.userId!
}]);
const mmr = statsObjs.length > 0
? (JSON.parse(statsObjs[0].value) as { mmr: number }).mmr
: 1000; // MMR default per nuovi giocatori
// Chiama Open Match Frontend via HTTP
const response = nk.httpRequest(
'http://open-match-frontend:51504/v1/tickets',
'POST',
{ 'Content-Type': 'application/json' },
JSON.stringify({
search_fields: {
double_args: {
mmr: mmr
},
string_args: {
mode: req.mode,
region: req.region
}
},
extensions: {
player_id: ctx.userId!,
nakama_session: ctx.sessionId!
}
})
);
if (response.code !== 200) {
throw new Error(`Open Match error: ${response.code}`);
}
interface OMTicket { id: string; }
const ticket = JSON.parse(response.body) as OMTicket;
// Salva ticket ID per polling status
nk.storageWrite([{
collection: 'matchmaking',
key: 'current_ticket',
userId: ctx.userId!,
value: JSON.stringify({
ticket_id: ticket.id,
mode: req.mode,
created_at: Date.now()
}),
permissionRead: 1,
permissionWrite: 0
}]);
return JSON.stringify({
ticket_id: ticket.id,
status: 'searching'
});
}
7. Open source versus beheerd: wanneer moet je wat kiezen?
De keuze tussen open-sourcestacks (Nakama + Open Match) en beheerde oplossingen (PlayFab, GameLift, Lootlocker) is afhankelijk van verschillende factoren. Er is geen universeel antwoord.
Gedetailleerde vergelijking
| Criterium | Open source (Nakama) | Beheerd (PlayFab) |
|---|---|---|
| Kosten (1 miljoen MAU) | ~$5.000/maand infrastructuur | ~$15.000/maand licentie + infra |
| Leverancierslock-in | Geen (Apache 2.0) | Hoog (Azure/AWS-gegevens) |
| Maatwerk | Totaal (broncode beschikbaar) | Beperkt tot blootgestelde API's |
| Tijd voor de markt | Weken (opzetten + leren) | Dagen (wizard + sjabloon) |
| Automatisch schalen | Handleiding (Kubernetes HPA) | Automatisch en beheerd |
| Naleving (AVG) | Totale gegevenscontrole | Het hangt af van de aanbieder |
| Gemeenschap/ondersteuning | Open-sourcegemeenschap, Discord | Gegarandeerde SLA, bedrijfsondersteuning |
Richtlijn voor keuze
- Kies Open Source als: Je hebt een competent DevOps-team, je verwacht meer dan 500.000 MAU, je hebt strikte compliance-eisen (AVG, kansspelregelgeving), of je wilt diepgaande aanpassingen van de matching/opslaglogica.
- Kies Beheerd als: Je bent een indiestudio met een klein team en je wilt een prototype maken snel, of u bevindt zich in de vroege toegangsfase zonder zekerheid over volumes.
- Hybride strategie: Begin met het beheersen van het pre-lanceringsrisico en migreer naar open-source na het valideren van het spel en de volumes.
Conclusies
Nakama en Open Match vertegenwoordigen de stand van zaken op het gebied van open-source game-backends in 2025. Nakama verwerkt de complexiteit van sociale functies en multiplayer met een elegante API een runtime hooks-systeem dat onbeperkte aanpassingen mogelijk maakt. Open Match brengt binnen open-source wereld een schaalbare en modulaire matchmaking, met de mogelijkheid tot implementatie elk koppelingsalgoritme.
De sleutel tot succes met deze tools is een diep begrip van Kubernetes voor de implementatie en de mogelijkheid om robuuste runtime-logica te schrijven in TypeScript of Go. Investeer tijd in deze wetenschap: de besparingen vergeleken met beheerde oplossingen zijn aanzienlijk, en de De architectonische vrijheid die je krijgt is van onschatbare waarde als het spel zich opschaalt.
Volgende stappen in de Game Backend-serie
- Vorig artikel: Anti-Cheat-architectuur: serverautoriteit
- Volgend artikel: LiveOps: evenementensysteem en featurevlag
- Nadere informatie: Gameserverorkestratie met GameLift en Agones







