オープンマッチとナカマ: オープンソースのゲームバックエンド
ゲーム バックエンドをゼロから構築するのは、数年と数十人のエンジニアがかかるプロジェクトです。プレイファブ、 GameSparks、GameLift: マネージド クラウド ソリューションは、すべての準備が整っていますが、多くの人にとってコストがかかります。 インディースタジオや小規模企業は法外な価格であり、ベンダーロックインにより方向転換が困難になっている 一度引き受けた。 3 番目の方法は、オープンソース フレームワークです。
なかま Heroic Labs e 著 オープン戦 Google には 2 つのプロジェクトがあります このセクターのゲームのルールを変えました。 NAKAMA は完全なソーシャル ゲーム サーバーです リアルタイム マルチプレイヤーでは、コミュニティによって毎月 1 兆を超えるリクエストが処理されます。 Open Match は、Google Stadia (RIP) および多くの AAA で使用される柔軟なマッチメイキング フレームワークです。 スタジオ。どちらも Apache 2.0 ライセンスを取得し、パフォーマンスを最大化するために Go で書かれ、設計されています。 Kubernetes 上で実行します。
この記事では、内部アーキテクチャ、Kubernetes デプロイ、 TypeScript/Go によるカスタマイズ、両者の統合、完全なゲーム ケース スタディ マルチプレイヤーは完全にオープンソース スタック上に構築されています。
何を学ぶか
- nakama アーキテクチャ: コア機能、ストレージ、リアルタイム、ソーシャル
- TypeScript と Go のnakama カスタム サーバー ロジック (ランタイム フック)
- CockroachDB と Redis を使用した Kubernetes への NAKAMA のデプロイ
- Open Match アーキテクチャ: ディレクター、マッチ関数、エバリュエーター
- Glicko-2 を使用したカスタム一致関数の実装
- ナカマ + オープンマッチのフルシステム統合
- マネージド ソリューションとの比較: いつ何を選択するか
- オープンソーススタックの監視と可観測性
1. ナカマ: ソーシャル ゲーム用のオープンソース サーバー
nakama は、あらゆるスタジオにサーバーを提供するという明確な目標を掲げ、Heroic Labs によって 2017 年に誕生しました。 以前は数か月のカスタム開発が必要だったすべてのソーシャル機能とマルチプレイヤー機能を備えたゲームです。 その結果、宣言型 API とシステムを備えた非常に高性能な Go サーバーが完成しました。 実行時フックを使用すると、コア コードを変更せずに各動作をカスタマイズできます。
なかまコアの特徴
| カテゴリ | 特徴 | 詳細 |
|---|---|---|
| 認証 | マルチプロバイダー認証 | 電子メール、デバイス ID、Apple、Google、Facebook、Steam、カスタム |
| ストレージ | オブジェクトストレージ | 詳細な ACL を持つプレーヤー用の JSON ストア (オーナー/パブリック/フレンド) |
| リアルタイム | 中継マルチプレイヤー | サーバー側認証による試合リレー、最大 64 プレイヤー/試合 |
| リアルタイム | 権威のある一致 | Go/TypeScript/Lua でカスタマイズ可能なサーバー側のゲーム ループ |
| 社交 | フレンドシステム | 友達の追加/削除、ブロックリスト、プレゼンス (オンライン/オフライン/外出中) |
| 社交 | チャット | 1 対 1、グループ チャット、ルームベース、メッセージ永続性あり |
| 競争力 | リーダーボード | グローバル、フレンド、季節、スコア所有者とメタデータ付き |
| 競争力 | トーナメント | 賞品付きの時間制限付きトーナメント、参加許可 |
| 経済 | 財布 | アトミックトランザクションを備えた多通貨ウォレット |
| 通知 | アプリ内通知 | 永続性と既読/未読ステータスを備えたプッシュ通知 |
2.nakama ランタイムフック: TypeScript でのカスタマイズ
フック ランタイム システムは、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 によって開発されました 何百万人ものプレイヤーと一緒に。そのアーキテクチャは、通信する 3 つの個別のコンポーネントに基づいています。 gRPC を使用すると、興味のある部分 (マッチング ロジック) のみをカスタマイズできます。 インフラストラクチャ全体を書き直す必要はありません。
オープンマッチコンポーネント
| 成分 | 責任 | カスタマイズ可能 |
|---|---|---|
| フロントエンド | クライアントからのマッチメイキングリクエストの受信(チケット作成) | いいえ(コア) |
| バックエンド | Director のフェッチ、マッチ、割り当てサイクルを管理します。 | いいえ(コア) |
| マッチ関数 | マッチングアルゴリズム: チケットをマッチにグループ化します。 | はい - 必ず実装しなければなりません |
| 評価者 | 提案された試合間の競合を解決します (複数の試合の 1 つのチケット) | はい - オプション |
| 監督 | オーケストレーター: 一致関数の呼び出し、サーバーの割り当て、通知 | はい - 必ず実装しなければなりません |
// 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 はスケーラブルなマッチメイキングを処理します。統合が起こる オープン マッチ チケットを作成するnakama RPC と、nakama に通知するオープン マッチ Webhook 経由 サーバー割り当ての。
// 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/月のライセンス + インフラ |
| ベンダーロックイン | なし (Apache 2.0) | 高 (Azure/AWS データ) |
| カスタマイズ | 合計 (ソースコードが利用可能) | 公開された API に限定される |
| 市場投入までの時間 | 数週間 (セットアップ + 学習) | 日数 (ウィザード + テンプレート) |
| 自動スケーリング | マニュアル (Kubernetes HPA) | 自動および管理 |
| コンプライアンス (GDPR) | トータルデータコントロール | プロバイダーによって異なります |
| コミュニティ/サポート | オープンソース コミュニティ、Discord | SLA保証、エンタープライズサポート |
選択のガイドライン
- 次の場合はオープンソースを選択してください。 有能な DevOps チームがあり、50 万以上の MAU が期待されています。 厳格なコンプライアンス要件 (GDPR、ゲーム規制) がある場合、または詳細なカスタマイズが必要な場合 マッチング/ストレージロジックの。
- 次の場合は「管理対象」を選択します。 あなたは小規模なチームを持つインディー スタジオで、プロトタイプを作成したいと考えています。 または、ボリュームに対する確実性がない早期アクセス段階にあります。
- ハイブリッド戦略: 導入前のリスクを軽減するために管理から開始し、移行する ゲームとボリュームを検証した後、オープンソースにします。
結論
nakama と Open Match は、2025 年のオープンソース ゲーム バックエンドの最先端技術を表しています。 ナカマは洗練された API を使用して複雑なソーシャル機能とマルチプレイヤーに対応します 無制限のカスタマイズを可能にするランタイムフックシステム。オープンマッチの持ち込み オープンソースの世界では、スケーラブルでモジュール式のマッチメイキングを実装する可能性があります。 任意のペアリングアルゴリズム。
これらのツールを使用して成功する鍵は、Kubernetes を深く理解していることです。 導入と、TypeScript または Go で堅牢なランタイム ロジックを作成する機能。時間を投資する この知識によれば、マネージド ソリューションと比較して大幅な節約が可能であり、 ゲームのスケールが大きくなると、アーキテクチャ上の自由度は非常に貴重になります。
ゲーム バックエンド シリーズの次のステップ
- 前の記事: アンチチートアーキテクチャ: サーバー権限
- 次の記事: LiveOps: イベント システムと機能フラグ
- 詳細情報: GameLift と Agones によるゲームサーバーオーケストレーション







