Open Match și Nakama: Open-Source Game Backend
Construirea unui backend de joc de la zero este un proiect care poate dura ani și zeci de ingineri. PlayFab, GameSparks, GameLift: soluțiile cloud gestionate oferă totul gata, dar la un cost care pentru mulți studiourile indie și companiile mici sunt prohibitive, blocarea vânzătorilor fiind dificilă schimbarea direcției odată întreprinsă. Există o a treia cale: framework-uri open-source.
Nakama de Heroic Labs e Deschideți meciul Google sunt două proiecte care au schimbat regulile jocului în sector. Nakama este un server complet de jocuri sociale și multiplayer în timp real, cu peste 1 trilion de solicitări pe lună procesate de comunitatea sa. Open Match este un cadru flexibil de matchmaking folosit de Google Stadia (RIP) și de multe AAA garsoniere. Ambele sunt licențiate Apache 2.0, scrise în Go pentru performanță maximă și proiectate pentru a rula pe Kubernetes.
În acest articol vom explora ambele în profunzime: arhitectura internă, implementarea Kubernetes, personalizare cu TypeScript/Go, integrare între cele două și un studiu de caz complet al jocului multiplayer construit în întregime pe stivă open-source.
Ce vei învăța
- Arhitectura Nakama: caracteristici de bază, stocare, în timp real, social
- Logica de server personalizată Nakama în TypeScript și Go (cârlige de rulare)
- Implementarea Nakama pe Kubernetes cu CockroachDB și Redis
- Arhitectură Open Match: director, funcție de meci, evaluator
- Implementarea funcției de potrivire personalizată cu Glicko-2
- Integrare Nakama + Open Match pentru sistem complet
- Comparație cu soluțiile gestionate: când să alegi ce
- Monitorizarea și observabilitatea stivei open-source
1. Nakama: Serverul Open-Source pentru jocurile sociale
Nakama s-a născut în 2017 de Heroic Labs cu un obiectiv clar: să ofere oricărui studio un server. joc cu toate funcțiile sociale și multiplayer care anterior necesitau luni de dezvoltare personalizată. Rezultatul este un server Go extrem de performant, cu un API și un sistem declarativ cârlige de rulare care vă permit să personalizați fiecare comportament fără a modifica codul de bază.
Caracteristici de bază Nakama
| Categorie | Caracteristici | Detaliu |
|---|---|---|
| Auth | Autentificare cu mai mulți furnizori | E-mail, ID dispozitiv, Apple, Google, Facebook, Steam, personalizat |
| Depozitare | Depozitarea obiectelor | Magazin JSON pentru jucători cu ACL-uri granulare (proprietar/public/prieteni) |
| În timp real | Multiplayer retransmis | Stafeu meci cu autorizare pe server, max 64 de jucători/meci |
| În timp real | Potriviri cu autoritate | Buclă de joc pe partea de server personalizabilă în Go/TypeScript/Lua |
| Social | Sistemul de prieteni | Adăugați/eliminați prieteni, listă blocată, prezență (online/offline/deplasare) |
| Social | Chat | 1-la-1, chat de grup, în cameră, cu persistență în mesaje |
| Competitiv | Clasamente | Global, prieteni, sezonier, cu proprietarul scorului și metadate |
| Competitiv | Turnee | Turnee cronometrate cu premii, autorizare de participare |
| Economie | Portofel | Portofel cu mai multe valute cu tranzacții atomice |
| Notificări | Notificări în aplicație | Notificări push cu persistență și stare de citit/necitit |
2. Nakama Runtime Hooks: Personalizare în TypeScript
Sistemul de rulare hooks este cea mai puternică caracteristică a lui Nakama. Vă permite să interceptați orice eveniment de server (autentificare, scriere de stocare, apeluri RPC personalizate) și adăugați logică personalizate. Timpurile de execuție sunt acceptate în Go (cea mai bună performanță), TypeScript/JavaScript (prin Deno) și Lua (istoric). TypeScript este alegerea recomandată pentru proiecte noi.
// 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. Potrivire autorizată: Buclă de joc pe partea de server
Cea mai puternică caracteristică a lui Nakama pentru jocuri multiplayer competitive șipotrivire autoritară: o buclă de joc care rulează în întregime pe partea serverului, procesează intrările clientului și calculează starea autoritar al jocului. Nu există o predicție nativă a clientului Nakama, dar o puteți implementa în clientul dvs. și reconciliați-l cu starea serverului.
// 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. Implementarea Nakama pe Kubernetes
Nakama este proiectat pentru a scala orizontal pe Kubernetes. Utilizați CockroachDB ca bază de date distribuit (compatibil cu PostgreSQL) pentru coerența datelor între noduri și Redis pentru cache și comunicare în timp real între noduri.
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. Deschideți potrivire: Cadrul Google Matchmaking
Open Match a fost dezvoltat de Google ca un sistem de potrivire scalabil și modular pentru jocuri cu milioane de jucători. Arhitectura sa se bazează pe trei componente separate care comunică prin gRPC, permițându-vă să personalizați doar partea care vă interesează (logica de potrivire) fără a fi nevoie să rescrie întreaga infrastructură.
Deschideți componentele Match
| Componentă | Responsabilitate | Personalizat |
|---|---|---|
| În față | Primește solicitări de potrivire de la clienți (crearea biletelor) | Nu (nucleu) |
| Backend-uri | Gestionează ciclul de preluare-potrivire-alocare al directorului | Nu (nucleu) |
| Funcția de potrivire | Algoritm de potrivire: grupează biletele în meciuri | Da - TREBUIE implementat |
| Evaluator | Rezolvă conflictele dintre meciurile propuse (un bilet în mai multe meciuri) | Da - optional |
| Director | Orchestrator: Apelați funcția de potrivire, atribuiți server, notifică | Da - TREBUIE implementat |
// 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. Integrare Nakama + Open Match
Nakama și Open Match se completează perfect: Nakama gestionează autentificarea, stocarea socială, prezență și chat, în timp ce Open Match se ocupă de matchmaking scalabil. Integrarea are loc printr-un RPC Nakama care creează un bilet Open Match și un webhook Open Match care îl notifică pe Nakama a atribuirii serverului.
// 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 gestionat: când să alegeți ce
Alegerea între stive open-source (Nakama + Open Match) și soluții gestionate (PlayFab, GameLift, Lootlocker) depinde de mai mulți factori. Nu există un răspuns universal.
Comparație detaliată
| Criteriu | Open Source (Nakama) | Gestionat (PlayFab) |
|---|---|---|
| Cost (1 M MAU) | ~ 5.000 USD/lună infrastructură | ~15.000 USD/lună licență + infra |
| Blocarea vânzătorului | Niciunul (Apache 2.0) | Ridicat (date Azure/AWS) |
| Personalizare | Total (codul sursă disponibil) | Limitat la API-urile expuse |
| Timp de piață | Săptămâni (configurare + învățare) | Zile (vrăjitor + șablon) |
| Scalare automată | Manual (Kubernetes HPA) | Automat și gestionat |
| Conformitate (GDPR) | Control total al datelor | Depinde de furnizor |
| Comunitate/Suport | Comunitate open-source, Discord | SLA garantat, suport pentru întreprinderi |
Ghid pentru alegere
- Alegeți Open-Source dacă: Aveți o echipă DevOps competentă, vă așteptați la 500.000+ MAU, aveți cerințe stricte de conformitate (GDPR, reglementări pentru jocuri) sau doriți personalizări profunde a logicii de potrivire/stocare.
- Alegeți Gestionat dacă: Ești un studio indie cu o echipă mică, vrei să faci prototipuri rapid, sau vă aflați în faza de acces timpuriu fără a avea certitudine cu privire la volume.
- Strategie hibridă: Începeți cu a reușit să reduceți riscul înainte de lansare, migrați la open-source după validarea jocului și a volumelor.
Concluzii
Nakama și Open Match reprezintă stadiul tehnicii backend-urilor pentru jocuri open-source în 2025. Nakama se ocupă de complexitatea caracteristicilor sociale și a jocului multiplayer cu un API elegant un sistem de cârlige de rulare care permite personalizări nelimitate. Open Match aduce în joc lume open-source un matchmaking scalabil și modular, cu posibilitatea de implementare orice algoritm de împerechere.
Cheia succesului cu aceste instrumente este o înțelegere profundă a Kubernetes pentru implementare și capacitatea de a scrie o logică de rulare robustă în TypeScript sau Go. Investește timp din aceste cunoștințe: economiile în comparație cu soluțiile gestionate sunt semnificative, iar cel libertatea arhitecturală pe care o obțineți este neprețuită atunci când jocul crește.
Următorii pași în seria Game Backend
- Articolul precedent: Arhitectură anti-cheat: Autoritatea serverului
- Articolul următor: LiveOps: sistem de evenimente și semnalizare caracteristică
- Informații suplimentare: Orchestrarea serverului de jocuri cu GameLift și Agones







