70万人のプレイヤーがインディーマルチプレイヤーゲームに同時に押し寄せたら何が起きるか(そして生き残る方法)
要点まとめ
70万人同時接続のバイラルヒットがバックエンドに与える衝撃と、P2Pアーキテクチャで生き残るための具体的な設計パターンを解説する。
インディー開発者なら誰でも、一夜にしてバイラルヒットすることを夢見たことがあるだろう。あなたのゲームがTwitchで爆発的に広まり、Steamの同時接続数が1週間で200から20万に跳ね上がり、突然業界の話題の中心になる。しかし、その幻想の中で誰も教えてくれないのは、午前3時のバックエンドの姿だ——マッチメイキングサービスが炎上し、ロビーデータベースで書き込み競合エラーが発生し、Discordには1試合も接続できないプレイヤーが溢れている。
これは仮定の話ではない。2022年後半に『Goose Goose Duck』がバイラルヒットしたとき、Gaggle Studios——自ら「大きなヒット作の経験がない少人数チーム」と称するスタジオ——は、同時接続プレイヤーが70万人を超えるのを目の当たりにした。彼らのバックエンドは持ちこたえた。無限のリソースがあったからではない。トラフィックの急増を乗り切るために、初期段階で特定のアーキテクチャ上の決定を下したからだ。
この記事では、その決定が具体的に何だったのか、マルチプレイヤーゲームがバイラルヒットしたときに最初に何が壊れるのか、そしてトラフィックが到来する前に自分のプロジェクトに適用できる具体的なパターンを解説する。
バイラルなマルチプレイヤー急増の解剖学
実際に壊れるもの(順番に)
マルチプレイヤーゲームが想定負荷の10倍~100倍を記録すると、障害は予測可能な順序で連鎖する。この順序を理解することは極めて重要である。なぜなら、正しい順序でシステムを強化する必要があるからだ。
1. 認証とログイン(最初の48時間で通常負荷の5~15倍)
プレイするすべてのプレイヤーは、まず認証を通過しなければならない。Steam認証のゲームでは、Steamのバックエンドが重い処理を担うが、あなたのサーバーでもチケットの検証、プレイヤープロファイルの作成/取得、セッショントークンの返却が必要だ。各認証リクエストがプライマリデータベースに触れるなら、問題が発生する。毎分5万件のログインリクエストのバーストが、それぞれセッション作成のためにPostgreSQLに書き込むと、90秒以内にコネクションプールが飽和する。
2. ロビー探索とマッチメイキング(通常負荷の10~50倍)
これが実際にプレイヤー体験を台無しにする最初のドミノだ。20万のプレイヤーが同時にロビーを閲覧するとき、ロビー一覧のクエリパターンは「毎秒数百の読み取り」から「毎秒数万の読み取り」に変わる。ロビーの状態がプライマリリレーショナルデータベースに保存されている場合、レプリカの遅延に追いつけず、既に満室の部屋を空きありと表示する古いロビーデータを返すことになる。
3. ロビー作成と参加操作(書き込み集中の急増)
新しいゲームロビーはすべて書き込みだ。プレイヤーがロビーに参加するたびに書き込み(プレイヤーリストの更新)、離脱するたびに書き込みが発生する。Goose Goose Duckのピークトラフィックでは、毎秒数千件のロビー状態変更を意味した。Gaggle Studiosは実際のゲームプレイにP2Pモデルを使用していたが、ロビー調整には依然として集中管理が必要だった——プレイヤー同士が直接接続する前に、お互いを見つける必要があるからだ。
4. NAT越えとP2P接続の確立
ここがP2Pアーキテクチャの天井にぶつかるポイントだ。STUN/TURNインフラストラクチャがあっても、P2P接続は失敗する。業界平均のP2P接続成功率(リレーフォールバックなし)は、プレイヤーペアの約75~85%だ。残りの15~25%はTURNリレーサーバーを必要とする。70万人の同時接続プレイヤーが毎秒数千の接続を試みる状況では、ほとんどのインディーチームが構築したことのないリレーインフラが求められる。
P2Pが正しい選択だった理由(そしてそうでなくなった理由)
Gaggle Studiosは、Goose Goose Duckの実際のゲームプレイにP2Pを選択した。1セッションあたり2~16人のソーシャルディダクションゲームにとって、これは正しい判断だった。その理由と、トレードオフがどこで発生するかを説明する。
P2Pのコストモデル
計算してみよう。16人対戦を15分間、控えめなクラウドインスタンス(共有vCPUで約$0.04/時間)でホストする専用サーバーアーキテクチャでは、1試合あたり約$0.01のコストがかかる。ピーク時に50万試合が同時に行われるとすれば、コンピュートだけで毎時$5,000、つまり1日$120,000になる。
P2Pはそのコンピュートコストをホストプレイヤーのマシンに移す。インフラコストは調整レイヤー(マッチメイキングサーバー、ロビー状態、認証、STUN/TURNリレー)に低下する。Goose Goose Duckの場合、プレイヤー数が急増してもインフラ請求額を管理可能な範囲に抑えることができた。
P2Pの信頼性の天井
しかし、P2Pには専用サーバーにはない障害モードが存在する。
- ホスト移行: ホストプレイヤーが切断すると、セッションは別のピアに権限を移譲しなければならない。ソーシャルディダクションゲームでは、ホスト移行に失敗すると投票状態の喪失、役職割り当ての同期ずれ、試合の破綻を招く。典型的なホスト移行の流れは次のようになる。
// 簡略化されたP2Pホスト移行ロジック
// 現在のホストに到達不能になった場合
void OnHostUnreachable(float timeoutSeconds = 3.0f) {
// 1. すべてのピアがハートビートタイムアウトでホスト切断を検出
// 2. 各ピアが独立して新しいホストになるべきか評価
TArray<FPlayerInfo> remainingPeers = GetConnectedPeers();
FPlayerInfo newHost = SelectNewHost(remainingPeers); // 最低レイテンシ、最高帯域幅
if (newHost.PlayerId == GetLocalPlayerId()) {
// このピアが新しいホストになる
BecomeHost();
// ローカルキャッシュから権威あるゲーム状態を再構築
GameState = ReconstructFromLastKnownState();
// 他のすべてのピアに新しいホストへ接続するよう通知
BroadcastHostMigration(newHost.Address);
// ゲームプレイ再開 - 投票、タイマー、役職割り当てはこの移行を乗り越えなければならない
ResumeSessionWithReconciledState();
} else {
// 移行シグナルを待ち、新しいホストに接続
ConnectToNewHost(newHost.Address, timeoutSeconds);
}
}
これらの各ステップは潜在的な障害点である。2つのピアがどちらも自分がホストになるべきだと判断した場合(スプリットブレイン)、調整不可能な2つの異なるゲーム状態が生まれる。
NAT越えの失敗: 対称NATやキャリアグレードNATの背後にいるプレイヤーは直接接続を確立できない。TURNリレーインフラがこれらのプレイヤーを吸収しなければならない。大規模になると、70万人の同時接続プレイヤーの15%は10万5000人であり、リレートラフィックが必要となる。リレーの帯域幅は高価で、通常GBあたり$0.05~$0.10である。
チートへの脆弱性: ホストプレイヤーのマシンが権威ある存在となる。クライアント側のデータはすべて操作可能である。カジュアルなパーティーゲームでは、競争的なシューティングゲームほど致命的ではないが、それでも体験を損なう。サーバー権威型の設計(Fortniteのサーバー最適化提案で使われているようなもの)は、カテゴリ全体のエクスプロイトを排除するが、専用のコンピュートが必要になる。
マッチメイキングレイヤー:想定ピークの10倍に備える
このセクションは、ほとんどのインディー開発者が必要としつつも、手遅れになるまでスキップする部分だ。マッチメイキングシステムはゲームへの玄関である。遅ければプレイヤーは去る。壊れればプレイヤーは遊べない。
ロビー状態管理アーキテクチャ
Goose Goose Duckのロビーシステムは、大規模に次の操作を処理する必要があった。
- ロビーを閲覧(読み取り集中): プレイヤーが利用可能なロビーをフィルタリングして一覧表示
- ロビーを作成(書き込み): ゲーム設定、リージョン、定員を含む新しいロビーレコード
- ロビーに参加(条件付き書き込み): アトミック操作——定員を確認し、プレイヤーを追加するか、失敗させる
- ロビーを離脱(書き込み+削除の可能性): プレイヤーを削除、空ならロビーを削除
- ロビー設定を更新(書き込み): ホストがゲームパラメータを変更
以下は、負荷がかかると最も障害が発生しやすいアトミック参加操作を処理する、簡略化されたロビーマネージャーである。
import asyncio
from dataclasses import dataclass, field
from typing import Optional
import uuid
@dataclass
class Lobby:
lobby_id: str
host_id: str
max_players: int
players: list = field(default_factory=list)
region: str = "us-east"
game_settings: dict = field(default_factory=dict)
created_at: float = 0.0
class LobbyManager:
def __init__(self, cache_client, db_client):
self.cache = cache_client # Redisまたは同等品
self.db = db_client # PostgreSQLまたは同等品
self.MAX_LOBBIES_PER_REGION = 10000
self.LOBBY_TTL_SECONDS = 3600 # 古いロビーの自動クリーンアップ
async def join_lobby(self, lobby_id: str, player_id: str) -> dict:
"""
Redisの楽観的ロックを使用したアトミック参加操作。
同時に2人のプレイヤーが残り1枠のロビーに参加しようとする
競合状態を防ぐ。
"""
cache_key = f"lobby:{lobby_id}"
# Luaスクリプトを使用して、Redis内でアトミックなチェック・アンド・モディファイを実行
# これがクリティカルパスである——バイラル負荷下では、この単一の操作が
# 毎秒数千回実行される
lua_script = """
local key = KEYS[1]
local player_id = ARGV[1]
local max_players = tonumber(ARGV[2])
local lobby_data = redis.call('HGETALL', key)
if #lobby_data == 0 then
return {-1, "lobby_not_found"}
end
-- ハッシュからプレイヤー数を解析
local current_players = tonumber(redis.call('HGET', key, 'player_count'))
if current_players == nil then
return {-1, "corrupted_state"}
end
if current_players >= max_players then
return {0, "lobby_full"}
end
-- アトミックなインクリメントとプレイヤー追加
redis.call('HINCRBY', key, 'player_count', 1)
redis.call('SADD', key .. ':players', player_id)
redis.call('EXPIRE', key, 3600)
return {1, "joined"}
"""
result = await self.cache.eval(
lua_script,
keys=[cache_key],
args=[player_id, str(self.MAX_PLAYERS)]
)
status_code, message = result
if status_code == -1:
raise LobbyNotFoundException(message)
elif status_code == 0:
raise LobbyFullException(message)
# 永続DBへの非同期書き込み(非ブロッキング、ここでは結果整合性で十分)
asyncio.create_task(self._persist_join(lobby_id, player_id))
return {"status": "joined", "lobby_id": lobby_id}
async def _persist_join(self, lobby_id: str, player_id: str):
"""バックグラウンド永続化——参加に関する真実の情報源はRedis内のロビー状態。
DBはミリ秒単位で遅れるが、クリティカルパスにはならない。"""
await self.db.execute(
"UPDATE lobbies SET player_count = player_count + 1, "
"updated_at = NOW() WHERE lobby_id = $1",
lobby_id
)
await self.db.execute(
"INSERT INTO lobby_players (lobby_id, player_id, joined_at) "
"VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING",
lobby_id, player_id
)
ここで重要なのはRedis内のLuaスクリプトである。GETを実行し、アプリケーションコードで定員を確認し、POSTを行う単純な実装では、15人のプレイヤーが同時に16人用ロビーに参加しようとする競合ウィンドウが発生し、結果として17人のプレイヤーと破綻したゲームロジックを招く。LuaスクリプトはRedis内でアトミックに実行されるため、毎秒数千の操作でも競合状態や参加の消失は発生しない。
接続の引き継ぎ:ロビーからゲームプレイへ
ロビーが満杯になると、ゲームは集中管理されたロビー調整からP2Pゲームプレイへ移行する必要がある。この引き継ぎこそ、ほとんどのインディーマルチプレイヤーゲームでレイテンシの急上昇や完全な障害が発生するポイントである。
有効なパターンは次の通り。
- ホストプレイヤーがWebSocketまたはUDPリスニングソケットを開く
- サーバー(ロビーシステム)がホストのIPとポートをすべてのピアに配布する
- ピアがSTUN経由で直接P2P接続を試みる
- STUNがN秒以内に失敗した場合、TURNリレーにフォールバックする
- すべてのピアが接続を報告したら、ホストがゲーム開始をシグナリングする
この引き継ぎ中のリアルタイム通信には、HTTPポーリングよりもWebSocket接続の方がはるかに信頼性が高い。特に、8~16のクライアントに同時に接続状態の更新をプッシュする必要がある場合に有効だ。
バイラル急増時のトラフィックシェーピング
Goose Goose Duckチームが行った最も賢いことの1つは、ピークトラフィック時に期待値を管理したことだ。バックエンドが限界に達したとき、選択肢は2つある:すべてを予測不能に劣化させるか(ランダムな切断、破損したロビー状態、タイムアウトエラー)、優雅な劣化を実装するか。
優雅な劣化のパターン
接続キューイング: ロビーサーバーが定員に達したときにプレイヤーを拒否する代わりに、リアルタイムの位置カウンター付きの仮想キューに配置する。プレイヤーは2分間待つだろう。彼らは不可解な「サーバーエラー」メッセージを許容しない。
// 位置情報付き接続キュー(C#)
public class ConnectionQueue
{
private readonly ConcurrentQueue<string> _queue = new();
private readonly SemaphoreSlim _admissionGate;
private readonly int _maxConcurrentSessions;
public ConnectionQueue(int maxConcurrentSessions)
{
_maxConcurrentSessions = maxConcurrentSessions;
_admissionGate = new SemaphoreSlim(maxConcurrentSessions, maxConcurrentSessions);
}
public async Task<QueueResult> TryEnterQueue(string playerId)
{
int position = _queue.Count + 1;
_queue.Enqueue(playerId);
// 待機時間を推定:現在のスループットで平均セッション検索時間を約30秒と仮定
int estimatedWaitSeconds = (position / _maxConcurrentSessions) * 30;
if (_admissionGate.CurrentCount > 0)
{
await _admissionGate.WaitAsync();
_queue.TryDequeue(out _);
return new QueueResult { Admitted = true, Position = 0 };
}
return new QueueResult
{
Admitted = false,
Position = position,
EstimatedWaitSeconds = estimatedWaitSeconds
};
}
}
リージョナルな負荷分散: US-Eastが過負荷でもEU-Westに余裕がある場合、レイテンシ警告とともに新規USプレイヤーをEUにリダイレクトし、接続を完全に拒否しない。ソーシャルディダクションゲームで120msのpingは事実上気付かれない——これらはフレームパーフェクトな格闘ゲームではない。
ロビー作成のレート制限: ピーク負荷時には、ロビー作成をプレイヤー1人あたり30秒間に1つに制限する。これはボットによるロビースパム(Goose Goose Duckで実際に問題になった)を防ぎ、ロビーデータベースへの書き込み負荷を軽減する。
コスト内訳:バイラル規模の実際のコスト
実際の数字を当てはめてみよう。以下は、異なるバックエンドアーキテクチャを使用した場合の、Goose Goose Duck規模のバイラルイベントの大まかなコストモデルである。
集中ロビー調整とP2P(Goose Goose Duckが採用した方式):
| コンポーネント | 月額コスト(ピークCCU 70万時) |
|---|---|
| ロビー/マッチメイキングサーバー(c5.2xlargeインスタンス×12、オートスケーリング) | $3,500~$5,000 |
| ロビー状態用Redisクラスター(3ノード、r6g.xlarge) | $1,800 |
| TURNリレーサーバー(トラフィックの15%、約10万プレイヤー分) | $8,000~$15,000 |
| 永続状態用PostgreSQL(RDS Multi-AZ) | $600 |
| 帯域幅(ロビー調整、約2TB/日) | $1,200 |
| 合計 | $15,100~$23,600/月 |
完全専用サーバー(すべての試合をクラウドVMでホスト):
| コンポーネント | 月額コスト(ピークCCU 70万時) |
|---|---|
| ゲームサーバー(約5万同時試合 × $0.04/時間) | $1,440,000/月 |
| マッチメイキングとロビー | $5,000 |
| データベースインフラ | $2,000 |
| 合計 | 約$1,447,000/月 |
コスト差は2桁である。無料でプレイでき、コスメティックで収益化するゲームにとって、専用サーバーモデルは、初日から積極的な収益化がない限り、破産への一直線である。P2Pは怠惰なアーキテクチャではなく、意識的な財務上の決定である。
しかし、節約にはトレードオフが伴う。チートの深刻度が増す。接続品質はホストによって変動する。そして、調整インフラは堅牢でなければならない。なぜなら、それがゲーム内のすべての試合における単一障害点となるからだ。
自分のプロジェクトでこれらのトレードオフを評価しているなら、horizOnが調整レイヤー(ロビー管理、マッチメイキング、プレイヤー認証、セッション状態)を処理するため、インフラストラクチャではなくゲームプレイに集中できる。このプラットフォームはまさにこのユースケースのために構築されたものである:バックエンドエンジニアリングに数ヶ月を費やすことなくスケーリングする必要があるインディーチーム向けだ。
バイラル成長を乗り切るための5つのバックエンドアーキテクチャパターン
以下は、必要になる前に実装すべき具体的なパターンである。なぜなら、トラフィック急増中にこれらを後付けすることは、バックエンドの火災を葬式に変える方法だからだ。
1. ロビー状態とゲーム状態を分離する
ロビー調整システムと実際のゲームプレイネットワーキングは、スケーリングプロファイルが異なる別のシステムである。ロビー状態は読み取りが多く、書き込みは中程度で、キャッシュ(Redis)の恩恵を受ける。ゲーム状態は高頻度、低レイテンシであり、ホストマシンまたは専用サーバーに属する。これらを1つのデータベースに混在させることは、スケーリングのデストラップである。
2. 定員に敏感な書き込みにはアトミック操作を使用する
先ほど示したロビー参加操作は、Redis Luaを介してアトミックである。ゲームルームがあふれるかどうかを決定するものに、アプリケーションレベルのロックを頼ってはいけない。毎秒2000の参加がある場合、10msの競合ウィンドウでも20の売り過ぎロビーが発生する。
3. 必要になる前に接続キューイングを実装する
推定待機時間60秒のキューは、プレイヤーの70~80%を維持する。一般的な「接続失敗」エラーはほぼゼロを維持する。キューシステムを初期アーキテクチャに組み込め。トラフィックが少ないときは無効にできるが、トラフィックが急増したときに素早く構築することはできない。
4. ロビーからゲームへの移行を個別に監視する
ほとんどの監視システムは「総オンラインプレイヤー数」と「エラーレート」を追跡する。移行ポイントには特定のメトリクスが必要である:満杯になったロビーのうち、何パーセントがゲームプレイに正常に移行できているか?この数値が95%を下回った場合、STUN/TURNインフラまたはP2Pの穴あけロジックが失敗している。このメトリクスは、他のどの指標よりも正確にプレイヤーの離脱を予測する。
5. 優雅な劣化のラダーを構築する
劣化条件を事前に定義せよ。
- 緑(容量の80%未満): 全機能、制限なし
- 黄(容量の80~95%): ロビー作成レート制限を有効化、既存ロビーへの参加を優先
- 橙(容量の95~100%): 接続キューイングを作動、マッチメイキングフィルターを無効化、クロスリージョンマッチを受け入れ
- 赤(容量超過): 完全キューイング、新規接続用の静的フォールバックページ、既存セッションを優先
これらのしきい値をインフラ設定に書き込め。各境界でアラートを設定せよ。「バイラルモーメント」と「バイラルな大惨事」の違いは、赤に達する前に橙を打つかどうかである。
より大きな教訓
Goose Goose Duckの話は、マルチプレイヤーゲームアーキテクチャに関する根本的な何かを示している:P2Pと専用サーバーの選択は品質の選択ではなく、連鎖的な結果をもたらす経済的かつアーキテクチャ上の選択である。P2PはGaggle Studiosに潜在的に数百万ドルのサーバーコストを節約させたが、その代わりに堅牢な調整レイヤー、注意深いロビー管理、そして一定の品質トレードオフを受け入れる姿勢が必要だった。
マルチプレイヤーアーキテクチャを計画しているインディー開発者にとっての教訓は明白だ:平均ではなくピークに合わせて設計せよ。ゲームがバイラルヒットした日、バックエンドは通常の50~100倍の負荷を経験する。その規模でテストしていなければ、準備はできていない。
ロビーとマッチメイキングレイヤーから始めよ。アトミックなロビー操作を正しく実装せよ。接続キューイングを構築せよ。リージョナルフェイルオーバーを実装せよ。これらが、バイラルモーメントを成功物語にするか、死後検証にするかを決定するコンポーネントである。
バックエンドエンジニアリングに数ヶ月を費やさずに、すでに大規模で戦闘テスト済みの調整レイヤーを出荷したいなら、horizOnはロビー管理、マッチメイキング、セッション状態をすぐに提供する。サーバーが持ちこたえるかどうかを心配する代わりに、ゲームを楽しくすることに集中できる。APIドキュメントをチェックして、あなたのアーキテクチャにどう適合するか確認してほしい。
ソース: Staying Lean: How We Built the World's Biggest Social Deduction Game