Open Match e Nakama: Game Backend Open-Source
Crear un backend de juego desde cero es un proyecto que puede llevar años y decenas de ingenieros. PlayFab, GameSparks, GameLift: las soluciones gestionadas en la nube ofrecen todo listo pero a un coste que para muchos Los estudios independientes y las pequeñas empresas son prohibitivos, y la dependencia de los proveedores dificulta el cambio de dirección. una vez emprendido. Hay una tercera vía: marcos de código abierto.
Nakama por Heroic Labs e Partido abierto Google son dos proyectos que han cambiado las reglas del juego en el sector. Nakama es un completo servidor de juegos sociales y multijugador en tiempo real, con más de 1 billón de solicitudes por mes procesadas por su comunidad. Open Match es un marco de emparejamiento flexible utilizado por Google Stadia (RIP) y muchos AAA estudios. Ambos tienen licencia Apache 2.0, están escritos en Go para obtener el máximo rendimiento y están diseñados para ejecutarse en Kubernetes.
En este artículo exploraremos ambos en profundidad: arquitectura interna, implementación en Kubernetes, personalización con TypeScript/Go, integración entre los dos y un estudio de caso de juego completo Multijugador construido íntegramente en una pila de código abierto.
lo que aprendes
- Arquitectura Nakama: características principales, almacenamiento, tiempo real, social
- Nakama custom server logic in TypeScript e Go (runtime hooks)
- Implementación de Nakama en Kubernetes con CockroachDB y Redis
- Architettura Open Match: director, matchfunction, evaluator
- Implementación personalizada de función de coincidencia con Glicko-2
- Integración de Nakama + Open Match para un sistema completo
- Comparación con soluciones gestionadas: cuándo elegir qué
- Monitoreo y observabilidad de la pila de código abierto.
1. Nakama: el servidor de código abierto para juegos sociales
Nakama nace en 2017 de la mano de Heroic Labs con un objetivo claro: dotar a cualquier estudio de un servidor juego con todas las funciones sociales y multijugador que anteriormente requerían meses de desarrollo personalizado. El resultado es un servidor Go de extremadamente alto rendimiento con una API declarativa y un sistema. ganchos de tiempo de ejecución que le permiten personalizar cada comportamiento sin modificar el código principal.
Nakama Core Features
| Categoría | Feature | Detalle |
|---|---|---|
| Auth | Multi-provider auth | Email, device ID, Apple, Google, Facebook, Steam, custom |
| Storage | Object storage | Tienda JSON para jugadores con ACL granulares (propietario/público/amigos) |
| Real-Time | Relayed multiplayer | Retransmisión de partidos con autorización del lado del servidor, máximo 64 jugadores/partido |
| 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 a 1, chat grupal, basado en salas, con persistencia de mensajes |
| Competitivo | Leaderboards | Global, amigos, estacional, con propietario de puntuación y metadatos |
| Competitivo | Tournaments | Torneos cronometrados con premios, autorización de participación |
| Economy | Wallet | Monedero multidivisa con transacciones atómicas |
| Notifications | In-app notifications | Notificaciones push con persistencia y estado leído/no leído |
2. Nakama Runtime Hooks: Customizzazione in TypeScript
El sistema de tiempo de ejecución de ganchos es la característica más poderosa de Nakama. Te permite interceptar cualquier evento del servidor (autenticación, escritura de almacenamiento, llamadas RPC personalizadas) y agregar lógica personalizado. Los tiempos de ejecución son compatibles con Go (mejor rendimiento), TypeScript/JavaScript (vía Deno) y Lua (histórico). TypeScript es la opción recomendada para nuevos proyectos.
// 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 característica más poderosa de Nakama para juegos multijugador competitivos ypartido autorizado: un bucle de juego que se ejecuta completamente en el lado del servidor, procesa las entradas del cliente y calcula el estado autoridad del juego. No existe una predicción nativa del cliente del lado de Nakama, pero puedes implementarla en su cliente y conciliarlo con el estado del servidor.
// 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. Implementación de Nakama en Kubernetes
Nakama está diseñado para escalar horizontalmente en Kubernetes. Utilice CockroachDB como base de datos distribuido (compatible con PostgreSQL) para la coherencia de los datos entre nodos, y Redis para Almacenamiento en caché y comunicación entre nodos en tiempo real.
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: el marco de emparejamiento de Google
Open Match fue desarrollado por Google como un sistema de emparejamiento modular y escalable para juegos con millones de jugadores. Su arquitectura se basa en tres componentes separados que se comunican a través de gRPC, permitiéndole personalizar solo la parte que le interesa (la lógica de coincidencia) sin tener que reescribir toda la infraestructura.
Componenti Open Match
| Componente | Responsabilità | Customizzabile |
|---|---|---|
| Frontend | Recibe solicitudes de emparejamiento de clientes (creación de tickets) | No (core) |
| Backend | Gestiona el ciclo de búsqueda, coincidencia y asignación del director. | No (core) |
| Match Function | Algoritmo de coincidencia: agrupa entradas en partidos | Si - MUST implement |
| Evaluator | Resuelve conflictos entre partidos propuestos (un boleto en múltiples partidos) | 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 y Open Match se complementan perfectamente: Nakama gestiona la autenticación, el almacenamiento social, presencia y chat, mientras que Open Match maneja el emparejamiento escalable. La integración ocurre a través de un RPC de Nakama que crea un ticket de Open Match y un webhook de Open Match que notifica a Nakama de la asignación del servidor.
// 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. Código abierto versus administrado: cuándo elegir qué
La elección entre pilas de código abierto (Nakama + Open Match) y soluciones administradas (PlayFab, GameLift, Lootlocker) depende de varios factores. No existe una respuesta universal.
Comparacion 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) | Alto (datos de Azure/AWS) |
| Customizzazione | Totale (codice sorgente disponibile) | Limitado a API expuestas |
| Time to Market | Semanas (configuración + aprendizaje) | Giorni (wizard + template) |
| Scaling automatico | Manuale (Kubernetes HPA) | Automatico e gestito |
| Compliance (GDPR) | Control total de datos | Dipende dal provider |
| Community/Support | Open-source community, Discord | SLA garantito, enterprise support |
Guía para las elecciones
- Elija código abierto si: Tiene un equipo de DevOps competente, espera más de 500.000 MAU, tiene requisitos de cumplimiento estrictos (GDPR, regulación de juegos) o desea personalizaciones profundas de la lógica de coincidencia/almacenamiento.
- Elija Administrado si: Eres un estudio independiente con un equipo pequeño y quieres crear un prototipo. rápido, o estás en la fase de acceso temprano sin certeza sobre los volúmenes.
- Estrategia híbrida: Comience con administrado para reducir el riesgo previo al lanzamiento, migre a código abierto después de validar el juego y los volúmenes.
Conclusiones
Nakama y Open Match representan el estado del arte de los backends de juegos de código abierto en 2025. Nakama maneja la complejidad de las funciones sociales y multijugador con una API elegante un sistema de ganchos en tiempo de ejecución que permite personalizaciones ilimitadas. Partido Abierto trae mundo de código abierto un matchmaking escalable y modular, con posibilidad de implementar cualquier algoritmo de emparejamiento.
La clave del éxito con estas herramientas es una comprensión profunda de Kubernetes para el implementación y la capacidad de escribir una lógica de tiempo de ejecución sólida en TypeScript o Go. invertir tiempo en este conocimiento: los ahorros en comparación con las soluciones gestionadas son significativos, y el La libertad arquitectónica que se obtiene no tiene precio cuando el juego escala.
Próximos pasos en la serie Backend del juego
- Articolo precedente: Arquitectura antitrampas: autoridad del servidor
- Articolo successivo: LiveOps: Event System e Feature Flag
- Más información: Orquestación del servidor de juegos con GameLift y Agones







