오픈 매치와 나카마: 오픈 소스 게임 백엔드
게임 백엔드를 처음부터 구축하는 것은 수년과 수십 명의 엔지니어가 걸릴 수 있는 프로젝트입니다. 플레이팹, GameSparks, GameLift: 관리형 클라우드 솔루션은 모든 것을 준비되어 있지만 많은 비용으로 제공합니다. 인디 스튜디오와 소규모 회사는 금지되어 있으며 공급업체에 종속되어 방향을 바꾸기가 어렵습니다. 일단 착수했습니다. 세 번째 방법은 오픈 소스 프레임워크입니다.
나카마 작성자: Heroic Labs e 공개 경기 구글은 두 개의 프로젝트이다 이는 해당 부문의 게임 규칙을 변경했습니다. Nakama는 완전한 소셜 게임 서버입니다. 커뮤니티에서 매월 1조 개가 넘는 요청을 처리하는 실시간 멀티플레이어입니다. Open Match는 Google Stadia(RIP) 및 많은 AAA에서 사용되는 유연한 매치메이킹 프레임워크입니다. 스튜디오. 둘 다 Apache 2.0 라이센스가 있으며 최대 성능을 위해 Go로 작성되었으며 설계되었습니다. Kubernetes에서 실행합니다.
이 기사에서는 내부 아키텍처, Kubernetes 배포, TypeScript/Go를 사용한 사용자 정의, 둘 사이의 통합 및 전체 게임 사례 연구 전적으로 오픈 소스 스택을 기반으로 구축된 멀티플레이어입니다.
무엇을 배울 것인가
- Nakama 아키텍처: 핵심 기능, 스토리지, 실시간, 소셜
- TypeScript 및 Go의 Nakama 사용자 정의 서버 로직(런타임 후크)
- CockroachDB 및 Redis를 사용하여 Kubernetes에 Nakama 배포
- 오픈 매치 아키텍처: 디렉터, 매치 함수, 평가자
- Glicko-2를 사용한 사용자 정의 matchfunction 구현
- 전체 시스템을 위한 Nakama + Open Match 통합
- 관리형 솔루션과의 비교: 언제 무엇을 선택해야 하는지
- 오픈 소스 스택의 모니터링 및 관찰 가능성
1. Nakama: 소셜 게임을 위한 오픈 소스 서버
Nakama는 모든 스튜디오에 서버를 제공하겠다는 명확한 목표를 가지고 2017년 Heroic Labs에서 태어났습니다. 이전에는 몇 달에 걸쳐 맞춤 개발이 필요했던 모든 소셜 및 멀티플레이어 기능을 갖춘 게임입니다. 그 결과 선언적 API와 시스템을 갖춘 매우 고성능의 Go 서버가 탄생했습니다. 핵심 코드를 수정하지 않고도 각 동작을 사용자 정의할 수 있는 런타임 후크입니다.
나카마 핵심 기능
| 범주 | 특징 | 세부 사항 |
|---|---|---|
| 인증 | 다중 제공자 인증 | 이메일, 장치 ID, Apple, Google, Facebook, Steam, 사용자 정의 |
| 저장 | 객체 스토리지 | 세분화된 ACL(소유자/공개/친구)이 있는 플레이어를 위한 JSON 저장소 |
| 실시간 | 릴레이된 멀티플레이어 | 서버측 인증을 통한 매치 릴레이, 최대 64명의 플레이어/매치 |
| 실시간 | 신뢰할 수 있는 일치 | Go/TypeScript/Lua에서 사용자 정의 가능한 서버측 게임 루프 |
| 사회의 | 친구 시스템 | 친구 추가/제거, 차단 목록, 현재 상태(온라인/오프라인/외출) |
| 사회의 | 채팅 | 1:1, 그룹 채팅, 룸 기반, 메시지 지속성 포함 |
| 경쟁력 있는 | 리더보드 | 글로벌, 친구, 계절별, 점수 소유자 및 메타데이터 포함 |
| 경쟁력 있는 | 토너먼트 | 상품이 포함된 시간 제한 토너먼트, 참가 승인 |
| 경제 | 지갑 | 원자적 거래가 가능한 다중 통화 지갑 |
| 알림 | 인앱 알림 | 지속성 및 읽음/읽지 않음 상태가 포함된 푸시 알림 |
2. Nakama 런타임 후크: TypeScript의 사용자 정의
Hooks 런타임 시스템은 Nakama의 가장 강력한 기능입니다. 이를 통해 무엇이든 차단할 수 있습니다. 서버 이벤트(인증, 스토리지 쓰기, 사용자 정의 RPC 호출) 및 로직 추가 개인화. 런타임은 Go(최고 성능), TypeScript/JavaScript에서 지원됩니다. (Deno를 통해) 및 Lua(역사적). TypeScript는 새 프로젝트에 권장되는 선택입니다.
// 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. 권위 있는 일치: 서버 측 게임 루프
경쟁적인 멀티플레이어 게임을 위한 Nakama의 가장 강력한 기능과신뢰할 수 있는 일치: 완전히 서버 측에서 실행되고 클라이언트 입력을 처리하고 상태를 계산하는 게임 루프 게임의 권위자. 기본 Nakama 측 클라이언트 예측은 없지만 구현할 수 있습니다. 클라이언트에서 이를 서버 상태와 조정합니다.
// 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. Kubernetes에 Nakama 배포
Nakama는 Kubernetes에서 수평으로 확장되도록 설계되었습니다. CockroachDB를 데이터베이스로 사용 노드 간 데이터 일관성을 위해 분산(PostgreSQL과 호환)되고 Redis는 캐싱 및 실시간 노드 간 통신.
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. 공개 매치: Google의 매치메이킹 프레임워크
Open Match는 확장 가능한 모듈식 게임 매치메이킹 시스템으로 Google에서 개발했습니다. 수백만 명의 플레이어와 함께. 그 아키텍처는 통신하는 세 가지 개별 구성 요소를 기반으로 합니다. gRPC를 통해 관심 있는 부분(일치 논리)만 맞춤설정할 수 있습니다. 전체 인프라를 다시 작성할 필요 없이 말이죠.
오픈매치 구성요소
| 요소 | 책임 | 맞춤형 |
|---|---|---|
| 프런트엔드 | 클라이언트로부터 매치메이킹 요청을 받습니다(티켓 생성). | 아니요(핵심) |
| 백엔드 | 디렉터의 가져오기-일치-할당 주기를 관리합니다. | 아니요(핵심) |
| 일치 기능 | 일치 알고리즘: 티켓을 일치 항목으로 그룹화합니다. | 예 - 반드시 구현해야 합니다. |
| 평가자 | 제안된 일치 간의 충돌을 해결합니다(여러 일치 중 하나의 티켓). | 예 - 선택사항 |
| 감독 | Orchestrator: 통화 일치 기능, 서버 할당, 알림 | 예 - 반드시 구현해야 합니다. |
// 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. 나카마 + 오픈매치 통합
Nakama와 Open Match는 서로를 완벽하게 보완합니다. Nakama는 인증, 소셜 저장, 현재 상태 및 채팅을 지원하며 Open Match는 확장 가능한 매치메이킹을 처리합니다. 통합이 발생합니다 Open Match 티켓을 생성하는 Nakama RPC와 Nakama에게 알리는 Open Match 웹훅을 통해 서버 할당 중.
// 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. 오픈 소스와 관리형: 언제 무엇을 선택해야 할까요?
오픈 소스 스택(Nakama + Open Match)과 관리형 솔루션(PlayFab, GameLift, Lootlocker)는 여러 요인에 따라 달라집니다. 보편적인 대답은 없습니다.
자세한 비교
| 표준 | 오픈 소스(나카마) | 관리형(PlayFab) |
|---|---|---|
| 비용(100만 MAU) | ~$5,000/월 인프라 | ~$15,000/월 라이선스 + 인프라 |
| 공급업체 종속 | 없음(아파치 2.0) | 높음(Azure/AWS 데이터) |
| 맞춤화 | 전체(소스코드 있음) | 노출된 API로 제한됨 |
| 출시 시간 | 주(설정 + 학습) | 일수(마법사 + 템플릿) |
| 자동 확장 | 매뉴얼(Kubernetes HPA) | 자동 및 관리형 |
| 규정 준수(GDPR) | 전체 데이터 관리 | 제공업체에 따라 다릅니다. |
| 커뮤니티/지원 | 오픈소스 커뮤니티, Discord | 보장된 SLA, 기업 지원 |
선택 지침
- 다음과 같은 경우 오픈 소스를 선택하세요. 유능한 DevOps 팀이 있고 MAU가 50만 명 이상일 것으로 예상합니다. 엄격한 규정 준수 요구 사항(GDPR, 게임 규정)이 있거나 심층적인 사용자 정의를 원하는 경우 일치/저장 논리.
- 다음과 같은 경우 관리형을 선택하세요. 당신은 소규모 팀으로 구성된 인디 스튜디오이고 프로토타입을 만들고 싶습니다. 빠르거나 볼륨에 대한 확신이 없는 초기 액세스 단계에 있습니다.
- 하이브리드 전략: 출시 전 위험을 줄이기 위해 관리형으로 시작하고 마이그레이션 게임과 볼륨을 검증한 후 오픈 소스로 전환합니다.
결론
Nakama와 Open Match는 2025년 오픈소스 게임 백엔드의 최첨단 기술을 대표합니다. Nakama는 우아한 API를 사용하여 소셜 기능과 멀티플레이어의 복잡성을 처리합니다. 무제한 사용자 정의를 허용하는 런타임 후크 시스템입니다. 오픈매치 도입 오픈 소스 세계에서는 구현 가능성이 있는 확장 가능한 모듈식 매치메이킹이 가능합니다. 모든 페어링 알고리즘.
이러한 도구를 사용하여 성공하는 열쇠는 Kubernetes에 대한 깊은 이해입니다. 배포 및 TypeScript 또는 Go에서 강력한 런타임 논리를 작성하는 기능. 시간을 투자하세요 이를 통해 관리형 솔루션에 비해 비용 절감 효과가 상당하며 게임이 확장되면 얻을 수 있는 건축적 자유는 매우 귀중합니다.
게임 백엔드 시리즈의 다음 단계
- 이전 기사: 치트 방지 아키텍처: 서버 권한
- 다음 기사: LiveOps: 이벤트 시스템 및 기능 플래그
- 추가 정보: GameLift 및 Agones를 사용한 게임 서버 오케스트레이션







