個人開発

じゃんけんリアルタイム対戦の同期設計 — WebSocket + DynamoDB で500人対戦を実現するまで

ネットワーク接続のイメージ
Photo by Merlin Lightpainting on Pexels

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人では設計がかなり変わります。

1v1Battle 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 はステートレスに保てるので、スケールの心配が減ります。


参照リンク