Open Match a Nakama: Open-Source Game Backend
Vybudování herního backendu od nuly je projekt, který může trvat roky a desítky inženýrů. PlayFab, GameSparks, GameLift: spravovaná cloudová řešení nabízejí vše připravené, ale za cenu, která je pro mnohé nezávislá studia a malé společnosti jsou neúnosné, protože kvůli zablokování prodejců je obtížné změnit směr jednou podniknutý. Existuje třetí cesta: open-source frameworky.
Nakama od Heroic Labs e Otevřete zápas Google jsou dva projekty které změnily pravidla hry v sektoru. Nakama je kompletní server pro sociální hry a multiplayer v reálném čase s více než 1 bilionem žádostí měsíčně zpracovaných komunitou. Open Match je flexibilní rámec pro vytváření zápasů, který používá Google Stadia (RIP) a mnoho AAA ateliéry. Oba mají licenci Apache 2.0, jsou napsány v Go pro maximální výkon a jsou navrženy spustit na Kubernetes.
V tomto článku prozkoumáme obojí do hloubky: interní architekturu, nasazení Kubernetes, přizpůsobení pomocí TypeScript/Go, integrace mezi těmito dvěma a kompletní případová studie hry multiplayer postavený výhradně na open-source stacku.
Co se naučíte
- Architektura Nakama: základní funkce, úložiště, v reálném čase, sociální sítě
- Logika vlastního serveru Nakama v TypeScript a Go (runtime hooks)
- Nasazení Nakama na Kubernetes s CockroachDB a Redis
- Architektura Open Match: ředitel, matchfunction, hodnotitel
- Vlastní implementace matchfunction s Glicko-2
- Integrace Nakama + Open Match pro celý systém
- Srovnání s řízenými řešeními: kdy si vybrat co
- Monitorování a pozorovatelnost open-source stacku
1. Nakama: Open-Source server pro sociální hry
Nakama se zrodila v roce 2017 v Heroic Labs s jasným cílem: poskytnout každému studiu server hra se všemi sociálními funkcemi a funkcemi pro více hráčů, které dříve vyžadovaly měsíce vlastního vývoje. Výsledkem je extrémně výkonný Go server s deklarativním API a systémem runtime háčky, které vám umožňují přizpůsobit každé chování bez úpravy základního kódu.
Základní vlastnosti Nakama
| Kategorie | Vlastnosti | Detail |
|---|---|---|
| Auth | Ověření pro více poskytovatelů | E-mail, ID zařízení, Apple, Google, Facebook, Steam, vlastní |
| Skladování | Skladování objektů | Obchod JSON pro hráče s podrobnými seznamy ACL (vlastník/veřejnost/přátelé) |
| V reálném čase | Přenos pro více hráčů | Zápasová štafeta s autorizací na straně serveru, max. 64 hráčů/zápas |
| V reálném čase | Autoritativní zápasy | Herní smyčka na straně serveru přizpůsobitelná v Go/TypeScript/Lua |
| Sociální | Systém přátel | Přidat/odebrat přátele, blokovaný seznam, přítomnost (online/offline/pryč) |
| Sociální | Povídání | 1-on-1, skupinový chat, v místnosti, s trvalou zprávou |
| Konkurenční | Žebříčky | Globální, přátelské, sezónní, s vlastníkem skóre a metadaty |
| Konkurenční | Turnaje | Časované turnaje s cenami, autorizace účasti |
| Ekonomika | Peněženka | Multiměnová peněženka s atomovými transakcemi |
| Oznámení | Oznámení v aplikaci | Push notifikace s trvalostí a stavem přečtených/nepřečtených |
2. Nakama Runtime Hooks: Přizpůsobení v TypeScriptu
Runtime systém háčků je nejvýkonnější funkcí Nakamy. Umožňuje vám zachytit jakékoli událost serveru (ověření, zápis do úložiště, vlastní volání RPC) a přidejte logiku personalizované. Runtime jsou podporovány v Go (nejlepší výkon), TypeScript/JavaScript (přes Deno) a Lua (historický). TypeScript je doporučená volba pro nové projekty.
// 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. Autoritativní shoda: herní smyčka na straně serveru
Nejvýkonnější funkce Nakamy pro kompetitivní hry pro více hráčů aautoritativní shoda: herní smyčka, která běží výhradně na straně serveru, zpracovává klientské vstupy a vypočítává stav autoritativní pro hru. Neexistuje žádná nativní predikce klienta na straně Nakamy, ale můžete ji implementovat ve vašem klientovi a sladit jej se stavem serveru.
// 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. Nasazení Nakama na Kubernetes
Nakama je navržena pro horizontální škálování na Kubernetes. Použijte CockroachDB jako databázi distribuovaný (kompatibilní s PostgreSQL) pro konzistenci dat mezi uzly a Redis pro ukládání do mezipaměti a komunikace mezi uzly v reálném čase.
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. Otevřete Match: Google's Matchmaking Framework
Open Match byl vyvinut společností Google jako škálovatelný a modulární systém dohazování pro hry s miliony hráčů. Jeho architektura je založena na třech samostatných komponentách, které spolu komunikují prostřednictvím gRPC, což vám umožní přizpůsobit pouze tu část, která vás zajímá (logika shody) aniž byste museli přepisovat celou infrastrukturu.
Otevřete komponenty Match
| Komponent | Odpovědnost | Přizpůsobitelné |
|---|---|---|
| Frontend | Přijímá žádosti o dohazování od klientů (vytváření vstupenek) | Ne (jádro) |
| Backendy | Řídí cyklus Director's fetch-match-assign | Ne (jádro) |
| Funkce shody | Algoritmus párování: seskupuje vstupenky do zápasů | Ano – MUSÍ se implementovat |
| Hodnotitel | Řeší konflikty mezi navrhovanými zápasy (jeden tiket ve více zápasech) | Ano – volitelné |
| Ředitel | Orchestrator: Call match funkce, přiřazení serveru, upozornění | Ano – MUSÍ se implementovat |
// 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. Integrace Nakama + Open Match
Nakama a Open Match se dokonale doplňují: Nakama spravuje autentizaci, sociální úložiště, přítomnost a chat, zatímco Open Match zvládá škálovatelné dohazování. Probíhá integrace prostřednictvím Nakama RPC, který vytvoří tiket Open Match a webhooku Open Match, který Nakamu upozorní přiřazení serveru.
// 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: Kdy zvolit co
Volba mezi balíky s otevřeným zdrojovým kódem (Nakama + Open Match) a spravovanými řešeními (PlayFab, GameLift, Lootlocker) závisí na několika faktorech. Univerzální odpověď neexistuje.
Podrobné srovnání
| Kritérium | Open Source (Nakama) | Spravováno (PlayFab) |
|---|---|---|
| Cena (1 milion MAU) | ~5 000 $ měsíčně infrastruktura | ~15 000 $ měsíčně licence + infra |
| Uzamčení dodavatele | Žádný (Apache 2.0) | Vysoká (data Azure/AWS) |
| Přizpůsobení | Celkem (zdrojový kód je k dispozici) | Omezeno na vystavená rozhraní API |
| Čas na trh | Týdny (nastavení + učení) | Dny (průvodce + šablona) |
| Automatické škálování | Manuál (Kubernetes HPA) | Automatické a řízené |
| Soulad (GDPR) | Celková kontrola dat | Záleží na poskytovateli |
| Komunita/Podpora | Open source komunita, Discord | Garantovaná SLA, podpora podniku |
Pokyny pro volbu
- Zvolte Open-Source, pokud: Máte kompetentní tým DevOps, očekáváte 500 000+ MAU, máte přísné požadavky na shodu (GDPR, herní regulace) nebo chcete hluboké úpravy logiky párování/ukládání.
- Vyberte Spravováno, pokud: Jste nezávislé studio s malým týmem, chcete prototypovat rychle, nebo jste ve fázi raného přístupu bez jistoty ohledně objemů.
- Hybridní strategie: Začněte se zvládnutým snížením rizika před uvedením na trh, migrujte na open-source po ověření hry a svazků.
Závěry
Nakama a Open Match představují nejmodernější backendy open-source her v roce 2025. Nakama zvládá složitost sociálních funkcí a multiplayeru pomocí elegantního API runtime hook systém, který umožňuje neomezené přizpůsobení. Open Match přináší open-source svět škálovatelný a modulární matchmaking s možností implementace jakýkoli párovací algoritmus.
Klíčem k úspěchu s těmito nástroji je hluboké porozumění Kubernetes for the nasazení a schopnost psát robustní runtime logiku v TypeScript nebo Go. Investujte čas s tímto vědomím: úspory ve srovnání s řízenými řešeními jsou značné a architektonická svoboda, kterou získáte, je k nezaplacení, když se hra rozšíří.
Další kroky v sérii Game Backend
- Předchozí článek: Anti-Cheat Architecture: Server Authority
- Další článek: LiveOps: Příznak systému událostí a funkcí
- Další informace: Orchestrace herního serveru s GameLift a Agones







