Open Match i Nakama: zaplecze gier typu open source
Budowa backendu gry od podstaw to projekt, który może zająć lata i wymaga zaangażowania dziesiątek inżynierów. PlayFab, GameSparks, GameLift: zarządzane rozwiązania w chmurze oferują wszystko gotowe, ale za cenę, która jest dla wielu niezależne studia i małe firmy są wygórowane, a uzależnienie od dostawców utrudnia zmianę kierunku raz podjęte. Istnieje trzeci sposób: frameworki typu open source.
Nakama przez Heroic Labs e Otwarty mecz Google to dwa projekty które zmieniły zasady gry w branży. Nakama to kompletny serwer gier społecznościowych oraz tryb wieloosobowy w czasie rzeczywistym, z ponad 1 bilionem żądań miesięcznie przetwarzanych przez społeczność. Open Match to elastyczna platforma dobierania graczy używana przez Google Stadia (RIP) i wiele AAA studia. Obydwa mają licencję Apache 2.0, zostały napisane w Go w celu zapewnienia maksymalnej wydajności i zaprojektowane działać na Kubernetesie.
W tym artykule szczegółowo omówimy oba: architekturę wewnętrzną, wdrożenie Kubernetes, personalizacja za pomocą TypeScript/Go, integracja między nimi i kompletne studium przypadku gry tryb wieloosobowy zbudowany w całości na stosie open source.
Czego się nauczysz
- Architektura Nakama: podstawowe funkcje, pamięć masowa, działanie w czasie rzeczywistym, społecznościowe
- Niestandardowa logika serwera Nakama w TypeScript i Go (haki środowiska uruchomieniowego)
- Wdrożenie Nakamy na Kubernetesie z CockroachDB i Redis
- Architektura Open Match: reżyser, funkcja dopasowania, oceniający
- Niestandardowa implementacja funkcji dopasowania za pomocą Glicko-2
- Integracja Nakama + Open Match dla pełnego systemu
- Porównanie z rozwiązaniami zarządzanymi: kiedy co wybrać
- Monitorowanie i obserwowalność stosu open source
1. Nakama: serwer typu open source do gier społecznościowych
Nakama urodziła się w 2017 roku w Heroic Labs z jasnym celem: zapewnić każdemu studiu serwer gra ze wszystkimi funkcjami społecznościowymi i wieloosobowymi, które wcześniej wymagały miesięcy niestandardowego rozwoju. Rezultatem jest niezwykle wydajny serwer Go z deklaratywnym API i systemem haki wykonawcze, które pozwalają dostosować każde zachowanie bez modyfikowania kodu podstawowego.
Podstawowe funkcje Nakamy
| Kategoria | Cechy | Szczegół |
|---|---|---|
| Autoryt | Uwierzytelnianie wielu dostawców | E-mail, identyfikator urządzenia, Apple, Google, Facebook, Steam, niestandardowe |
| Składowanie | Przechowywanie obiektów | Sklep JSON dla graczy ze szczegółowymi listami ACL (właściciel/publiczny/znajomi) |
| W czasie rzeczywistym | Przekazywany tryb wieloosobowy | Przekaźnik meczowy z autoryzacją po stronie serwera, maksymalnie 64 graczy na mecz |
| W czasie rzeczywistym | Autorytatywne mecze | Pętla gry po stronie serwera konfigurowalna w Go/TypeScript/Lua |
| Społeczny | System znajomych | Dodaj/usuń znajomych, listę zablokowanych, obecność (online/offline/poza domem) |
| Społeczny | Pogawędzić | Czat grupowy 1 na 1, w oparciu o pokój, z trwałością wiadomości |
| Konkurencyjny | Tabele liderów | Globalny, przyjaciele, sezonowy, z właścicielem wyniku i metadanymi |
| Konkurencyjny | Turnieje | Turnieje czasowe z nagrodami, zezwolenie na udział |
| Gospodarka | Portfel | Portfel wielowalutowy z transakcjami atomowymi |
| Powiadomienia | Powiadomienia w aplikacji | Powiadomienia push z trwałością i statusem przeczytane/nieprzeczytane |
2. Haki środowiska wykonawczego Nakama: dostosowywanie w TypeScript
System wykonawczy hooków to najpotężniejsza funkcja Nakamy. Pozwala przechwycić każdy zdarzenie serwera (uwierzytelnianie, zapis w pamięci, niestandardowe wywołania RPC) i dodaj logikę spersonalizowane. Środowiska wykonawcze są obsługiwane w Go (najlepsza wydajność), TypeScript/JavaScript (przez Deno) i Lua (historycznie). TypeScript jest zalecanym wyborem w przypadku nowych projektów.
// 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. Dopasowanie wiarygodne: pętla gry po stronie serwera
Najpotężniejsza funkcja Nakamy do konkurencyjnych gier wieloosobowych iautorytatywny mecz: pętla gry działająca całkowicie po stronie serwera, przetwarzająca dane wejściowe klienta i obliczająca stan autorytatywny w grze. Nie ma natywnej prognozy klienta po stronie Nakamy, ale można ją zaimplementować w kliencie i uzgodnij go ze stanem serwera.
// 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. Wdrożenie Nakamy na Kubernetesie
Nakama została zaprojektowana do skalowania poziomego na platformie Kubernetes. Użyj CockroachDB jako bazy danych rozproszone (kompatybilne z PostgreSQL) w celu zapewnienia spójności danych między węzłami, a Redis dla buforowanie i komunikacja między węzłami w czasie rzeczywistym.
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. Otwarty mecz: Ramy dobierania partnerów Google
Open Match został opracowany przez Google jako skalowalny i modułowy system dobierania graczy do gier z milionami graczy. Jego architektura opiera się na trzech odrębnych, komunikujących się ze sobą komponentach poprzez gRPC, co pozwala dostosować tylko tę część, która Cię interesuje (logika dopasowania) bez konieczności przepisywania całej infrastruktury.
Otwórz komponenty dopasowania
| Część | Odpowiedzialność | Możliwość dostosowania |
|---|---|---|
| Frontend | Otrzymuje prośby o kojarzenie od klientów (tworzenie biletów) | Nie (rdzeń) |
| Backendy | Zarządza cyklem pobierania, dopasowania i przypisywania dyrektora | Nie (rdzeń) |
| Funkcja dopasowania | Algorytm dopasowywania: grupuje bilety w dopasowania | Tak – MUSISZ wdrożyć |
| Oceniający | Rozwiązuje konflikty pomiędzy proponowanymi meczami (jeden bilet na wiele meczów) | Tak – opcjonalnie |
| Dyrektor | Orchestrator: Wywołaj funkcję dopasowania, przypisz serwer, powiadom | Tak – MUSISZ wdrożyć |
// 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. Integracja Nakama + Mecz Otwarty
Nakama i Open Match doskonale się uzupełniają: Nakama zarządza uwierzytelnianiem, przechowywaniem w mediach społecznościowych, obecność i czat, podczas gdy Open Match obsługuje skalowalne dobieranie graczy. Integracja następuje za pośrednictwem Nakama RPC, który tworzy bilet Open Match i webhook Open Match, który powiadamia Nakamę przydziału serwera.
// 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 a zarządzane: kiedy co wybrać
Wybór pomiędzy stosami typu open source (Nakama + Open Match) a rozwiązaniami zarządzanymi (PlayFab, GameLift, Lootlocker) zależy od kilku czynników. Nie ma uniwersalnej odpowiedzi.
Szczegółowe porównanie
| Kryterium | Otwarte oprogramowanie (Nakama) | Zarządzane (PlayFab) |
|---|---|---|
| Koszt (1 mln MAU) | Infrastruktura ~ 5000 USD miesięcznie | Licencja ~15 000 USD miesięcznie + infrastruktura |
| Blokada dostawcy | Brak (Apache 2.0) | Wysoki (dane platformy Azure/AWS) |
| Personalizacja | Razem (dostępny kod źródłowy) | Ograniczone do odsłoniętych interfejsów API |
| Czas na rynek | Tygodnie (konfiguracja + nauka) | Dni (kreator + szablon) |
| Automatyczne skalowanie | Ręczny (Kubernetes HPA) | Automatyczne i zarządzane |
| Zgodność (RODO) | Całkowita kontrola danych | To zależy od dostawcy |
| Społeczność/wsparcie | Społeczność open source, Discord | Gwarantowana umowa SLA, wsparcie dla przedsiębiorstw |
Wytyczne dotyczące wyboru
- Wybierz Open Source, jeśli: Masz kompetentny zespół DevOps, oczekujesz 500K+ MAU, masz surowe wymagania dotyczące zgodności (RODO, przepisy dotyczące gier) lub chcesz głębokiej personalizacji logiki dopasowywania/przechowywania.
- Wybierz Zarządzane, jeśli: Jesteś niezależnym studiem z małym zespołem i chcesz prototypować szybko lub jesteś w fazie wczesnego dostępu i nie masz pewności co do wolumenów.
- Strategia hybrydowa: Zacznij od udało się zmniejszyć ryzyko przed uruchomieniem, migruj do open source po sprawdzeniu gry i tomów.
Wnioski
Nakama i Open Match reprezentują najnowocześniejsze rozwiązania w zakresie backendów gier typu open source w roku 2025. Nakama radzi sobie ze złożonością funkcji społecznościowych i gry wieloosobowej za pomocą eleganckiego interfejsu API system zaczepów środowiska wykonawczego, który umożliwia nieograniczone dostosowania. Open Match przynosi świat open source to skalowalne i modułowe kojarzenie, z możliwością wdrożenia dowolny algorytm parowania.
Kluczem do sukcesu w przypadku tych narzędzi jest głębokie zrozumienie Kubernetes dla wdrożenia i możliwość pisania solidnej logiki wykonawczej w TypeScript lub Go. Inwestuj czas w tej wiedzy: oszczędności w porównaniu z rozwiązaniami zarządzanymi są znaczne, a Swoboda architektoniczna, jaką otrzymujesz, jest bezcenna, gdy gra się skaluje.
Kolejne kroki w serii Game Backend
- Poprzedni artykuł: Architektura zapobiegająca oszustwom: Władze serwera
- Następny artykuł: LiveOps: System wydarzeń i flaga funkcji
- Dalsze informacje: Orkiestracja serwerów gier za pomocą GameLift i Agones







