Criar placares com o Firestore

1. Introdução

Última atualização:27/01/2023

O que é preciso para criar um ranking?

Em essência, os placares são apenas tabelas de pontuações com um fator complicado: a leitura de uma classificação de qualquer pontuação requer o conhecimento de todas as outras pontuações em algum tipo de ordem. Além disso, se o jogo decolar, seus rankings vão crescer e ser lidos e gravados com frequência. Para construir um quadro de liderança bem-sucedido, ele precisa ser capaz de lidar com essa operação de classificação rapidamente.

O que você vai criar

Neste codelab, você implementará vários placares diferentes, cada um adequado para um cenário diferente.

Conteúdo

Você aprenderá a implementar quatro placares diferentes:

  • Uma implementação simples que usa contagem de registros simples para determinar a classificação.
  • Um placar barato com atualizações periódicas
  • Um quadro de liderança em tempo real com dados sem sentido
  • Um ranking estocástico (probabilístico) para uma classificação aproximada de bases de jogadores muito grandes

Pré-requisitos

  • Uma versão recente do Chrome (107 ou mais recente)
  • Node.js 16 ou mais recente (execute nvm --version para ver o número da versão se você estiver usando o nvm)
  • Um plano pago do Firebase Blaze (opcional)
  • A CLI do Firebase v11.16.0 ou mais recente
    Para instalar a CLI, execute npm install -g firebase-tools ou consulte a documentação da CLI para mais opções de instalação.
  • Conhecimento sobre JavaScript, Cloud Firestore, Cloud Functions e Chrome DevTools.

2. Etapas da configuração

Buscar o código

Colocamos tudo o que você precisa para este projeto em um repositório Git. Para começar, abra o código no seu ambiente de desenvolvimento favorito. Neste codelab, usamos o VS Code, mas qualquer editor de texto serve.

e descompacte o arquivo ZIP salvo.

Você também pode clonar o diretório que preferir:

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

Qual é nosso ponto de partida?

No momento, nosso projeto é uma folha em branco com algumas funções vazias:

  • index.html contém alguns scripts agrupadores que nos permitem invocar funções do console do desenvolvedor e ver as saídas. Ele será usado para interagir com o back-end e ver os resultados das invocações da função. Em um cenário real, você faria essas chamadas de back-end diretamente no seu jogo. Não estamos usando um jogo neste codelab porque levaria muito tempo para jogar cada vez que você quisesse adicionar uma pontuação ao placar.
  • functions/index.js contém todas as funções do Cloud. Você vai conhecer algumas funções utilitárias, como addScores e deleteScores, além das funções que implementaremos neste codelab e que chamam funções auxiliares em outro arquivo.
  • functions/functions-helpers.js contém as funções vazias que vamos implementar. Para cada quadro de liderança, vamos implementar funções de leitura, criação e atualização. Você verá como nossa escolha de implementação afeta a complexidade da implementação e o desempenho de escalonamento.
  • functions/utils.js contém mais funções utilitárias. Não vamos mexer nesse arquivo neste codelab.

Criar e configurar um projeto do Firebase

  1. No Console do Firebase, clique em Adicionar projeto.
  2. Para criar um novo projeto, digite o nome dele.
    Isso também definirá o ID do projeto (exibido abaixo do nome do projeto) como algo baseado no nome dele. Clique no ícone de edição no ID do projeto para personalizá-lo ainda mais.
  3. Se solicitado, leia e aceite os Termos do Firebase.
  4. Clique em Continuar.
  5. Selecione a opção Ativar o Google Analytics para este projeto e clique em Continuar.
  6. Selecione uma conta existente do Google Analytics para usar ou selecione Criar uma nova conta.
  7. Clique em Criar projeto.
  8. Quando o projeto for criado, clique em Continuar.
  9. No menu Build, clique em Functions e, se solicitado, faça upgrade do seu projeto para usar o plano de faturamento Blaze.
  10. No menu Build, clique em Banco de dados do Firestore.
  11. Na caixa de diálogo Criar banco de dados exibida, selecione Iniciar no modo de teste e clique em Próximo.
  12. Escolha uma região na lista suspensa Local do Cloud Firestore e clique em Ativar.

Configure e execute seu quadro de liderança

  1. Em um terminal, navegue até a raiz do projeto e execute firebase use --add. Escolha o projeto do Firebase que você acabou de criar.
  2. Na raiz do projeto, execute firebase emulators:start --only hosting.
  3. No navegador, acesse localhost:5000.
  4. Abra o console JavaScript do Chrome DevTools e importe leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Execute leaderboard.codelab(); no console. Se você receber uma mensagem de boas-vindas, a configuração estará concluída. Caso contrário, desligue o emulador e execute novamente as etapas 2 a 4.

Vamos passar para a primeira implementação do quadro de liderança.

3. Implementar um placar simples

Ao final desta seção, poderemos adicionar uma pontuação ao quadro de liderança e fazer com que ela nos informe nossa classificação.

Antes de começar, vamos explicar como funciona essa implementação de placar: todos os jogadores são armazenados em uma única coleção, e a busca da classificação de um jogador é feita recuperando a coleção e contando quantos jogadores estão à frente deles. Isso facilita a inserção e a atualização de uma pontuação. Para inserir uma nova pontuação, basta anexá-la à coleção e atualizá-la, filtramos o usuário atual e atualizamos o documento resultante. Vamos conferir como isso fica no código.

No functions/functions-helper.js, implemente a função createScore, que é tão simples quanto possível:

async function createScore(score, playerID, firestore) {
  return firestore.collection("scores").doc().create({
    user: playerID,
    score: score,
  });
}

Para atualizar as pontuações, basta adicionar uma verificação de erro para garantir que a pontuação que está sendo atualizada já existe:

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

E, finalmente, nossa função de classificação simples, mas menos escalonável:

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

Vamos testar! Implante as funções executando o seguinte no terminal:

firebase deploy --only functions

Depois, no console JS do Chrome, adicione outras pontuações para que possamos ver nossa classificação entre os outros jogadores.

leaderboard.addScores(); // Results may take some time to appear.

Agora podemos adicionar nossa própria pontuação à mistura:

leaderboard.addScore(999, 11); // You can make up a score (second argument) here.

Quando a gravação for concluída, você verá uma resposta no console que diz "Pontuação criada". Você está vendo um erro? Abra os registros do Functions pelo Console do Firebase para ver o que deu errado.

E, por fim, podemos buscar e atualizar nossa pontuação.

leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)

No entanto, essa implementação nos proporciona requisitos indesejáveis de tempo e memória lineares para buscar a classificação de uma determinada pontuação. Como o tempo de execução da função e a memória são limitados, isso não apenas significa que nossas buscas se tornam cada vez mais lentas, mas, depois que pontuações suficientes são adicionadas ao quadro de liderança, nossas funções expiram ou falham antes que possam retornar um resultado. Claramente precisaremos de algo melhor se formos expandir além de alguns jogadores.

Se você for um fã do Firestore, talvez conheça as consultas de agregação COUNT, que tornariam esse ranking muito mais eficiente. E você tem razão! Com COUNT consultas, a escala fica bem abaixo de um milhão de usuários, embora seu desempenho ainda seja linear.

Mas espere, você pode estar pensando: se vamos enumerar todos os documentos na coleção de qualquer maneira, podemos atribuir uma classificação a cada documento e, quando precisarmos buscá-lo, nossas buscas serão O(1) tempo e memória! Isso nos leva à nossa próxima abordagem, o ranking com atualização periódica.

4. Implemente um ranking com atualização periódica

A chave para essa abordagem é armazenar a classificação no próprio documento. Portanto, buscá-la nos fornece a classificação sem nenhum trabalho adicional. Para isso, vamos precisar de um novo tipo de função.

Em index.js, inclua o seguinte:

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

Agora, nossas operações de leitura, atualização e gravação estão bem simples e simples. A gravação e a atualização permanecem inalteradas, mas a leitura se torna (em 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"),
  };
}

Infelizmente, não é possível implantar e testar esse recurso sem adicionar uma conta de faturamento ao projeto. Se você tiver uma conta de faturamento, reduza o intervalo na função programada e confira como ela atribui classificações às pontuações do quadro de liderança.

Caso contrário, exclua a função programada e pule para a próxima implementação.

Exclua as pontuações do banco de dados do Firestore clicando nos três pontos ao lado da coleção e se prepare para a próxima seção.

O Firestore pontua a página do documento com a opção\n"Excluir coleção" ativada

5. Implementar um quadro de liderança em árvore em tempo real

Essa abordagem funciona armazenando dados de pesquisa na própria coleção do banco de dados. Em vez de ter uma coleção uniforme, nosso objetivo é armazenar tudo em uma árvore que possamos percorrer movendo-se documentos. Isso nos permite realizar uma pesquisa binária (ou n-ária) para a classificação de uma determinada pontuação. Como isso pode ser?

Para começar, queremos distribuir nossas pontuações em segmentos, o que exige certo conhecimento dos valores das pontuações que nossos usuários registram. Por exemplo, se você estiver construindo um quadro de liderança para a classificação de habilidades em um jogo competitivo, as classificações de habilidade de seus usuários quase sempre acabarão normalmente distribuídas. Nossa função aleatória de geração de pontuação usa a Math.random() do JavaScript, o que resulta em uma distribuição aproximadamente uniforme. Por isso, dividiremos nossos buckets uniformemente.

Neste exemplo, usaremos três buckets para simplificar, mas você provavelmente vai descobrir que, se usar essa implementação em um app real, mais buckets vão gerar resultados mais rápidos. Uma árvore mais superficial significa, em média, menos buscas de coleção e menos contenção de bloqueio.

A classificação de um jogador é determinada pela soma do número de jogadores com pontuações mais altas, mais uma para o próprio jogador. Cada coleção em scores armazenará três documentos, cada um com um intervalo, o número de documentos em cada intervalo e três subcoleções correspondentes. Para ler uma classificação, vamos atravessar esta árvore buscando uma pontuação e acompanhando a soma das maiores pontuações. Quando encontrarmos nossa pontuação, também teremos a soma correta.

Escrever é muito mais complicado. Primeiro, precisaremos fazer todas as nossas gravações dentro de uma transação para evitar inconsistências de dados quando várias gravações ou leituras ocorrerem ao mesmo tempo. Também precisaremos manter todas as condições descritas acima enquanto atravessamos a árvore para escrever nossos novos documentos. E, finalmente, como temos toda a complexidade de árvore dessa nova abordagem combinada com a necessidade de armazenar todos os documentos originais, nosso custo de armazenamento aumentará um pouco (mas ainda é linear).

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

Isso certamente é mais complicado do que nossa última implementação, que era uma única chamada de método e apenas seis linhas de código. Depois de implementar esse método, tente adicionar algumas pontuações ao banco de dados e observar a estrutura da árvore resultante. No console JS:

leaderboard.addScores();

A estrutura do banco de dados resultante deve ser semelhante a esta, com a estrutura de árvore claramente visível e as folhas da árvore representando as pontuações individuais.

scores
  - document
    range: 0-333.33
    count: 2
    scores:
      - document
        exact:
          score: 18
          user: 1
      - document
        exact:
          score: 22
          user: 2

Agora que temos a parte difícil, podemos ler as pontuações passando pela árvore, como descrito anteriormente.

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

As atualizações são deixadas como um exercício extra. Adicione e busque pontuações no Console JS com os métodos leaderboard.addScore(id, score) e leaderboard.getRank(id) e confira como seu ranking muda no Console do Firebase.

No entanto, com essa implementação, a complexidade que adicionamos para alcançar o desempenho logarítmico tem um custo.

  • Primeiro, essa implementação do placar pode enfrentar problemas de contenção de bloqueio, já que as transações exigem o bloqueio de leituras e gravações nos documentos para garantir que eles permaneçam consistentes.
  • Em segundo lugar, o Firestore impõe um limite de profundidade de subcoleção de 100, o que significa que você precisa evitar a criação de subárvores após 100 pontuações vinculadas, o que não acontece com essa implementação.
  • Por fim, esse placar é escalonado de forma logarítmica apenas no caso ideal em que a árvore está equilibrada. Se estiver desequilibrada, o pior desempenho desse ranking será linear.

Quando terminar, exclua as coleções scores e players pelo Console do Firebase, e vamos para a última implementação do ranking.

6. Implementar um quadro de liderança estocástico (probabilístico)

Ao executar o código de inserção, você pode perceber que se executá-lo muitas vezes em paralelo suas funções começarão a falhar com uma mensagem de erro relacionada à contenção de bloqueio de transação. Há maneiras de lidar com isso que não vamos abordar neste codelab, mas se você não precisar da classificação exata, é possível abandonar toda a complexidade da abordagem anterior para algo mais simples e mais rápido. Vamos conferir como podemos retornar uma classificação estimada para as pontuações dos jogadores em vez de uma classificação exata, e como isso muda a lógica do nosso banco de dados.

Para essa abordagem, dividiremos nosso ranking em 100 buckets, cada um representando aproximadamente 1% das pontuações que esperamos receber. Essa abordagem funciona mesmo sem o conhecimento da nossa distribuição de pontos. Nesse caso, não podemos garantir uma distribuição quase uniforme das pontuações em todo o bucket, mas conseguiremos maior precisão nas nossas aproximações se soubermos como as pontuações serão distribuídas.

Nossa abordagem é a seguinte: como antes, cada bucket armazena a contagem do número de pontuações dentro e o intervalo das pontuações. Ao inserir uma nova pontuação, encontraremos o bucket da pontuação e incrementaremos sua contagem. Ao buscar uma classificação, apenas somaremos os buckets à frente e, em seguida, faremos a aproximação dentro do nosso bucket, em vez de pesquisar mais. Isso nos proporciona pesquisas e inserções de tempo muito boas constantes e requer muito menos código.

Primeiro, a inserção:

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

Você notará que esse código de inserção tem uma lógica para inicializar o estado do banco de dados na parte superior com um aviso para não fazer algo assim na produção. O código para inicialização não está protegido contra disputas, portanto, se você fizer isso, várias gravações simultâneas corromperiam seu banco de dados, fornecendo a você vários blocos duplicados.

Implante suas funções e, em seguida, execute uma inserção para inicializar todos os buckets com uma contagem de zero. Ele retornará um erro, que você pode ignorar.

leaderboard.addScore(999, 0); // The params aren't important here.

Agora que o banco de dados foi inicializado corretamente, podemos executar addScores e ver a estrutura dos dados no Console do Firebase. A estrutura resultante é muito mais plana do que nossa última implementação, embora sejam superficialmente semelhantes.

leaderboard.addScores();

E, agora, para ler as pontuações:

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

Como fizemos a função addScores gerar uma distribuição uniforme de pontuações e estamos usando interpolação linear nos buckets, conseguiremos resultados muito precisos, o desempenho do nosso ranking não será prejudicado à medida que aumentarmos o número de usuários, e não precisamos nos preocupar com a contenção de bloqueio ao atualizar as contagens.

7. Adendo: trapaça

Talvez você esteja pensando: se eu escrevo valores para meu codelab pelo console JS de uma guia do navegador, algum dos meus jogadores não pode simplesmente mentir para o quadro de liderança e dizer que teve uma pontuação alta, mas não alcançou o suficiente?

Sim, é possível. Se quiser evitar trapaças, a maneira mais robusta de fazer isso é desativar as gravações de clientes no banco de dados com regras de segurança, proteger o acesso ao Cloud Functions para que os clientes não possam chamá-los diretamente e validar as ações no jogo antes de enviar atualizações de pontuação ao placar.

É importante notar que essa estratégia não é uma panaceia contra trapaças. Com um incentivo grande o suficiente, os trapaceiros podem encontrar maneiras de contornar as validações do lado do servidor, e muitos jogos de grande sucesso estão sempre jogando gato e rato com seus trapaceiros para identificar novas trapaças e impedi-las de proliferar. Uma consequência difícil desse fenômeno é que a validação do lado do servidor para todos os jogos é inerentemente personalizada. Embora o Firebase forneça ferramentas antiabuso, como o App Check, que impedem o usuário de copiar seu jogo por meio de um cliente com script simples, o Firebase não oferece nenhum serviço que equivale a um antiabuso holístico.

Qualquer coisa abaixo da validação do lado do servidor, para um jogo popular o suficiente ou com uma barreira baixa o suficiente para trapacear, resultará em um quadro de liderança em que os valores principais são todos trapaças.

8. Parabéns

Parabéns! Você criou quatro rankings diferentes no Firebase. Dependendo das necessidades de precisão e velocidade do seu jogo, você pode escolher uma que funcione a um custo razoável.

A seguir, confira os Programas de treinamentos para jogos.