使用 Firestore 建構排行榜

1. 簡介

上次更新時間:2023 年 1 月 27 日

建立排行榜需要哪些條件?

排行榜本質上就是分數表,但有一個複雜因素:如要讀取任何給定分數的排名,必須知道所有其他分數的某種順序。此外,如果遊戲大受歡迎,排行榜就會越來越大,而且會經常讀取及寫入資料。如要建立成功的排行榜,必須能快速處理這項排名作業。

建構項目

在本程式碼研究室中,您將實作各種不同的排行榜,每種排行榜都適合不同的情境。

課程內容

您將瞭解如何實作四種不同的排行榜:

  • 使用簡單的記錄計數來判斷排名的簡單實作方式
  • 便宜且定期更新的排行榜
  • 即時排行榜,但有些樹木相關的無稽之談
  • 隨機 (機率) 排行榜,可大致排列大量玩家的排名

事前準備

  • 新版 Chrome (107 以上版本)
  • Node.js 16 以上版本 (如果使用 nvm,請執行 nvm --version 查看版本號碼)
  • 付費的 Firebase Blaze 方案 (選用)
  • Firebase CLI 11.16.0 以上版本
    如要安裝 CLI,可以執行 npm install -g firebase-tools,或參閱 CLI 說明文件瞭解更多安裝選項。
  • 熟悉 JavaScript、Cloud Firestore、Cloud Functions 和 Chrome 開發人員工具

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 專案

建立新的 Firebase 專案

  1. 使用 Google 帳戶登入 Firebase 控制台
  2. 按一下按鈕建立新專案,然後輸入專案名稱 (例如 Leaderboards Codelab)。
  3. 按一下「繼續」
  4. 如果系統提示,請詳閱並接受 Firebase 條款,然後按一下「繼續」
  5. (選用) 在 Firebase 控制台中啟用 AI 輔助功能 (稱為「Gemini in Firebase」)。
  6. 本程式碼研究室不需要 Google Analytics,因此請關閉 Google Analytics 選項。
  7. 按一下「建立專案」,等待專案佈建完成,然後按一下「繼續」

設定 Firebase 產品

  1. 在「Build」選單中,按一下「Functions」,並視需要將專案升級至 Blaze 方案。
  2. 在「建構」選單中,按一下「Firestore 資料庫」
  3. 在隨即顯示的「建立資料庫」對話方塊中,選取「以測試模式啟動」,然後按一下「下一步」
  4. 從「Cloud Firestore location」(Cloud Firestore 位置) 下拉式選單中選擇區域,然後按一下「Enable」(啟用)

設定及執行排行榜

  1. 在終端機中,前往專案根目錄並執行 firebase use --add。選擇您剛建立的 Firebase 專案。
  2. 在專案的根目錄中,執行 firebase emulators:start --only hosting
  3. 在瀏覽器中前往 localhost:5000
  4. 開啟 Chrome 開發人員工具的 JavaScript 控制台,然後匯入 leaderboard.js
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. 在控制台中執行 leaderboard.codelab();。如果看到歡迎訊息,表示一切就緒!如果沒有,請關閉模擬器,然後重新執行步驟 2 到 4。

我們來實作第一個排行榜。

3. 實作簡易排行榜

本節結束時,我們就能將分數新增至排行榜,並顯示我們的排名。

開始之前,請先瞭解排行榜的實作方式:所有玩家都會儲存在單一集合中,如要擷取玩家的排名,只要擷取集合並計算有多少玩家領先即可。因此插入和更新分數非常簡單。如要插入新分數,只要將分數附加至集合即可;如要更新分數,請篩選目前使用者,然後更新產生的文件。我們來看看程式碼的樣子。

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 查詢時,這項功能可順利擴充至約一百萬名使用者,但效能仍為線性。

但請稍等,您可能會想,如果我們無論如何都要列舉集合中的所有文件,那麼可以為每個文件指派排名,然後在需要擷取文件時,擷取作業的時間和記憶體複雜度就會是 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 個 bucket,但您可能會發現,在實際應用程式中使用這項實作時,bucket 越多,結果就越快出爐,因為樹狀結構越淺,平均來說,擷取的集合就越少,鎖定爭用也越少。

玩家的排名等於分數較高的玩家人數加上 1 (玩家本人)。scores 下的每個集合都會儲存三份文件,每份文件都有一個範圍、每個範圍下的文件數量,以及三個對應的子集合。如要讀取排名,我們會遍歷這棵樹,搜尋分數並追蹤較高分數的總和。找到分數後,我們也會得到正確的總和。

寫入作業會複雜許多。首先,我們需要在交易中進行所有寫入作業,以防止同時發生多個寫入或讀取作業時,出現資料不一致的情況。在遍歷樹狀結構以撰寫新文件時,我們也需要維持上述所有條件。最後,由於我們採用新方法後,樹狀結構的複雜度會提高,且需要儲存所有原始文件,因此儲存空間成本會稍微增加 (但仍為線性)。

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

這當然比我們上次的實作方式複雜,上次只要呼叫單一方法,而且只有六行程式碼。實作這個方法後,請嘗試在資料庫中新增幾筆分數,並觀察產生的樹狀結構。在 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%。即使不知道分數分布情況,這個方法也適用。在這種情況下,我們無法保證分數在儲存區中大致平均分布,但如果知道分數分布情況,就能更精確地估算。

我們的做法如下:與先前一樣,每個值區都會儲存分數範圍內的分數數量和分數範圍。插入新分數時,我們會找出該分數的 bucket 並遞增其計數。擷取排名時,我們會將該排名之前的儲存區加總,然後在儲存區內進行估算,而不是進一步搜尋。這可讓我們進行非常好的常數時間查閱和插入作業,而且需要的程式碼少得多。

首先,插入:

// 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();
    }
  }
}

您會發現這段插入程式碼在頂端有一些邏輯,用於初始化資料庫狀態,並警告您不要在正式環境中執行類似操作。初始化程式碼完全不會防範競爭狀況,因此如果您這麼做,多個並行寫入作業會提供一堆重複的 bucket,導致資料庫損毀。

請繼續部署函式,然後執行插入作業,將所有 bucket 初始化為零。系統會傳回錯誤,您可以放心忽略。

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 函式產生分數的均勻分布,且在 bucket 中使用線性插補,因此可獲得非常準確的結果,隨著使用者人數增加,排行榜的效能不會降低,更新計數時也不必擔心鎖定爭用 (在某種程度上)。

7. 附錄:作弊

等等,您可能會想,如果我透過瀏覽器分頁的 JS 控制台將值寫入程式碼研究室,那麼任何玩家是否都能向排行榜謊報,聲稱自己獲得高分,但其實是作弊?

可以。如要防止作弊,最有效的方法是透過安全性規則停用用戶端寫入資料庫的作業,安全存取 Cloud Functions,讓用戶端無法直接呼叫,然後在伺服器上驗證遊戲內動作,再將分數更新傳送至排行榜。

請注意,這項策略並非防止作弊的萬靈丹。只要誘因夠大,作弊者就能找到方法規避伺服器端驗證。許多大型熱門電玩遊戲都會不斷與作弊者鬥智,找出新的作弊手法並加以防堵。這項現象的嚴重後果是,每個遊戲的伺服器端驗證本質上都是客製化。雖然 Firebase 提供 App Check 等防濫用工具,可防止使用者透過簡單的指令碼用戶端複製遊戲,但 Firebase 並未提供任何可全面防作弊的服務。

如果遊戲夠熱門,或作弊門檻夠低,只要沒有伺服器端驗證,排行榜上的最高分就會全是作弊者。

8. 恭喜

恭喜,您已在 Firebase 上成功建構四種不同的排行榜!您可以根據遊戲對準確度和速度的需求,選擇合適的服務,並以合理的價格使用。

接著,請查看遊戲的學習路徑