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

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

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

次のセクションの準備として、スコア コレクションの横にある 3 つの点をクリックして、Firestore データベース内のスコアを削除します。

Firestore が Delete Collection を有効にしてドキュメント ページをスコアリングする

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 コンソールでリーダーボードがどのように変化するかを確認します。

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

  • まず、このリーダーボードの実装では、トランザクションでドキュメントの読み取りと書き込みをロックして整合性を維持する必要があるため、ロックの競合が発生する可能性があります。
  • 第 2 に、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 コンソールからコードラボに値を書き込む場合、プレーヤーがリーダーボードに嘘のスコアを報告して、不正な方法で高スコアを獲得したと主張する可能性はないのでしょうか?

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

この戦略は不正行為に対する万能薬ではないことに注意してください。十分なインセンティブがあれば、不正行為者はサーバーサイドの検証を回避する方法を見つけることができます。多くの大規模で成功しているビデオゲームは、新しい不正行為を特定して拡散を防ぐために、不正行為者と常にいたちごっこを繰り広げています。この現象の難しい結果として、すべてのゲームのサーバーサイド検証は本質的にカスタムメイドになります。Firebase は、簡単なスクリプト クライアントを介してユーザーがゲームをコピーするのを防ぐ App Check などの不正使用防止ツールを提供していますが、包括的な不正行為防止に相当するサービスは提供していません。

サーバーサイドの検証が不十分な場合、人気のあるゲームや不正行為のハードルが低いゲームでは、上位の値がすべて不正行為者で埋め尽くされたランキングになってしまいます。

8. 完了

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

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