じゃんけんリアルタイム対戦の同期設計 — WebSocket + DynamoDB で500人対戦を実現するまで
janken.party とは
janken.party は、ブラウザ上でじゃんけんのリアルタイム対戦ができるサービスです。個人開発で作っています。とりあえず公開して見た段階なので、お手柔らかに遊んでみてくださいw
対戦モードは2つあります。
- 1v1: 2人でルームに入って対戦。リマッチも可能
- Battle Royale: 最大500人が同時に参加。ラウンドごとに負けたプレイヤーが脱落していく
どちらも WebSocket でリアルタイム通信しています。この記事では、この同期の仕組みをどう設計したかについて書いていきます。
なぜ「30秒タイマー」が必要なのか
リアルタイム対戦で最初にぶつかった問題は「選択しないプレイヤー」への対処です。
1v1で相手が手を出さなければゲームが永遠に進まない。Battle Royale で数百人のうち何人かが放置していたら、全員が待たされます。
そこで選択制限時間を設けています。
- 1v1: 30秒
- Battle Royale: 15秒(テンポ重視で短め)
時間内に手を出さなかったプレイヤーにはランダムな手が自動で割り当てられます。
タイマーをどこで管理するか
ここで重要なのは、タイマーをクライアント側ではなくサーバー側で管理するということです。
クライアント側のタイマーだと:
- ブラウザのタブを閉じたらタイマーが止まる
- 時刻のずれで公平性が損なわれる
- 改ざんの余地がある
サーバー側で確実にタイムアウトを発火させる必要があります。ただし Lambda のような FaaS 環境では setTimeout で30秒待つわけにはいきません(1v1はコストがかかりすぎる)。
EventBridge Scheduler による分散タイマー
AWS の EventBridge Scheduler を使っています。ゲーム開始時に「30秒後にこの Lambda を呼び出す」というワンタイムスケジュールを登録して、発火後は自動削除される仕組みです。
ゲーム開始 → EventBridge Schedule 登録 (30秒後)
↓ (30秒経過)
Lambda 呼び出し → 未選択プレイヤーにランダムハンド → 結果判定
これならサーバーがステートを持ち続ける必要がないですし、Lambda がスケールアウトしても正確にタイムアウトが発火します。
DynamoDB の条件付き書き込みで競合を防ぐ
リアルタイム対戦では、複数のプレイヤーがほぼ同時にリクエストを送ってきます。ここで競合が起きると、同じプレイヤーが2回手を出せたり、定員オーバーでルームに入れてしまったりします。
DynamoDB の ConditionExpression を使って、こうした競合をデータベース層で防いでいます。
手の二重提出防止
ConditionExpression: "attribute_not_exists(hand) OR hand = :null"
すでに手を出しているプレイヤーが再度リクエストしても、条件が満たされずに失敗します。アプリケーション側でロックを取る必要がないのがポイントです。
Battle Royale の参加数制限
ConditionExpression: "totalJoined < :maxPlayers AND phase = :lobby"
参加者数の上限チェックとフェーズの確認を1回のDB操作で原子的に行っています。500人が同時に参加しようとしても、定員を超えることはありません。
提出数のアトミックカウンター
Battle Royale では「全員が手を出し終わったか」を知る必要があります。
UpdateExpression: "SET submittedCount = submittedCount + :one"
ConditionExpression: "submittedCount < aliveCount"
カウンターの更新と上限チェックを原子的にやっています。最後のプレイヤーが手を出した瞬間に submittedCount >= aliveCount になり、ラウンドの結果判定に進みます。
1v1 と Battle Royale の同期設計の違い
同じ「じゃんけん」でも、2人と500人では設計がかなり変わります。
| 1v1 | Battle Royale | |
|---|---|---|
| タイムアウト | 30秒 | 15秒 |
| プレイヤー数 | 固定2人 | 3〜500人 |
| 結果配信 | 個別メッセージ | バッチ送信 |
| リマッチ | あり | なし |
| ラウンド進行 | 手動 (リマッチ) | 自動 (サーバー側で進行) |
| 終了条件 | なし (何回でも) | 生存者が閾値以下 |
結果配信の違い
1v1 では2人にそれぞれ「あなたの結果」を送るだけです。
Battle Royale では数百人に同時にメッセージを送る必要があります。しかも各プレイヤーに送る内容が違います(自分が勝ったのか負けたのか)。WebSocket の同時送信数が多すぎるとコネクションが枯渇するので、100件ずつバッチに分けて送信しています。
ラウンド自動進行
1v1ではリマッチボタンを両者が押して次のラウンドに進みます。
Battle Royale では結果表示後、サーバー側で自動的に次のラウンドに進みます。結果を数秒間表示してから、次の EventBridge Schedule を登録するという流れです。プレイヤーの操作を待たずに進むので、テンポよくゲームが進行します。
再接続の扱い
ブラウザのタブを閉じた、ネットワークが切れた、など切断は日常的に起きます。
クライアント側では、タブが表示されている限り自動で再接続を試みます。サーバー側では切断から30秒間のグレースピリオドを設けていて、その間に再接続すればゲームに復帰できます。
切断 → 30秒のグレースピリオド開始
↓ (再接続)
connectionId を更新してゲーム復帰
↓ (30秒超過)
プレイヤーをルームから除外
再接続時にはゲームの現在の状態(フェーズ、ラウンド数、スコアなど)を丸ごと送り返すので、クライアントは途中から再開できます。
やってみて思ったこと
リアルタイム対戦の同期は、普通のWeb開発とは違う難しさがありますね。
- 時間の概念: REST API にはない「タイムアウト」「同時性」の概念がコアロジックに食い込んでくる
- 状態の一貫性: 複数プレイヤーの操作が同時に来る前提で設計しないといけない
- スケール: 2人と500人では設計がまるで違う
DynamoDB の条件付き書き込みと EventBridge Scheduler の組み合わせは、サーバーレス環境でリアルタイムゲームを作るのに相性が良かったです。ステートをDBに持たせて、タイマーはマネージドサービスに任せる。Lambda はステートレスに保てるので、スケールの心配が減ります。