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. [Build] メニューで、[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,
  });
}

最後に、シンプルだがスケーラビリティに欠けるランク関数を示します。

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"),
  };
}

申し訳ございませんが、プロジェクトに請求先アカウントを追加しないと、このデプロイとテストを行うことはできません。課金アカウントをお持ちの場合は、スケジュール設定された関数の間隔を短くして、関数によってリーダーボードのスコアにランクが自動的に割り当てられる様子を確認できます。

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

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

Firestore が、コレクションの削除が有効になっているドキュメント ページをスコアリングする

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

このアプローチは、検索データをデータベース コレクション自体に格納することで機能します。均一なコレクションではなく、ドキュメント間を移動して走査できるツリーにすべてを保存することを目標としています。これにより、特定のスコアのランクに対してバイナリ(または 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,
      });
    });
  });
}

これは、単一のメソッド呼び出しと 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 つの異なるリーダーボードを作成できました。正確性と速度に対するゲームニーズに応じて、妥当なコストで最適なものを選択できます。

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