Firestore でリーダーボードを構築する

1. はじめに

最終更新日: 2023-01-27

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

本質的に、リーダーボードは単なるスコアの表であり、1 つの複雑な要因があります。特定のスコアのランクを読み取るには、ある種の順序で他のすべてのスコアについての知識が必要です。また、ゲームが軌道に乗ると、リーダーボードが大きくなり、頻繁に読み書きされるようになります。成功するリーダーボードを構築するには、このランキング操作を迅速に処理できる必要があります。

何を構築するか

このコードラボでは、それぞれが異なるシナリオに適したさまざまなリーダーボードを実装します。

学べること

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 リポジトリにまとめました。始めるには、コードを取得して、お気に入りの開発環境で開く必要があります。このコードラボでは VS Code を使用しましたが、任意のテキスト エディターで使用できます。

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

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

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

私たちの出発点は何でしょうか?

私たちのプロジェクトは現在、いくつかの空の関数を含む白紙の状態です。

  • index.html開発コンソールから関数を呼び出してその出力を確認できるようにするいくつかのグルー スクリプトが含まれています。これを使用してバックエンドと接続し、関数呼び出しの結果を確認します。現実のシナリオでは、これらのバックエンド呼び出しをゲームから直接行うことになります。リーダーボードにスコアを追加するたびにゲームをプレイすると時間がかかりすぎるため、このコードラボではゲームを使用しません。 。
  • functions/index.jsすべての Cloud Functions が含まれています。 addScoresdeleteScoresなどのいくつかのユーティリティ関数と、このコードラボで実装する別のファイルのヘルパー関数を呼び出す関数が表示されます。
  • functions/functions-helpers.js実装する空の関数が含まれています。各リーダーボードに対して、読み取り、作成、および更新関数を実装します。実装の選択が実装の複雑さとスケーリング パフォーマンスの両方にどのように影響するかがわかります。
  • functions/utils.jsは、さらに多くのユーティリティ関数が含まれています。このコードラボではこのファイルには触れません。

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

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

書き込みが完了すると、コンソールに「スコアが作成されました」という応答が表示されます。代わりにエラーが表示されますか? 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 データベース内のスコアを削除してください。

Firestore scores document page with\nDelete Collection activated

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

このアプローチは、検索データをデータベース コレクション自体に保存することで機能します。私たちの目標は、均一なコレクションを用意するのではなく、ドキュメント内を移動することによってたどることができるツリーにすべてを保存することです。これにより、特定のスコアのランクについて二項 (または 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,
  };
}

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

ただし、この実装では、対数パフォーマンスを達成するために追加した複雑さによってコストがかかります。

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

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

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

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

このアプローチでは、リーダーボードを 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 コンソールを介してコードラボに値を書き込んでいる場合、プレーヤーの誰もがリーダーボードに嘘をつき、高スコアを獲得していないと言うことができるのではないかと考えているかもしれません。公平に達成しますか?

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

この戦略は不正行為に対する万能薬ではないことに注意することが重要です。十分なインセンティブがあれば、不正行為者はサーバー側の検証を回避する方法を見つけることができ、多くの大規模で成功したビデオゲームは不正行為者を特定するために絶えず不正行為者とのいたちごっこをしています。新しいチートを発見し、その蔓延を阻止します。この現象の難しい結果は、すべてのゲームのサーバー側の検証が本質的にオーダーメイドであることです。 Firebase は、ユーザーが単純なスクリプト クライアントを介してゲームをコピーすることを防ぐ App Check などの不正行為防止ツールを提供していますが、総合的な不正行為防止に相当するサービスは提供していません。

サーバー側での検証が不十分な場合、十分に人気のあるゲームや不正行為に対する障壁が十分に低い場合、上位の値がすべて不正行為者であるリーダーボードが作成されます。

8. おめでとうございます

おめでとうございます。Firebase 上に 4 つの異なるリーダーボードを構築することができました。ゲームの正確性と速度のニーズに応じて、手頃な価格で自分に合ったものを選択できます。

次に、ゲームの学習経路を確認してください。