Open Match e Nakama: Game Backend Open-Source
Der Aufbau eines Spiele-Backends von Grund auf ist ein Projekt, das Jahre und Dutzende von Ingenieuren in Anspruch nehmen kann. PlayFab, GameSparks, GameLift: Managed-Cloud-Lösungen bieten alles bereit, aber zu einem Preis, der für viele erschwinglich ist Die Zusammenarbeit mit Indie-Studios und kleinen Unternehmen ist unerschwinglich, da die Bindung an einen bestimmten Anbieter einen Richtungswechsel erschwert einmal unternommen. Es gibt noch einen dritten Weg: Open-Source-Frameworks.
Nakama von Heroic Labs e Offenes Spiel Google sind zwei Projekte die die Spielregeln in der Branche verändert haben. Nakama ist ein vollständiger Social-Gaming-Server und Echtzeit-Multiplayer, wobei über 1 Billion Anfragen pro Monat von der Community verarbeitet werden. Open Match ist ein flexibles Matchmaking-Framework, das von Google Stadia (RIP) und vielen AAAs verwendet wird Studios. Beide sind Apache 2.0-lizenziert, für maximale Leistung in Go geschrieben und konzipiert auf Kubernetes laufen zu lassen.
In diesem Artikel werden wir uns eingehend mit beiden befassen: interne Architektur, Bereitstellung auf Kubernetes, Anpassung mit TypeScript/Go, Integration zwischen beiden und eine vollständige Fallstudie zum Spiel Mehrspielermodus, der vollständig auf einem Open-Source-Stack basiert.
Was Sie lernen werden
- Nakama-Architektur: Kernfunktionen, Speicher, Echtzeit, soziale Netzwerke
- Nakama custom server logic in TypeScript e Go (runtime hooks)
- Bereitstellung von Nakama auf Kubernetes mit CockroachDB und Redis
- Architettura Open Match: director, matchfunction, evaluator
- Benutzerdefinierte Matchfunction-Implementierung mit Glicko-2
- Nakama + Open Match-Integration für das gesamte System
- Vergleich mit verwalteten Lösungen: Wann sollte man was auswählen?
- Überwachung und Beobachtbarkeit des Open-Source-Stacks
1. Nakama: Der Open-Source-Server für Social Gaming
Nakama wurde 2017 von Heroic Labs mit einem klaren Ziel gegründet: jedem Studio einen Server zur Verfügung zu stellen Spiel mit allen sozialen und Multiplayer-Funktionen, die zuvor monatelange individuelle Entwicklung erforderten. Das Ergebnis ist ein äußerst leistungsstarker Go-Server mit deklarativer API und System Laufzeit-Hooks, mit denen Sie jedes Verhalten anpassen können, ohne den Kerncode zu ändern.
Nakama Core Features
| Kategorie | Feature | Detail |
|---|---|---|
| Auth | Multi-provider auth | Email, device ID, Apple, Google, Facebook, Steam, custom |
| Storage | Object storage | JSON-Store für Spieler mit granularen ACLs (Eigentümer/Öffentlich/Freunde) |
| Real-Time | Relayed multiplayer | Spielstaffel mit serverseitiger Autorisierung, max. 64 Spieler/Spiel |
| Real-Time | Authoritative matches | Server-side game loop personalizzabile in Go/TypeScript/Lua |
| Social | Friend system | Add/remove amici, blocklist, presenza (online/offline/away) |
| Social | Chat | 1-zu-1-Gruppenchat, raumbasiert, mit Nachrichtenpersistenz |
| Competitivo | Leaderboards | Global, Freunde, saisonal, mit Partiturbesitzer und Metadaten |
| Competitivo | Tournaments | Zeitgesteuerte Turniere mit Preisen, Teilnahmeberechtigung |
| Economy | Wallet | Multi-Währungs-Wallet mit atomaren Transaktionen |
| Notifications | In-app notifications | Push-Benachrichtigungen mit Persistenz und Gelesen/Ungelesen-Status |
2. Nakama Runtime Hooks: Customizzazione in TypeScript
Das Hooks-Laufzeitsystem ist Nakamas leistungsstärkste Funktion. Es ermöglicht Ihnen, alle abzufangen Serverereignis (Authentifizierung, Speicherschreibvorgang, benutzerdefinierte RPC-Aufrufe) und Logik hinzufügen personalisiert. Laufzeiten werden in Go (beste Leistung), TypeScript/JavaScript unterstützt (über Deno) und Lua (historisch). TypeScript ist die empfohlene Wahl für neue Projekte.
// 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. Authoritative Match: Game Loop Lato Server
Nakamas leistungsstärkste Funktion für kompetitive Multiplayer-Spiele undmaßgebliche Übereinstimmung: eine Spielschleife, die vollständig serverseitig abläuft, Client-Eingaben verarbeitet und den Zustand berechnet maßgeblich für das Spiel. Es gibt keine native Nakama-seitige Client-Vorhersage, aber Sie können sie implementieren in Ihrem Client und gleichen Sie es mit dem Serverstatus ab.
// 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. Bereitstellung von Nakama auf Kubernetes
Nakama ist für die horizontale Skalierung auf Kubernetes konzipiert. Verwenden Sie CockroachDB als Datenbank verteilt (kompatibel mit PostgreSQL) für Datenkonsistenz zwischen Knoten und Redis für Caching und Echtzeit-Kommunikation zwischen Knoten.
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: Googles Matchmaking Framework
Open Match wurde von Google als skalierbares und modulares Matchmaking-System für Spiele entwickelt mit Millionen von Spielern. Seine Architektur basiert auf drei separaten Komponenten, die kommunizieren über gRPC, sodass Sie nur den Teil anpassen können, der Sie interessiert (die Matching-Logik) ohne die gesamte Infrastruktur neu schreiben zu müssen.
Componenti Open Match
| Componente | Responsabilità | Customizzabile |
|---|---|---|
| Frontend | Entgegennahme von Matchmaking-Anfragen von Kunden (Ticketerstellung) | No (core) |
| Backend | Verwaltet den Fetch-Match-Assign-Zyklus des Directors | No (core) |
| Match Function | Matching-Algorithmus: Gruppiert Tickets in Übereinstimmungen | Si - MUST implement |
| Evaluator | Löst Konflikte zwischen vorgeschlagenen Spielen (ein Ticket in mehreren Spielen) | Si - opzionale |
| Director | Orchestratore: chiama match function, assegna server, notifica | Si - MUST implement |
// 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. Integrazione Nakama + Open Match
Nakama und Open Match ergänzen sich perfekt: Nakama verwaltet Authentifizierung, Social Storage, Präsenz und Chat, während Open Match das skalierbare Matchmaking übernimmt. Integration geschieht über einen Nakama-RPC, der ein Open-Match-Ticket erstellt, und einen Open-Match-Webhook, der Nakama benachrichtigt der Serverzuordnung.
// 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 vs. Managed: Wann sollte man sich für was entscheiden?
Die Wahl zwischen Open-Source-Stacks (Nakama + Open Match) und verwalteten Lösungen (PlayFab, GameLift, Lootlocker) hängt von mehreren Faktoren ab. Eine allgemeingültige Antwort gibt es nicht.
Vergleich Dettagliato
| Criterio | Open-Source (Nakama) | Managed (PlayFab) |
|---|---|---|
| Costo (1M MAU) | ~$5,000/mese infrastruttura | ~$15,000/mese licenza + infra |
| Vendor Lock-in | Nessuno (Apache 2.0) | Hoch (Azure/AWS-Daten) |
| Customizzazione | Totale (codice sorgente disponibile) | Beschränkt auf verfügbar gemachte APIs |
| Time to Market | Wochen (Einrichtung + Lernen) | Giorni (wizard + template) |
| Scaling automatico | Manuale (Kubernetes HPA) | Automatico e gestito |
| Compliance (GDPR) | Vollständige Datenkontrolle | Dipende dal provider |
| Community/Support | Open-source community, Discord | SLA garantito, enterprise support |
Leitfaden fuer die Auswahl
- Wählen Sie Open-Source, wenn: Sie haben ein kompetentes DevOps-Team, Sie erwarten mehr als 500.000 MAU, Sie haben strenge Compliance-Anforderungen (DSGVO, Glücksspielregulierung) oder Sie wünschen tiefgreifende Anpassungen der Matching-/Speicherlogik.
- Wählen Sie „Verwaltet“, wenn: Sie sind ein Indie-Studio mit einem kleinen Team und möchten Prototypen erstellen schnell, oder Sie befinden sich in der frühen Zugriffsphase und haben keine Gewissheit über die Lautstärke.
- Hybridstrategie: Beginnen Sie mit der Reduzierung des Pre-Launch-Risikos und führen Sie die Migration durch nach Validierung des Spiels und der Bände als Open-Source-Version verfügbar.
Fazit
Nakama und Open Match repräsentieren den neuesten Stand der Open-Source-Spiele-Backends im Jahr 2025. Nakama bewältigt die Komplexität sozialer Funktionen und Multiplayer mit einer eleganten API ein Laufzeit-Hooks-System, das unbegrenzte Anpassungen ermöglicht. Open Match bringt ein Open-Source-Welt ein skalierbares und modulares Matchmaking mit der Möglichkeit der Implementierung irgendein Paarungsalgorithmus.
Der Schlüssel zum Erfolg mit diesen Tools ist ein tiefes Verständnis von Kubernetes Bereitstellung und die Möglichkeit, robuste Laufzeitlogik in TypeScript oder Go zu schreiben. Investieren Sie Zeit In diesem Wissen: Die Einsparungen im Vergleich zu verwalteten Lösungen sind erheblich, und die Die architektonische Freiheit, die Sie erhalten, ist von unschätzbarem Wert, wenn das Spiel skaliert.
Naechste Schritte in der Serie Game Backend
- Articolo precedente: Anti-Cheat-Architektur: Server Authority
- Articolo successivo: LiveOps: Event System e Feature Flag
- Weitere Informationen: Gameserver-Orchestrierung mit GameLift und Agones







