Firestore でリーダーボードを作成する

1. はじめに

最終更新日: 2023 年 1 月 27 日

リーダーボードを作るには何が必要ですか?

基本的に、リーダーボードは単なるスコアのテーブルで、複雑な要因が 1 つあります。特定のスコアのランクを読み取るには、他のすべてのスコアをなんらかの順序で知っておく必要があります。また、ゲームがリリースされるとリーダーボードが大きくなり、読み取りと書き込みを頻繁に行うようになります。効果的なリーダーボードを作成するには、このランキング オペレーションを迅速に処理できる必要があります。

作成するアプリの概要

この Codelab では、シナリオに適したさまざまなリーダーボードを実装します。

学習内容

次の 4 種類のリーダーボードを実装する方法を学びましょう。

  • 単純なレコードカウントを使用してランクを決定するための単純な実装
  • 安価で定期的に更新されるリーダーボード
  • 意味不明な内容を含むリアルタイムのリーダーボード
  • 非常に大規模なプレーヤー ベースのおおよそのランキングのための確率的(確率的)リーダーボード

必要なもの

  • Chrome の最新バージョン(107 以降)
  • Node.js 16 以降(nvm を使用している場合は、nvm --version を実行してバージョン番号を確認できます)
  • Firebase Blaze の有料プラン(オプション)
  • Firebase CLI v11.16.0 以降
    CLI をインストールするには、npm install -g firebase-tools を実行します。その他のインストール オプションについては、CLI のドキュメントをご覧ください。
  • JavaScript、Cloud Firestore、Cloud Functions、Chrome DevTools に関する知識

2. 設定方法

コードを取得する

このプロジェクトに必要なすべてのものが Git リポジトリにまとめられています。まず、コードを取得して、お好みの開発環境で開きます。この Codelab では VS Code を使用しましたが、どのテキスト エディタでも問題ありません。

し、ダウンロードした zip ファイルを解凍します。

または、任意のディレクトリにクローンを作成します。

git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git

出発点

このプロジェクトは現在、空白の関数がいくつかある空白の状態です。

  • index.html には、デベロッパー コンソールから関数を呼び出して出力を確認できるグルー スクリプトが含まれています。これを使用して、バックエンドとのインターフェースとなり、関数呼び出しの結果を確認します。実際のシナリオでは、これらのバックエンド呼び出しをゲームから直接行います。この Codelab ではゲームは使用しません。リーダーボードにスコアを追加するたびにゲームをプレイすると時間がかかりすぎるためです。
  • functions/index.js にはすべての Cloud Functions の関数が含まれています。addScoresdeleteScores などのユーティリティ関数のほか、この Codelab で実装する関数もあります。これらは別のファイルでヘルパー関数を呼び出します。
  • functions/functions-helpers.js には実装する空の関数が含まれています。リーダーボードごとに read、create、update の各関数を実装し、選択した実装が実装の複雑さとスケーリング パフォーマンスの両方にどう影響するかを確認します。
  • functions/utils.js には、さらに多くのユーティリティ関数が含まれています。この Codelab では、このファイルについては扱いません。

Firebase プロジェクトを作成して構成する

  1. Firebase コンソールで [プロジェクトを追加] をクリックします。
  2. 新しいプロジェクトを作成するには、目的のプロジェクト名を入力します。
    これにより、プロジェクト ID(プロジェクト名の下に表示される)もプロジェクト名に基づいて設定されます。必要に応じて、プロジェクト ID の編集アイコンをクリックして、さらにカスタマイズできます。
  3. Firebase の利用規約が表示されたら、内容を読み、同意します。
  4. [続行] をクリックします。
  5. [このプロジェクトの Google アナリティクスを有効にする] オプションを選択し、[続行] をクリックします。
  6. 使用する既存の Google アナリティクス アカウントを選択するか、[新しいアカウントを作成] を選択して新しいアカウントを作成します。
  7. [プロジェクトの作成] をクリックします。
  8. プロジェクトが作成されたら、[続行] をクリックします。
  9. [ビルド] メニューで [Functions] をクリックします。プロンプトが表示されたら、Blaze 料金プランを使用するようにプロジェクトをアップグレードします。
  10. [構築] メニューから [Firestore データベース] をクリックします。
  11. 表示された [データベースを作成] ダイアログで、[テストモードで開始] を選択し、[次へ] をクリックします。
  12. [Cloud Firestore のロケーション] プルダウンからリージョンを選択し、[有効にする] をクリックします。

リーダーボードを設定して実行する

  1. ターミナルでプロジェクトのルートに移動し、firebase use --add を実行します。作成した Firebase プロジェクトを選択します。
  2. プロジェクトのルートで firebase emulators:start --only hosting を実行します。
  3. ブラウザで localhost:5000 にアクセスします。
  4. Chrome DevTools の JavaScript コンソールを開き、leaderboard.js をインポートします。
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. コンソールで leaderboard.codelab(); を実行します。ウェルカム メッセージが表示された場合は、準備完了です。実行されていない場合は、エミュレータをシャットダウンしてステップ 2 ~ 4 を再実行します。

最初のリーダーボードの実装に移りましょう。

3. シンプルなリーダーボードを実装する

このセクションを終えると、リーダーボードにスコアを追加して、ランクを伝えられるようになります。

本題に入る前に、このリーダーボードの実装の仕組みを説明しましょう。すべてのプレーヤーが 1 つのコレクションに格納され、プレーヤーのランクを取得するには、コレクションを取得して、どれだけのプレーヤーが先行しているかをカウントします。これにより、スコアの挿入と更新が簡単になります。新しいスコアを挿入するには、それをコレクションに追加します。スコアを更新するには、現在のユーザーでフィルタしてから、結果として得られるドキュメントを更新します。では、コードではどうなるか見てみましょう。

functions/functions-helper.js で、createScore 関数を実装します。この関数は次のように簡単に使用できます。

async function createScore(score, playerID, firestore) {
  return firestore.collection("scores").doc().create({
    user: playerID,
    score: score,
  });
}

スコアを更新するには、更新するスコアがすでに存在することを確認するエラーチェックを追加するだけです。

async function updateScore(playerID, newScore, firestore) {
  const playerSnapshot = await firestore.collection("scores")
      .where("user", "==", playerID).get();
  if (playerSnapshot.size !== 1) {
    throw Error(`User not found in leaderboard: ${playerID}`);
  }
  const player = playerSnapshot.docs[0];
  const doc = firestore.doc(player.id);
  return doc.update({
    score: newScore,
  });
}

最後は、シンプルですがスケーラビリティに劣る rank 関数です。

async function readRank(playerID, firestore) {
  const scores = await firestore.collection("scores")
      .orderBy("score", "desc").get();
  const player = `${playerID}`;
  let rank = 1;
  for (const doc of scores.docs) {
    const user = `${doc.get("user")}`;
    if (user === player) {
      return {
        user: player,
        rank: rank,
        score: doc.get("score"),
      };
    }
    rank++;
  }
  // No user found
  throw Error(`User not found in leaderboard: ${playerID}`);
}

実際に試してみましょう。ターミナルで次のコマンドを実行して、関数をデプロイします。

firebase deploy --only functions

次に、Chrome の JS コンソールで他のスコアを追加して、他のプレーヤーとのランキングを確認できるようにします。

leaderboard.addScores(); // Results may take some time to appear.

これで、独自のスコアをミックスに追加できるようになりました。

leaderboard.addScore(999, 11); // You can make up a score (second argument) here.

書き込みが完了すると、「スコアが作成されました」というレスポンスがコンソールに表示されます。エラーが表示される場合は、Firebase コンソールで Functions のログを開き、何が問題だったかを確認します。

最後に、スコアを取得して更新します。

leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)

ただし、この実装では、特定のスコアのランクを取得するための線形的な時間とメモリ要件が課せられます。関数の実行時間とメモリはどちらも限られているため、フェッチが次第に遅くなるだけでなく、リーダーボードに十分なスコアが追加されると、結果を返す前に関数がタイムアウトまたはクラッシュします。明らかに、少数のプレーヤーにスケーリングする場合には、もっと優れたツールが必要であることは明らかです。

Firestore の達人には、COUNT 集計クエリをご存じかもしれません。このクエリを使用すると、このリーダーボードのパフォーマンスが大幅に向上します。そうだね!COUNT クエリを使用すると、ユーザー数が 100 万人程度にまで小さくなりますが、パフォーマンスは一定ではありません。

しかし、コレクション内のすべてのドキュメントを列挙するとしたら、すべてのドキュメントにランクを割り当てることができ、それを取得する必要があるときは、フェッチは O(1) 時間とメモリになります。これが次のアプローチ、つまり定期的に更新されるリーダーボードにつながります。

4. 定期的に更新されるリーダーボードを実装する

このアプローチの鍵は、ランクをドキュメント自体に格納することです。したがって、ランクを取得すると、追加の作業なしでランクを取得できます。これを実現するには、新しい種類の関数が必要です。

index.js に以下を追加します。

// Also add this to the top of your file
const admin = require("firebase-admin");

exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
    // Schedule this when most of your users are offline to avoid
    // database spikiness.
    .timeZone("America/Los_Angeles")
    .onRun((context) => {
      const scores = admin.firestore().collection("scores");
      scores.orderBy("score", "desc").get().then((snapshot) => {
        let rank = 1;
        const writes = [];
        for (const docSnapshot of snapshot.docs) {
          const docReference = scores.doc(docSnapshot.id);
          writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
          rank++;
        }
        Promise.all(writes).then((result) => {
          console.log(`Writes completed with results: ${result}`);
        });
      });
      return null;
    });

これで、読み取り、更新、書き込みのオペレーションはすべて適切でシンプルになりました。書き込みと更新はどちらも変更されていませんが、読み取りは次のようになります(functions-helpers.js 内)。

async function readRank(playerID, firestore) {
  const scores = firestore.collection("scores");
  const playerSnapshot = await scores
      .where("user", "==", playerID).get();
  if (playerSnapshot.size === 0) {
    throw Error(`User not found in leaderboard: ${playerID}`);
  }

  const player = playerSnapshot.docs[0];
  if (player.get("rank") === undefined) {
    // This score was added before our scheduled function could run,
    // but this shouldn't be treated as an error
    return {
    user: playerID,
    rank: null,
    score: player.get("score"),
  };
  }

  return {
    user: playerID,
    rank: player.get("rank"),
    score: player.get("score"),
  };
}

プロジェクトに請求先アカウントを追加せずに、これをデプロイしてテストすることはできません。請求先アカウントがある場合は、スケジュール設定された関数の間隔を短くして、関数によってリーダーボードのスコアに魔法のようにランクが割り当てられていることを確認します。

そうでない場合は、スケジュール設定された関数を削除し、次の実装に進みます。

スコア コレクションの横にあるその他アイコン(3 つの点)をクリックして Firestore データベースのスコアを削除し、次のセクションに備えます。

[コレクションを削除] が\n アクティブになっている\nFirestore スコア ドキュメント ページ

5. リアルタイム ツリー リーダーボードを実装する

このアプローチは、検索データをデータベース コレクション自体に格納することで機能します。統一されたコレクションを用意するのではなく、ドキュメント内を移動して走査できるようにすべてを 1 つのツリーに格納することが目標です。これにより、指定されたスコアのランクに対してバイナリ(または N 項)検索を実行できます。これはどのようなものでしょうか。

まずは、スコアをほぼ均等なバケットに分散できるようにします。そのためには、ユーザーが記録しているスコアの値に関する知識が必要です。たとえば、対戦型ゲームでスキル評価のリーダーボードを作成するとすれば、スキル評価はほとんどの場合、正規分布になります。ランダムスコア生成関数では JavaScript の Math.random() を使用します。これにより、ほぼ均等な分布になるので、バケットを均等に分割します。

この例では、わかりやすくするために 3 つのバケットを使用しますが、実際のアプリでこの実装を使用すると、バケットが多いほど結果が出るまでの時間が短くなるはずです。ツリーが浅いほど、平均してコレクションのフェッチが少なくなり、ロックの競合が少なくなります。

プレーヤーのランクは、スコアが高いプレーヤーの数に、プレーヤー自体のスコアを 1 加算して算出されます。scores にある各コレクションは、3 つのドキュメントを格納し、それぞれに範囲、各範囲のドキュメント数、対応する 3 つのサブコレクションを格納します。ランクを読み取るために、このツリーを走査してスコアを検索し、上位のスコアの合計を追跡します。スコアが見つかったら、正しい合計も算出されます。

記述は非常に複雑です。まず、複数の書き込みまたは読み取りが同時に行われた場合のデータの不整合を防ぐために、1 つのトランザクション内ですべての書き込みを行う必要があります。また、ツリーをたどって新しいドキュメントを作成するときは、前述の条件をすべて維持する必要もあります。そして最後に、この新しいアプローチのツリーの複雑さと、元のドキュメントをすべて保存する必要性が組み合わされているため、ストレージの費用がわずかに増加します(ただし、依然として線形的です)。

functions-helpers.js で次のように変更します。

async function createScore(playerID, score, firestore) {
  /**
   * This function assumes a minimum score of 0 and that value
   * is between min and max.
   * Returns the expected size of a bucket for a given score
   * so that bucket sizes stay constant, to avoid expensive
   * re-bucketing.
   * @param {number} value The new score.
   * @param {number} min The min of the previous range.
   * @param {number} max The max of the previous range. Must be greater than
   *     min.
   * @return {Object<string, number>} Returns an object containing the new min
   *     and max.
   */
  function bucket(value, min, max) {
    const bucketSize = (max - min) / 3;
    const bucketMin = Math.floor(value / bucketSize) * bucketSize;
    const bucketMax = bucketMin + bucketSize;
    return {min: bucketMin, max: bucketMax};
  }

  /**
   * A function used to store pending writes until all reads within a
   * transaction have completed.
   *
   * @callback PendingWrite
   * @param {admin.firestore.Transaction} transaction The transaction
   *     to be used for writes.
   * @return {void}
   */

  /**
   * Recursively searches for the node to write the score to,
   * then writes the score and updates any counters along the way.
   * @param {number} id The user associated with the score.
   * @param {number} value The new score.
   * @param {admin.firestore.CollectionReference} coll The collection this
   *     value should be written to.
   * @param {Object<string, number>} range An object with properties min and
   *     max defining the range this score should be in. Ranges cannot overlap
   *     without causing problems. Use the bucket function above to determine a
   *     root range from constant values to ensure consistency.
   * @param {admin.firestore.Transaction} transaction The transaction used to
   *     ensure consistency during tree updates.
   * @param {Array<PendingWrite>} pendingWrites A series of writes that should
   *     occur once all reads within a transaction have completed.
   * @return {void} Write error/success is handled via the transaction object.
   */
  async function writeScoreToCollection(
      id, value, coll, range, transaction, pendingWrites) {
    const snapshot = await transaction.get(coll);
    if (snapshot.empty) {
      // This is the first score to be inserted into this node.
      for (const write of pendingWrites) {
        write(transaction);
      }
      const docRef = coll.doc();
      transaction.create(docRef, {exact: {score: value, user: id}});
      return;
    }

    const min = range.min;
    const max = range.max;

    for (const node of snapshot.docs) {
      const data = node.data();
      if (data.exact !== undefined) {
        // This node held an exact score.
        const newRange = bucket(value, min, max);
        const tempRange = bucket(data.exact.score, min, max);

        if (newRange.min === tempRange.min &&
          newRange.max === tempRange.max) {
          // The scores belong in the same range, so we need to "demote" both
          // to a lower level of the tree and convert this node to a range.
          const rangeData = {
            range: newRange,
            count: 2,
          };
          for (const write of pendingWrites) {
            write(transaction);
          }
          const docReference = node.ref;
          transaction.set(docReference, rangeData);
          transaction.create(docReference.collection("scores").doc(), data);
          transaction.create(
              docReference.collection("scores").doc(),
              {exact: {score: value, user: id}},
          );
          return;
        } else {
          // The scores are in different ranges. Continue and try to find a
          // range that fits this score.
          continue;
        }
      }

      if (data.range.min <= value && data.range.max > value) {
        // The score belongs to this range that may have subvalues.
        // Increment the range's count in pendingWrites, since
        // subsequent recursion may incur more reads.
        const docReference = node.ref;
        const newCount = node.get("count") + 1;
        pendingWrites.push((t) => {
          t.update(docReference, {count: newCount});
        });
        const newRange = bucket(value, min, max);
        return writeScoreToCollection(
            id,
            value,
            docReference.collection("scores"),
            newRange,
            transaction,
            pendingWrites,
        );
      }
    }

    // No appropriate range was found, create an `exact` value.
    transaction.create(coll.doc(), {exact: {score: value, user: id}});
  }

  const scores = firestore.collection("scores");
  const players = firestore.collection("players");
  return firestore.runTransaction((transaction) => {
    return writeScoreToCollection(
        playerID, score, scores, {min: 0, max: 1000}, transaction, [],
    ).then(() => {
      transaction.create(players.doc(), {
        user: playerID,
        score: score,
      });
    });
  });
}

この方法は、1 回のメソッド呼び出しでわずか 6 行のコードしかかからなかった前回の実装よりも明らかに複雑です。このメソッドを実装したら、データベースにスコアをいくつか追加し、結果として得られるツリーの構造を観察してみましょう。JS コンソールで以下を行います。

leaderboard.addScores();

最終的なデータベース構造は次のようになります。ツリー構造がはっきりと表示され、ツリーの葉が個々のスコアを表します。

scores
  - document
    range: 0-333.33
    count: 2
    scores:
      - document
        exact:
          score: 18
          user: 1
      - document
        exact:
          score: 22
          user: 2

難しい部分が終わったところで、前述のようにツリーを走査してスコアを読み取ることができます。

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = firestore.collection("scores");

  /**
   * Recursively finds a player score in a collection.
   * @param {string} id The player's ID, since some players may be tied.
   * @param {number} value The player's score.
   * @param {admin.firestore.CollectionReference} coll The collection to
   *     search.
   * @param {number} currentCount The current count of players ahead of the
   *     player.
   * @return {Promise<number>} The rank of the player (the number of players
   *     ahead of them plus one).
   */
  async function findPlayerScoreInCollection(id, value, coll, currentCount) {
    const snapshot = await coll.get();
    for (const doc of snapshot.docs) {
      if (doc.get("exact") !== undefined) {
        // This is an exact score. If it matches the score we're looking
        // for, return. Otherwise, check if it should be counted.
        const exact = doc.data().exact;
        if (exact.score === value) {
          if (exact.user === id) {
            // Score found.
            return currentCount + 1;
          } else {
            // The player is tied with another. In this case, don't increment
            // the count.
            continue;
          }
        } else if (exact.score > value) {
          // Increment count
          currentCount++;
          continue;
        } else {
          // Do nothing
          continue;
        }
      } else {
        // This is a range. If it matches the score we're looking for,
        // search the range recursively, otherwise, check if it should be
        // counted.
        const range = doc.data().range;
        const count = doc.get("count");
        if (range.min > value) {
          // The range is greater than the score, so add it to the rank
          // count.
          currentCount += count;
          continue;
        } else if (range.max <= value) {
          // do nothing
          continue;
        } else {
          const subcollection = doc.ref.collection("scores");
          return findPlayerScoreInCollection(
              id,
              value,
              subcollection,
              currentCount,
          );
        }
      }
    }
    // There was no range containing the score.
    throw Error(`Range not found for score: ${value}`);
  }

  const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
  return {
    user: playerID,
    rank: rank,
    score: score,
  };
}

更新は追加の演習として残されます。JS コンソールで leaderboard.addScore(id, score) メソッドと leaderboard.getRank(id) メソッドを使用してスコアを追加および取得し、Firebase コンソールでリーダーボードがどのように変化するか試してみましょう。

ただし、この実装では、対数演算を実行するために複雑さが加えられ、代償が伴います。

  • まず、このリーダーボードの実装では、ロック競合の問題が発生する可能性があります。これは、トランザクションでは、整合性を確保するためにドキュメントの読み取りと書き込みをロックする必要があるためです。
  • 次に、Firestore ではサブコレクションの深さの上限が 100 に設定されています。つまり、同点のスコアが 100 を超えたらサブツリーを作成しないようにする必要がありますが、この実装ではそうではありません。
  • そして最後に、このリーダーボードは、ツリーのバランスが取れている理想的なケースでのみ対数的にスケーリングされます。バランスが取れていないと、このリーダーボードのパフォーマンスは最悪で、やはり線形になります。

完了したら、Firebase コンソールで scores コレクションと players コレクションを削除します。リーダーボードの最後の実装に進みます。

6. 確率的(確率的)リーダーボードを実装する

挿入コードを実行するとき、並列で何度も実行すると関数が失敗し、トランザクション ロックの競合に関連するエラー メッセージが表示されます。この Codelab では説明しませんが、正確なランキングが必要ない場合は、以前のアプローチの複雑さをすべてなくして、よりシンプルで高速な方法にすることができます。プレーヤーの推定ランクを返す方法をスコアと、それによってデータベースのロジックがどのように変化するかについて説明します。

このアプローチでは、リーダーボードを 100 個のバケットに分割します。各バケットは、受け取ると予想されるスコアの約 1% を表します。このアプローチは、スコア分布がわからない場合でも機能します。この場合、バケット全体でスコアがほぼ均等に分布することを保証することはできませんが、スコアの分布方法がわかっていれば、近似の精度は高くなります。

アプローチは次のとおりです。前と同じように、各バケットにスコアのカウントと範囲内のスコアが格納されます。新しいスコアを挿入すると、そのスコアのバケットが見つかり、そのカウントが増えます。ランクを取得するときは、その前のバケットを合計し、バケット内で近似するだけです。これにより、常時ルックアップと挿入がきわめて高速になり、必要なコードもはるかに少なく済みます。

まず、挿入:

// Add this line to the top of your file.
const admin = require("firebase-admin");

// Implement this method (again).
async function createScore(playerID, score, firestore) {
  const scores = await firestore.collection("scores").get();
  if (scores.empty) {
    // Create the buckets since they don't exist yet.
    // In a real app, don't do this in your write function. Do it once
    // manually and then keep the buckets in your database forever.
    for (let i = 0; i < 10; i++) {
      const min = i * 100;
      const max = (i + 1) * 100;
      const data = {
        range: {
          min: min,
          max: max,
        },
        count: 0,
      };
      await firestore.collection("scores").doc().create(data);
    }
    throw Error("Database not initialized");
  }

  const buckets = await firestore.collection("scores")
      .where("range.min", "<=", score).get();
  for (const bucket of buckets.docs) {
    const range = bucket.get("range");
    if (score < range.max) {
      const writeBatch = firestore.batch();
      const playerDoc = firestore.collection("players").doc();
      writeBatch.create(playerDoc, {
        user: playerID,
        score: score,
      });
      writeBatch.update(
          bucket.ref,
          {count: admin.firestore.FieldValue.increment(1)},
      );
      const scoreDoc = bucket.ref.collection("scores").doc();
      writeBatch.create(scoreDoc, {
        user: playerID,
        score: score,
      });
      return writeBatch.commit();
    }
  }
}

この挿入コードの上部には、データベースの状態を初期化するロジックがあり、本番環境でこのような処理を行わないように警告しています。初期化のコードは競合状態からまったく保護されていないため、このような場合、複数の同時書き込みによって大量の重複バケットが作成され、データベースが破損します。

関数をデプロイしてから挿入を実行して、すべてのバケットをカウント 0 で初期化します。するとエラーが返されますが、無視しても問題ありません。

leaderboard.addScore(999, 0); // The params aren't important here.

データベースが正しく初期化されたので、addScores を実行して Firebase コンソールでデータの構造を確認できます。その結果の構造は、表面的には似ていますが、前回の実装よりもはるかにフラットになります。

leaderboard.addScores();

次に、スコアを読み取ります。

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = await firestore.collection("scores").get();
  let currentCount = 1; // Player is rank 1 if there's 0 better players.
  let interp = -1;
  for (const bucket of scores.docs) {
    const range = bucket.get("range");
    const count = bucket.get("count");
    if (score < range.min) {
      currentCount += count;
    } else if (score >= range.max) {
      // do nothing
    } else {
      // interpolate where the user is in this bucket based on their score.
      const relativePosition = (score - range.min) / (range.max - range.min);
      interp = Math.round(count - (count * relativePosition));
    }
  }

  if (interp === -1) {
    // Didn't find a correct bucket
    throw Error(`Score out of bounds: ${score}`);
  }

  return {
    user: playerID,
    rank: currentCount + interp,
    score: score,
  };
}

addScores 関数でスコアの均一分布を生成し、バケット内で線形補間を使用しているため、非常に正確な結果が得られます。ユーザー数が増えてもリーダーボードのパフォーマンスは低下せず、カウントの更新時にロックの競合を(特に)心配する必要はありません。

7. 追加条項: 不正行為

ちょっと待ってください。ブラウザタブの JS コンソールから Codelab に値を書き込んでいると、リーダーボードに嘘をついているプレーヤーは、公平に達成できなかったハイスコアを獲得したと言えないのではないかと思っているかもしれません。

はい、使用できます。不正行為を防止したい場合、最も確実な方法は、セキュリティ ルールによってデータベースへのクライアントの書き込みを無効にし、Cloud Functions への安全なアクセスをクライアントが直接呼び出せないようにすることです。その後、スコアの更新をリーダーボードに送信する前に、サーバーでゲーム内アクションを検証します。

ただし、この戦略は不正行為を阻止するための万能ではありません。十分なインセンティブがあるため、チーターはサーバーサイド検証を回避する方法を見つけられます。また、大きな成功を収めているビデオゲームの多くは、新しいチートを特定し、拡散を阻止するために、チーターと常に関係しています。この現象は、すべてのゲームのサーバーサイド検証が本質的にカスタマイズされていることが困難な結果となっています。Firebase には、ユーザーが単純なスクリプト クライアントを介してゲームをコピーすることを防ぐ App Check などの不正使用対策ツールがありますが、総合的な不正行為対策となるサービスは提供されていません。

サーバーサイドの検証を行わないと、人気のあるゲームや不正行為の障壁が十分に低いゲームでは、リーダーボードで上位値がすべてチーターになります。

8. 完了

これで、Firebase で 4 つの異なるリーダーボードを作成できました。正確性と速度に対するゲームニーズに応じて、妥当なコストで最適なものを選択できます。

次は、ゲームの学習プログラムをご覧ください。