Crie placares de classificação com o Firestore

1. Introdução

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

O que é necessário para construir uma tabela de classificação?

Em sua essência, as tabelas de classificação são apenas tabelas de pontuações com um fator complicador: a leitura de uma classificação para qualquer pontuação requer o conhecimento de todas as outras pontuações em algum tipo de ordem. Além disso, se o seu jogo decolar, suas tabelas de classificação crescerão e serão lidas e gravadas com frequência. Para construir uma tabela de classificação de sucesso, ela precisa ser capaz de lidar rapidamente com essa operação de classificação.

O que você construirá

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

O que você aprenderá

Você aprenderá como implementar quatro tabelas de classificação diferentes:

  • Uma implementação ingênua usando contagem simples de registros para determinar a classificação
  • Uma tabela de classificação barata e atualizada periodicamente
  • Uma tabela de classificação em tempo real com algumas bobagens sobre árvores
  • Uma tabela de classificação estocástica (probabilística) para classificação aproximada de bases de jogadores muito grandes

O que você precisará

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

2. Preparando-se

Obtenha o código

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

e descompacte o arquivo zip baixado.

Ou clone no diretório de sua escolha:

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

Qual é o nosso ponto de partida?

Nosso projeto é atualmente uma tela em branco com algumas funções vazias:

  • index.html contém alguns scripts cola que nos permitem invocar funções do console de desenvolvimento e ver suas saídas. Usaremos isso para fazer interface com nosso back-end e ver os resultados de nossas invocações de função. Em um cenário do mundo real, você faria essas chamadas de back-end diretamente do seu jogo. Não estamos usando um jogo neste codelab porque levaria muito tempo para jogar sempre que você quisesse adicionar uma pontuação ao placar. .
  • functions/index.js contém todas as nossas funções do Cloud. Você verá algumas funções utilitárias, como addScores e deleteScores , bem como as funções que implementaremos neste codelab, que chamam funções auxiliares em outro arquivo.
  • functions/functions-helpers.js contém as funções vazias que implementaremos. Para cada tabela de classificação, implementaremos funções de leitura, criação e atualização, e você verá como nossa escolha de implementação afeta tanto a complexidade de nossa implementação quanto seu desempenho de escalonamento.
  • functions/utils.js contém mais funções utilitárias. Não abordaremos esse arquivo neste codelab.

Crie e configure um projeto do Firebase

  1. No console do Firebase , clique em Adicionar projeto .
  2. Para criar um novo projeto, insira o nome do projeto desejado.
    Isso também definirá o ID do projeto (exibido abaixo do nome do projeto) como algo baseado no nome do projeto. Opcionalmente, você pode clicar no ícone de edição no ID do projeto para personalizá-lo ainda mais.
  3. Se solicitado, revise e aceite os termos do Firebase .
  4. Clique em Continuar .
  5. Selecione a opção Ativar 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 para criar uma nova conta.
  7. Clique em Criar projeto .
  8. Quando o projeto for criado, clique em Continuar .
  9. No menu Construir , clique em Funções e, se solicitado, atualize seu projeto para usar o plano de faturamento Blaze.
  10. No menu Criar , clique em Banco de dados Firestore .
  11. Na caixa de diálogo Criar banco de dados exibida, selecione Iniciar em modo de teste e clique em Avançar .
  12. Escolha uma região no menu suspenso de localização do Cloud Firestore e clique em Habilitar .

Configure e execute seu placar

  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 seu navegador, navegue até 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ê vir uma mensagem de boas-vindas, está tudo pronto! Caso contrário, desligue o emulador e execute novamente as etapas 2 a 4.

Vamos pular para a primeira implementação do placar.

3. Implemente um placar simples

Ao final desta seção, poderemos adicionar uma pontuação à tabela de classificação e fazer com que ela nos indique nossa classificação.

Antes de começarmos, vamos explicar como funciona essa implementação da tabela de classificação: Todos os jogadores são armazenados em uma única coleção, e a obtenção 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 atualização de uma pontuação. Para inserir uma nova pontuação, basta anexá-la à coleção e, para atualizá-la, filtrar nosso usuário atual e atualizar o documento resultante. Vamos ver como isso fica no código.

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

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

Para atualizar pontuações, só precisamos 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 escalá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 colocar isso à prova! Implante suas funções executando o seguinte no terminal:

firebase deploy --only functions

E então, no console JS do Chrome, adicione algumas outras pontuações para que possamos ver nossa classificação entre 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 dizendo "Pontuação criada". Em vez disso, está vendo um erro? Abra os logs do Functions por meio do console do Firebase para ver o que deu errado.

E, finalmente, 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, esta implementação nos dá requisitos indesejáveis ​​de tempo linear e memória para obter 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 significará que nossas buscas se tornarão cada vez mais lentas, mas depois que pontuações suficientes forem adicionadas à tabela de classificação, nossas funções atingirão o tempo limite ou travarão antes que possam retornar um resultado. Claramente, precisaremos de algo melhor se quisermos ir além de um punhado de jogadores.

Se você é um aficionado do Firestore, talvez conheça COUNT consultas de agregação , o que tornaria esse placar com muito melhor desempenho. E você estaria certo! Com consultas COUNT, isso fica bem abaixo de um milhão de usuários, embora seu desempenho ainda seja linear.

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

4. Implemente um placar atualizado periodicamente

A chave para esta abordagem é armazenar a classificação no próprio documento, portanto, buscá-la nos dá a classificação sem nenhum trabalho adicional. Para conseguir isso, precisaremos de um novo tipo de função.

Em index.js , adicione 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 são todas agradáveis ​​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, você não poderá implantar e testar isso sem adicionar uma conta de faturamento ao seu projeto. Se você tiver uma conta de faturamento, reduza o intervalo da função agendada e observe sua função atribuir classificações magicamente às pontuações da tabela de classificação.

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

Vá em frente e exclua as pontuações do seu banco de dados do Firestore clicando nos 3 pontos ao lado da coleção de pontuações para se preparar para a próxima seção.

Firestore scores document page with\nDelete Collection activated

5. Implemente um placar de árvore em tempo real

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

Para começar, queremos ser capazes de distribuir nossas pontuações em grupos aproximadamente iguais, o que exigirá algum conhecimento dos valores das pontuações que nossos usuários estão registrando; por exemplo, se você estiver criando uma tabela de classificação para classificação de habilidades em um jogo competitivo, as classificações de habilidades dos seus usuários quase sempre acabarão distribuídas normalmente. Nossa função de geração de pontuação aleatória usa Math.random() do JavaScript, o que resulta em uma distribuição aproximadamente uniforme, portanto, dividiremos nossos intervalos igualmente.

Neste exemplo, usaremos três buckets para simplificar, mas você provavelmente descobrirá que, se usar essa implementação em um aplicativo real, mais buckets produzirão resultados mais rápidos – uma árvore mais rasa significa, em média, menos buscas de coleção e menos contenção de bloqueio.

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

Escrever é significativamente mais complicado. Primeiro, precisaremos fazer todas as nossas gravações em uma transação para evitar inconsistências de dados quando ocorrerem várias gravações ou leituras ao mesmo tempo. Também precisaremos manter todas as condições descritas acima enquanto percorremos a árvore para escrever nossos novos documentos. E, finalmente, como temos toda a complexidade desta nova abordagem combinada com a necessidade de armazenar todos os nossos documentos originais, o nosso custo de armazenamento aumentará ligeiramente (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 consistia em 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 seu console JS:

leaderboard.addScores();

A estrutura do banco de dados resultante deve ser semelhante a esta, com a estrutura em árvore claramente visível e as folhas da árvore representando 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 resolvemos a parte difícil, podemos ler as pontuações percorrendo a árvore conforme 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 ficam como um exercício extra. Tente adicionar e buscar pontuações em seu console JS com os métodos leaderboard.addScore(id, score) e leaderboard.getRank(id) e veja como seu placar muda no console do Firebase.

Com esta implementação, entretanto, a complexidade que adicionamos para alcançar o desempenho logarítmico tem um custo.

  • Primeiro, essa implementação de tabela de classificação pode gerar problemas de contenção de bloqueio, uma vez que as transações exigem o bloqueio de leituras e gravações em documentos para garantir que permaneçam consistentes.
  • Em segundo lugar, o Firestore impõe um limite de profundidade de subcoleção de 100 , o que significa que você precisará evitar a criação de subárvores após 100 pontuações empatadas, o que esta implementação não faz.
  • E, finalmente, esta tabela de classificação é dimensionada logaritmicamente apenas no caso ideal em que a árvore está balanceada – se estiver desequilibrada, o pior caso de desempenho desta tabela de classificação é mais uma vez linear.

Quando terminar, exclua as scores e coleções players por meio do console do Firebase e passaremos para nossa última implementação de tabela de classificação.

6. Implementar um placar estocástico (probabilístico)

Ao executar o código de inserção, você poderá 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. Existem maneiras de contornar isso que não exploraremos neste codelab, mas se você não precisar de uma classificação exata, poderá descartar toda a complexidade da abordagem anterior para algo mais simples e rápido. Vamos dar uma olhada em como podemos retornar uma classificação estimada para as pontuações dos nossos jogadores em vez de uma classificação exata, e como isso muda a lógica do nosso banco de dados.

Para esta abordagem, dividiremos nossa tabela de classificação em 100 grupos, cada um representando aproximadamente um por cento das pontuações que esperamos receber. Essa abordagem funciona mesmo sem o conhecimento de nossa distribuição de pontuação; nesse caso, não temos como garantir uma distribuição aproximadamente uniforme de pontuações em todo o intervalo, mas alcançaremos maior precisão em nossas aproximações se soubermos como nossas pontuações serão distribuídas .

Nossa abordagem é a seguinte: como antes, cada balde armazena a contagem do número de pontuações e o intervalo das pontuações. Ao inserir uma nova pontuação, encontraremos o intervalo da pontuação e aumentaremos sua contagem. Ao buscar uma classificação, apenas somaremos os grupos à frente dela e, em seguida, aproximaremos nosso intervalo, em vez de pesquisar mais. Isso nos dá ótimas pesquisas e inserções em tempo constante e requer muito menos código.

Primeiro, 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 este código de inserção tem alguma lógica para inicializar o estado do seu 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 é protegido contra condições de corrida; portanto, se você fizesse isso, várias gravações simultâneas corromperiam seu banco de dados, fornecendo vários buckets duplicados.

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

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 de nossos dados no console do Firebase. A estrutura resultante é muito mais plana do que a 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 dentro dos intervalos, obteremos resultados muito precisos, o desempenho do nosso placar não será prejudicado à medida que aumentarmos o número de usuários, e não precisamos nos preocupar (tanto) com a contenção de bloqueios ao atualizar as contagens.

7. Adendo: Trapaça

Espere aí, você pode estar pensando: se estou escrevendo valores no meu codelab por meio do console JS de uma guia do navegador, nenhum dos meus jogadores pode simplesmente mentir para o placar e dizer que obteve uma pontuação alta que não obteve? conseguir de forma justa?

Sim eles podem. Se você quiser evitar trapaças, a maneira mais robusta de fazer isso é desabilitar as gravações do cliente no seu banco de dados por meio de regras de segurança , proteger o acesso às suas Cloud Functions para que os clientes não possam chamá-las diretamente e, em seguida, validar as ações do jogo no seu servidor antes enviando atualizações de pontuação para a tabela de classificação.

É importante notar que esta estratégia não é uma panacéia contra a trapaça – com um incentivo grande o suficiente, os trapaceiros podem encontrar maneiras de contornar as validações do lado do servidor, e muitos videogames grandes e bem-sucedidos estão constantemente brincando de gato e rato com seus trapaceiros para identificar. novos cheats e impedi-los de proliferar. Uma consequência difícil deste fenómeno é que a validação do lado do servidor para cada jogo é inerentemente personalizada; embora o Firebase forneça ferramentas antiabuso, como o App Check, que impedirá que um usuário copie seu jogo por meio de um cliente simples com script, o Firebase não fornece nenhum serviço que represente um anti-cheat holístico.

Qualquer coisa que não seja a validação do lado do servidor, para um jogo suficientemente popular ou com uma barreira baixa o suficiente para trapaças, resultará em uma tabela de classificação onde os valores mais altos são todos trapaceiros.

8. Parabéns

Parabéns, você construiu com sucesso quatro placares diferentes no Firebase! Dependendo das necessidades de exatidão e velocidade do seu jogo, você poderá escolher um que funcione para você a um custo razoável.

A seguir, confira os caminhos de aprendizagem para jogos.