Open Match e Nakama: Game Backend Open-Source
Construire un backend de jeu à partir de zéro est un projet qui peut prendre des années et des dizaines d'ingénieurs. PlayFab, GameSparks, GameLift : les solutions cloud gérées offrent tout prêt mais à un coût qui pour beaucoup les studios indépendants et les petites entreprises sont prohibitifs, la dépendance vis-à-vis des fournisseurs rendant difficile le changement de direction une fois entrepris. Il existe une troisième voie : les frameworks open source.
Nakama par Heroic Labs e Match ouvert Google est deux projets qui ont changé les règles du jeu dans le secteur. Nakama est un serveur de jeu social complet et multijoueur en temps réel, avec plus de 1 000 milliards de demandes par mois traitées par sa communauté. Open Match est un cadre de mise en relation flexible utilisé par Google Stadia (RIP) et de nombreux AAA les studios. Les deux sont sous licence Apache 2.0, écrits en Go pour des performances maximales et conçus pour fonctionner sur Kubernetes.
Dans cet article, nous explorerons les deux en profondeur : l'architecture interne, le déploiement sur Kubernetes, personnalisation avec TypeScript/Go, intégration entre les deux et étude de cas de jeu complète multijoueur entièrement construit sur une pile open source.
Ce que Vous Apprendrez
- Architecture Nakama : fonctionnalités principales, stockage, temps réel, réseaux sociaux
- Nakama custom server logic in TypeScript e Go (runtime hooks)
- Déploiement Nakama sur Kubernetes avec CockroachDB et Redis
- Architettura Open Match: director, matchfunction, evaluator
- Implémentation d'une fonction de correspondance personnalisée avec Glicko-2
- Intégration Nakama + Open Match pour le système complet
- Comparaison avec les solutions managées : quand choisir quoi
- Surveillance et observabilité de la pile open source
1. Nakama : le serveur open source pour les jeux sociaux
Nakama est né en 2017 par Heroic Labs avec un objectif clair : doter n'importe quel studio d'un serveur jeu avec toutes les fonctionnalités sociales et multijoueurs qui nécessitaient auparavant des mois de développement personnalisé. Le résultat est un serveur Go extrêmement performant avec une API et un système déclaratifs des hooks d'exécution qui vous permettent de personnaliser chaque comportement sans modifier le code principal.
Nakama Core Features
| Catégorie | Feature | Détail |
|---|---|---|
| Auth | Multi-provider auth | Email, device ID, Apple, Google, Facebook, Steam, custom |
| Storage | Object storage | Magasin JSON pour les joueurs avec des ACL granulaires (propriétaire/public/amis) |
| Real-Time | Relayed multiplayer | Relais de match avec autorisation côté serveur, max 64 joueurs/match |
| 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 | Chat en tête-à-tête, en groupe, dans une salle, avec persistance des messages |
| Competitivo | Leaderboards | Global, amis, saisonnier, avec propriétaire du score et métadonnées |
| Competitivo | Tournaments | Tournois chronométrés avec prix, autorisation de participation |
| Economy | Wallet | Portefeuille multi-devises avec transactions atomiques |
| Notifications | In-app notifications | Notifications push avec persistance et statut lu/non lu |
2. Nakama Runtime Hooks: Customizzazione in TypeScript
Le système d'exécution des hooks est la fonctionnalité la plus puissante de Nakama. Il vous permet d'intercepter n'importe quel événement serveur (authentification, écriture de stockage, appels RPC personnalisés) et ajout de logique personnalisé. Les runtimes sont pris en charge dans Go (meilleures performances), TypeScript/JavaScript (via Deno) et Lua (historique). TypeScript est le choix recommandé pour les nouveaux projets.
// 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
La fonctionnalité la plus puissante de Nakama pour les jeux multijoueurs compétitifs etmatch faisant autorité: une boucle de jeu qui s'exécute entièrement côté serveur, traite les entrées du client et calcule l'état faisant autorité dans le jeu. Il n'y a pas de prédiction client native côté Nakama, mais vous pouvez l'implémenter dans votre client et réconciliez-le avec l’état du serveur.
// 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. Déploiement Nakama sur Kubernetes
Nakama est conçu pour évoluer horizontalement sur Kubernetes. Utiliser CockroachDB comme base de données distribué (compatible avec PostgreSQL) pour la cohérence des données entre les nœuds, et Redis pour mise en cache et communication inter-nœuds en temps réel.
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 : le cadre de matchmaking de Google
Open Match a été développé par Google comme un système de mise en relation évolutif et modulaire pour les jeux. avec des millions de joueurs. Son architecture repose sur trois composants distincts qui communiquent via gRPC, vous permettant de personnaliser uniquement la partie qui vous intéresse (la logique de correspondance) sans avoir à réécrire toute l’infrastructure.
Componenti Open Match
| Componente | Responsabilità | Customizzabile |
|---|---|---|
| Frontend | Reçoit les demandes de mise en relation des clients (création de tickets) | No (core) |
| Backend | Gère le cycle de récupération, de correspondance et d'attribution du directeur | No (core) |
| Match Function | Algorithme de correspondance : regroupe les billets en matchs | Si - MUST implement |
| Evaluator | Résout les conflits entre les matchs proposés (un ticket dans plusieurs matchs) | 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 et Open Match se complètent parfaitement : Nakama gère l'authentification, le stockage social, présence et chat, tandis qu'Open Match gère le matchmaking évolutif. L'intégration se produit via un RPC Nakama qui crée un ticket Open Match et un webhook Open Match qui informe Nakama de l'affectation du serveur.
// 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 ou géré : quand choisir quoi
Le choix entre des stacks open source (Nakama + Open Match) et des solutions managées (PlayFab, GameLift, Lootlocker) dépend de plusieurs facteurs. Il n’y a pas de réponse universelle.
Comparaison 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) | Élevé (données Azure/AWS) |
| Customizzazione | Totale (codice sorgente disponibile) | Limité aux API exposées |
| Time to Market | Semaines (mise en place + apprentissage) | Giorni (wizard + template) |
| Scaling automatico | Manuale (Kubernetes HPA) | Automatico e gestito |
| Compliance (GDPR) | Contrôle total des données | Dipende dal provider |
| Community/Support | Open-source community, Discord | SLA garantito, enterprise support |
Guide pour le Choix
- Choisissez Open-Source si : Vous disposez d'une équipe DevOps compétente, vous attendez 500K+ MAU, vous avez des exigences de conformité strictes (RGPD, réglementation des jeux), ou vous souhaitez des personnalisations approfondies de la logique d'appariement/stockage.
- Choisissez Géré si : Vous êtes un studio indépendant avec une petite équipe, vous souhaitez réaliser des prototypes rapide, ou vous êtes en phase d’early access sans certitude sur les volumes.
- Stratégie hybride : Commencez par réussir à réduire les risques avant le lancement, migrez en open-source après validation du jeu et des volumes.
Conclusions
Nakama et Open Match représentent l’état de l’art des backends de jeux open source en 2025. Nakama gère la complexité des fonctionnalités sociales et multijoueurs avec une API élégante un système de hooks d'exécution qui permet des personnalisations illimitées. Open Match apporte monde open source un matchmaking évolutif et modulaire, avec la possibilité de mettre en œuvre n’importe quel algorithme d’appariement.
La clé du succès avec ces outils est une compréhension approfondie de Kubernetes pour le déploiement et la possibilité d'écrire une logique d'exécution robuste dans TypeScript ou Go. Investir du temps dans cette connaissance : les économies par rapport aux solutions managées sont significatives, et le la liberté architecturale dont vous bénéficiez est inestimable lorsque le jeu évolue.
Prochaines Etapes de la Série Game Backend
- Articolo precedente: Architecture anti-triche : autorité du serveur
- Articolo successivo: LiveOps: Event System e Feature Flag
- Informations complémentaires : Orchestration du serveur de jeux avec GameLift et Agones







