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 には、実装する空の関数が含まれています。リーダーボードごとに、読み取り、作成、更新の関数を実装します。実装の選択が、実装の複雑さとそのスケーリング パフォーマンスにどのように影響するかを説明します。
  • functions/utils.js には、その他のユーティリティ関数が含まれています。この Codelab では、このファイルには触れません。

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

  1. Firebase コンソールで [プロジェクトを追加] をクリックします。
  2. 新しいプロジェクトを作成するには、目的のプロジェクト名を入力します。
    これにより、プロジェクト ID(プロジェクト名の下に表示)もプロジェクト名に基づいたものに設定されます。必要に応じて、プロジェクト ID の編集アイコンをクリックして、さらにカスタマイズできます。
  3. Firebase の利用規約が表示されたら、内容を読み、同意します。
  4. [続行] をクリックします。
  5. [このプロジェクトで Google アナリティクスを有効にする] オプションを選択し、[続行] をクリックします。
  6. 使用する既存の Google アナリティクス アカウントを選択するか、[新しいアカウントを作成] を選択して新しいアカウントを作成します。
  7. [プロジェクトの作成] をクリックします。
  8. プロジェクトが作成されたら、[続行] をクリックします。
  9. [構築] メニューから [関数] をクリックします。プロンプトが表示されたら、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,
  });
}

最後に、シンプルながらスケーラビリティは低いランク関数です。

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.

書き込みが完了すると、コンソールに「Score created」というレスポンスが表示されます。エラーが発生した場合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. リアルタイムのツリー リーダーボードを実装する

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

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

この例では、わかりやすくするために 3 つのバケットを使用しますが、実際のアプリでこの実装を使用すると、バケットが多いほど結果が速くなる可能性があります。ツリーが浅いほど、平均でコレクションの取得が少なくなり、ロック競合も少なくなります。

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

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

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 コンソールで scoresplayers のコレクションを削除します。最後のリーダーボードの実装に進みます。

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 などの不正使用対策ツールが用意されていますが、Firebase は包括的な不正対策に相当するサービスは提供していません。

高い人気を誇るゲームでも、不正行為を許容できるほどハードルが低いゲームでも、サーバーサイドで検証できないものがあれば、ランキングの上位の値がすべて不正行為者になります。

8. 完了

これで、Firebase で 4 種類のリーダーボードの作成が完了しました。正確さとスピードに対するゲームのニーズに応じて、妥当な費用で適切なものを選択できます。

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