Open Match e Nakama: Game Backend Open-Source
Construir um back-end de jogo do zero é um projeto que pode levar anos e dezenas de engenheiros. PlayFab, GameSparks, GameLift: soluções gerenciadas em nuvem oferecem tudo pronto, mas a um custo que para muitos estúdios independentes e pequenas empresas são proibitivos, com a dependência do fornecedor dificultando a mudança de direção uma vez realizado. Existe uma terceira via: frameworks de código aberto.
Nakama por Heroic Labs e Partida Aberta Google são dois projetos que mudaram as regras do jogo no setor. Nakama é um servidor de jogos sociais completo e multijogador em tempo real, com mais de 1 trilhão de solicitações processadas por mês por sua comunidade. Open Match é uma estrutura de matchmaking flexível usada pelo Google Stadia (RIP) e muitos AAAs estúdios. Ambos são licenciados pelo Apache 2.0, escritos em Go para desempenho máximo e projetados para rodar no Kubernetes.
Neste artigo exploraremos ambos em profundidade: arquitetura interna, implantação em Kubernetes, personalização com TypeScript/Go, integração entre os dois e um estudo de caso de jogo completo multijogador construído inteiramente em pilha de código aberto.
O que Voce Aprendera
- Arquitetura Nakama: recursos principais, armazenamento, tempo real, social
- Nakama custom server logic in TypeScript e Go (runtime hooks)
- Implantação Nakama no Kubernetes com CockroachDB e Redis
- Architettura Open Match: director, matchfunction, evaluator
- Implementação de matchfunction personalizada com Glicko-2
- Integração Nakama + Open Match para sistema completo
- Comparação com soluções gerenciadas: quando escolher o que
- Monitoramento e observabilidade da pilha de código aberto
1. Nakama: o servidor de código aberto para jogos sociais
Nakama nasceu em 2017 pela Heroic Labs com um objetivo claro: fornecer um servidor a qualquer estúdio jogo com todos os recursos sociais e multijogador que antes exigiam meses de desenvolvimento personalizado. O resultado é um servidor Go de altíssimo desempenho com uma API declarativa e um sistema ganchos de tempo de execução que permitem personalizar cada comportamento sem modificar o código principal.
Nakama Core Features
| Categoria | Feature | Detalhe |
|---|---|---|
| Auth | Multi-provider auth | Email, device ID, Apple, Google, Facebook, Steam, custom |
| Storage | Object storage | Armazenamento JSON para jogadores com ACLs granulares (proprietário/público/amigos) |
| Real-Time | Relayed multiplayer | Retransmissão de partida com autorização do servidor, máximo de 64 jogadores/partida |
| 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 | Bate-papo individual, em grupo, baseado em sala, com persistência de mensagens |
| Competitivo | Leaderboards | Global, amigos, sazonal, com proprietário de pontuação e metadados |
| Competitivo | Tournaments | Torneios cronometrados com prêmios, autorização de participação |
| Economy | Wallet | Carteira multimoeda com transações atômicas |
| Notifications | In-app notifications | Notificações push com persistência e status lido/não lido |
2. Nakama Runtime Hooks: Customizzazione in TypeScript
O sistema de tempo de execução de ganchos é o recurso mais poderoso do Nakama. Ele permite que você intercepte qualquer evento do servidor (autenticação, gravação de armazenamento, chamadas RPC personalizadas) e adicionar lógica personalizado. Os tempos de execução são suportados em Go (melhor desempenho), TypeScript/JavaScript (via Deno) e Lua (histórico). TypeScript é a escolha recomendada para novos projetos.
// 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
O recurso mais poderoso do Nakama para jogos multijogador competitivos ecorrespondência oficial: um loop de jogo que gira interamente para o servidor, processa a entrada do cliente e calcula o estado autoridade do jogo. Não há previsão de cliente nativo do lado Nakama, mas você pode implementá-la no seu cliente e reconciliá-lo com o estado do 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. Implantação Nakama no Kubernetes
Nakama foi projetado para ser dimensionado horizontalmente no Kubernetes. Use CockroachDB como banco de dados distribuído (compatível com PostgreSQL) para consistência de dados entre nós e Redis para cache e comunicação entre nós em tempo 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. Partida aberta: estrutura de matchmaking do Google
Open Match foi desenvolvido pelo Google como um sistema de matchmaking escalável e modular para jogos com milhões de jogadores. Sua arquitetura é baseada em três componentes separados que comunicam através do gRPC, permitindo personalizar apenas a parte que lhe interessa (a lógica de correspondência) sem ter que reescrever toda a infraestrutura.
Componenti Open Match
| Componente | Responsabilità | Customizzabile |
|---|---|---|
| Frontend | Recebe solicitações de matchmaking de clientes (criação de tickets) | No (core) |
| Backend | Gerencia o ciclo de busca-correspondência-atribuição do Diretor | No (core) |
| Match Function | Algoritmo de correspondência: agrupa tickets em partidas | Si - MUST implement |
| Evaluator | Resolve conflitos entre partidas propostas (um ticket em múltiplas partidas) | 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 e Open Match complementam-se perfeitamente: Nakama gerencia autenticação, armazenamento social, presença e bate-papo, enquanto o Open Match lida com matchmaking escalonável. A integração acontece por meio de um RPC Nakama que cria um ticket Open Match e um webhook Open Match que notifica Nakama da atribuição do 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 aberto versus gerenciado: quando escolher o quê
A escolha entre pilhas de código aberto (Nakama + Open Match) e soluções gerenciadas (PlayFab, GameLift, Lootlocker) depende de vários fatores. Não existe uma resposta universal.
Comparacao 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 (dados do Azure/AWS) |
| Customizzazione | Totale (codice sorgente disponibile) | Limitado a APIs expostas |
| Time to Market | Semanas (configuração + aprendizagem) | Giorni (wizard + template) |
| Scaling automatico | Manuale (Kubernetes HPA) | Automatico e gestito |
| Compliance (GDPR) | Controle total de dados | Dipende dal provider |
| Community/Support | Open-source community, Discord | SLA garantito, enterprise support |
Guia para a Escolha
- Escolha código aberto se: Você tem uma equipe DevOps competente, espera mais de 500 mil MAU, você tem requisitos de conformidade rígidos (GDPR, regulamentação de jogos) ou deseja personalizações profundas da lógica de correspondência/armazenamento.
- Escolha Gerenciado se: Você é um estúdio independente com uma equipe pequena e deseja criar um protótipo rápido ou você está na fase de acesso inicial sem certeza sobre os volumes.
- Estratégia híbrida: Comece gerenciado para reduzir o risco de pré-lançamento, migre para código aberto após validar o jogo e os volumes.
Conclusoes
Nakama e Open Match representam o que há de mais moderno em back-end de jogos de código aberto em 2025. Nakama lida com a complexidade dos recursos sociais e multijogador com uma API elegante um sistema de ganchos de tempo de execução que permite personalizações ilimitadas. Open Match traz mundo de código aberto um matchmaking escalável e modular, com possibilidade de implementação qualquer algoritmo de emparelhamento.
A chave para o sucesso com essas ferramentas é um conhecimento profundo do Kubernetes para o implantação e a capacidade de escrever lógica de tempo de execução robusta em TypeScript ou Go. Invista tempo neste conhecimento: as economias em comparação com soluções gerenciadas são significativas, e o a liberdade arquitetônica que você obtém não tem preço quando o jogo é dimensionado.
Proximos Passos na Serie Game Backend
- Articolo precedente: Arquitetura Anti-Cheat: Autoridade do Servidor
- Articolo successivo: LiveOps: Event System e Feature Flag
- Mais informações: Orquestração de servidores de jogos com GameLift e Agones







