マガジン

Firebaseでターン制対戦を作る:Firestoreの設計

遊びをつくる

執筆にClaudeを使用しています。

不具合の起きた順番を絵札で並べ、相手に伝えるための手紙を整える手元。
記事のテーマをもとに制作したAI生成イラスト。

ターン制対戦をブラウザゲームに組み込む際、最初の壁になるのはデータ設計です。「どのドキュメントに何を持たせるか」を間違えると、後からルームの状態管理が複雑になり、片方のプレイヤーの操作がもう片方に届かない、あるいは二重に届くといった不具合が頻発します。 FirebaseのFirestoreはリアルタイムリスナーとドキュメント単位のトランザクションを備えており、ターン制対戦との相性は良好です。ただし「とりあえずフィールドを追加していく」やり方では、セキュリティルールが書けず、整合性も取れません。この記事では、Firestoreのドキュメント設計に絞って、判断基準と実装の流れを示します。

この記事の目次
  1. Firestoreをターン制に使うときの前提
  2. ゲームドキュメントの基本構造
  3. ターンの進行をトランザクションで書く
  4. セキュリティルールの書き方
  5. マッチングの実装方針
  6. 次の行動
  7. 参考資料

Firestoreをターン制に使うときの前提

Firestoreのデータはコレクション→ドキュメント→フィールドという階層です。ターン制対戦では「1試合=1ドキュメント」を基本単位にするのが最もシンプルで、セキュリティルールも書きやすくなります。

リアルタイムリスナー(onSnapshot)は指定したドキュメントや条件に一致するコレクションの変化をクライアントへ即座に通知します。ターン制であれば「相手がターンを終えた瞬間に自分の画面が更新される」という動作をこの仕組みで実現できます。WebSocketのようにサーバー側のコードを書かなくてよい点が、個人開発や小規模チームにとって大きなメリットです。

ゲームドキュメントの基本構造

1試合のドキュメントは次の要素を持たせます。

games/{gameId}
  status        : "waiting" | "playing" | "finished"
  players       : { uid1: { name, ready }, uid2: { name, ready } }
  currentTurn   : uid1 または uid2
  board         : ゲーム固有の盤面データ(配列やマップ)
  lastMove      : { uid, action, timestamp }
  winner        : null または uid
  createdAt     : Timestamp
  updatedAt     : Timestamp

status は試合の大きな状態遷移を表します。waiting(対戦相手を待っている)→playing(対戦中)→finished(終了)の3値で十分です。これ以上増やすとセキュリティルールの条件分岐が爆発するため、状態を追加したくなったら「本当に必要か」を慎重に判断してください。

currentTurn には「今ターンを操作できるプレイヤーのUID」を入れます。UIDを持たせることで、セキュリティルールで request.auth.uid == resource.data.currentTurn という条件を書けます。これが設計上もっとも重要な点で、サーバーサイドのロジックを持たなくても「自分のターン以外は書き込めない」制約をルールだけで表現できます。

lastMove は前回の操作内容を記録します。履歴を全件保存する必要がなければ最後の1手だけをここに上書きすれば十分です。履歴が必要なゲーム(将棋・チェス系など)ではサブコレクション games/{gameId}/moves に1手ずつドキュメントを追加する方法を取ります。履歴の有無でコストと複雑さが変わるため、ゲームの要件に合わせて選んでください。

ターンの進行をトランザクションで書く

ターンを進めるときに「盤面を更新する」「currentTurnを相手に切り替える」の2操作は必ずセットで行う必要があります。片方だけが成功すると整合性が崩れます。Firestoreのトランザクション(runTransaction)を使うとこの2操作を原子的に実行できます。

js
import { runTransaction, doc, serverTimestamp } from "firebase/firestore";

async function submitMove(db, gameId, myUid, opponentUid, newBoard, moveAction) {
  const gameRef = doc(db, "games", gameId);

  await runTransaction(db, async (transaction) => {
    const gameSnap = await transaction.get(gameRef);
    if (!gameSnap.exists()) throw new Error("game not found");

    const data = gameSnap.data();
    if (data.currentTurn !== myUid) throw new Error("not your turn");
    if (data.status !== "playing")  throw new Error("game not active");

    transaction.update(gameRef, {
      board:       newBoard,
      currentTurn: opponentUid,
      lastMove:    { uid: myUid, action: moveAction, timestamp: serverTimestamp() },
      updatedAt:   serverTimestamp(),
    });
  });
}

トランザクション内で currentTurn !== myUid を確認しているのはクライアント側の二重チェックです。セキュリティルール側でも同じ条件を書いておくと、悪意あるリクエストをサーバー(Firestore)の手前で止められます。

勝敗判定のロジックが複雑な場合は、同じトランザクション内で盤面を評価して winner と status: "finished" を書き込むか、Cloud Functionsで onDocumentUpdated をトリガーにして判定を行うかを選びます。クライアント側で完結させる方が実装コストは低く、不正リスクを許容できる(友人間のプレイなど)なら十分です。公開対戦で不正対策を厳密にしたい場合はFunctionsへの移行を検討してください。

セキュリティルールの書き方

ドキュメント設計とセキュリティルールは表裏一体です。先ほどの構造なら次のようなルールが書けます。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /games/{gameId} {
      // 読み取り:対戦者のみ
      allow read: if request.auth.uid in resource.data.players;

      // 自分のターン中のみ更新可
      allow update: if
        request.auth.uid == resource.data.currentTurn &&
        resource.data.status == "playing";

      // 新規作成:認証済みなら可
      allow create: if request.auth != null;
    }

    match /games/{gameId}/moves/{moveId} {
      allow read:   if request.auth.uid in get(/databases/$(database)/documents/games/$(gameId)).data.players;
      allow create: if request.auth.uid == get(/databases/$(database)/documents/games/$(gameId)).data.currentTurn;
    }
  }
}

resource.data.players がマップの場合、in 演算子でキーの存在確認ができます。players を配列にすると in の挙動が変わるため、マップ形式(キーがUID)の方がルール上扱いやすくなります。

よくある失敗は「allow write」とまとめて書いてしまうことです。write は create・update・delete をすべて含むため、ゲームの削除も通ってしまいます。用途ごとに分けて書く習慣をつけてください。

マッチングの実装方針

試合が始まる前の「相手を探す」処理についても触れておきます。Firestoreにマッチングキュー用コレクションを作り、status: "waiting" のドキュメントを検索してjoinする方法が一般的です。

matchQueue/{uid}
  uid       : string
  gameType  : string
  joinedAt  : Timestamp

クライアントAがキューに自分のドキュメントを書き込み、クライアントBがstatus: "waiting"のドキュメントを検索して見つけたらゲームドキュメントを生成し、両者のUIDをplayersに入れてstatus: "playing"にします。その後、両クライアントのキューエントリを削除します。

この流れはCloud Functionsを使わずクライアント側で実装できますが、ネットワーク分断でキューエントリが残り続ける可能性があります。joinedAt にタイムスタンプを入れておき、一定時間(例:30秒)以上古いエントリを定期的に削除する処理をCloud Schedulerで動かすか、次の検索時にクライアントが古いエントリを無視するロジックを入れておくと安定します。

次の行動

今回示した設計をベースに、まずは2人固定のゲームで games コレクションに最小限のフィールドを持たせてプロトタイプを作ってみてください。セキュリティルールはFirebaseコンソールのルールシミュレーターで事前に検証できます。完成したゲームはHTMLファイルまたはZIPにまとめてアソバースに投稿し、実際の対戦でデータフローを確認するのが最短の改善サイクルです。

参考資料

  • Firebase公式ドキュメント(Firestore データモデル)
  • Firebase公式ドキュメント(Firestoreセキュリティルール)
  • Firebase公式ドキュメント(トランザクションとバッチ書き込み)