1. Introdução
Última atualização:27/01/2023
O que é preciso para criar um ranking?
Basicamente, os rankings são apenas tabelas de pontuações com um fator complicado: para ler uma classificação de uma determinada pontuação, é necessário conhecer todas as outras pontuações em alguma ordem. Além disso, se o jogo fizer sucesso, as tabelas de classificação vão crescer e serão lidas e gravadas com frequência. Para criar um ranking de sucesso, ele precisa ser capaz de lidar com essa operação rapidamente.
O que você vai criar
Neste codelab, você vai implementar vários rankings diferentes, cada um adequado para um cenário diferente.
O que você vai aprender
Você vai aprender a implementar quatro placares diferentes:
- Uma implementação simples usando contagem de registros para determinar a classificação
- Um quadro de liderança barato e atualizado periodicamente
- Um ranking em tempo real com algumas bobagens sobre árvores
- Um ranking estocástico (probabilístico) para 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 conferir 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, executenpm install -g firebase-tools
ou consulte a documentação da CLI para mais opções de instalação. - Conhecimento de JavaScript, Cloud Firestore, Cloud Functions e Chrome DevTools
2. Etapas da configuração
Acessar 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 ambiente de desenvolvimento de sua preferência. 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 é nosso ponto de partida?
No momento, nosso projeto é uma tela em branco com algumas funções vazias:
- O
index.html
contém alguns scripts de junção que permitem invocar funções do console de desenvolvimento e conferir as saídas delas. Vamos usar isso para fazer interface com nosso back-end e ver os resultados das nossas invocações de função. Em um cenário real, você faria essas chamadas de back-end diretamente do jogo. Não estamos usando um jogo neste codelab porque levaria muito tempo para jogar toda vez que você quisesse adicionar uma pontuação ao ranking. functions/index.js
contém todas as nossas funções do Cloud. Você vai encontrar algumas funções utilitárias, comoaddScores
edeleteScores
, além das funções que vamos implementar neste codelab, que chamam funções auxiliares em outro arquivo.- O
functions/functions-helpers.js
contém as funções vazias que vamos implementar. Para cada ranking, vamos implementar funções de leitura, criação e atualização. Você vai ver como nossa escolha de implementação afeta a complexidade 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
- No Console do Firebase, clique em Adicionar projeto.
- Para criar um novo projeto, digite o nome dele.
Isso também vai definir o ID do projeto (mostrado abaixo do nome dele) com base no nome do projeto. Se quiser, clique no ícone de edição no ID do projeto para personalizar ainda mais. - Se solicitado, leia e aceite os Termos do Firebase.
- Clique em Continuar.
- Selecione a opção Ativar o Google Analytics para este projeto e clique em Continuar.
- Selecione uma conta do Google Analytics para usar ou clique em Criar uma conta para criar uma nova.
- Clique em Criar projeto.
- Quando o projeto for criado, clique em Continuar.
- No menu Build, clique em Functions e, se solicitado, faça upgrade do projeto para usar o plano de faturamento Blaze.
- No menu Build, clique em Banco de dados do Firestore.
- Na caixa de diálogo Criar banco de dados, selecione Iniciar no modo de teste e clique em Próxima.
- Escolha uma região no menu suspenso Local do Cloud Firestore e clique em Ativar.
Configurar e executar seu ranking
- Em um terminal, navegue até a raiz do projeto e execute
firebase use --add
. Escolha o projeto do Firebase que você acabou de criar. - Na raiz do projeto, execute
firebase emulators:start --only hosting
. - No navegador, acesse
localhost:5000
. - Abra o console JavaScript do Chrome DevTools e importe
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- Execute
leaderboard.codelab();
no console. Se você vir uma mensagem de boas-vindas, está tudo pronto. Se não estiver, desligue o emulador e repita as etapas de 2 a 4.
Vamos começar com a primeira implementação de um ranking.
3. Implementar um placar simples
Ao final desta seção, vamos adicionar uma pontuação ao ranking e saber nossa posiçã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 dele. Isso facilita a inserção e a atualização de uma pontuação. Para inserir uma nova pontuação, basta anexá-la à coleção. Para atualizar, filtramos o usuário atual e atualizamos o documento resultante. Vamos ver como isso fica no código.
Em functions/functions-helper.js
, implemente a função createScore
, que é bem simples:
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 erros para garantir que a pontuação a ser atualizada já exista:
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,
});
}
Por fim, 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
Em seguida, no console JS do Chrome, adicione outras pontuações para que possamos ver nosso ranking entre outros jogadores.
leaderboard.addScores(); // Results may take some time to appear.
Agora podemos adicionar nossa própria pontuação:
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
Quando a gravação for concluída, uma resposta vai aparecer no console dizendo "Score created". Está vendo um erro? Abra os registros de funções no console do Firebase para ver o que deu errado.
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 dá 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 e a memória da função são limitados, não só nossas buscas vão ficar cada vez mais lentas, mas, depois que forem adicionadas pontuações suficientes ao ranking, nossas funções vão atingir o tempo limite ou falhar antes de retornar um resultado. Claramente, vamos precisar de algo melhor se quisermos escalar além de alguns jogadores.
Se você é fã do Firestore, talvez conheça as consultas de agregação COUNT, que tornariam esse ranking muito mais eficiente. E você estaria certo! Com consultas COUNT, isso é dimensionado bem abaixo de um milhão de usuários, embora o desempenho ainda seja linear.
Mas espere, você pode estar pensando: se vamos enumerar todos os documentos da coleção de qualquer maneira, podemos atribuir uma classificação a cada um deles. Assim, quando precisarmos buscá-los, nossas buscas serão O(1) de tempo e memória. Isso nos leva à próxima abordagem, o ranking atualizado periodicamente.
4. Implementar um ranking que é atualizado periodicamente
A chave dessa abordagem é armazenar a classificação no próprio documento. Assim, ao buscá-lo, temos a classificação sem trabalho extra. 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 são simples e eficientes. As ações de gravação e 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 sem adicionar uma conta de faturamento ao projeto. Se você tiver uma conta de faturamento, diminua o intervalo na função programada e veja sua função atribuir magicamente classificações às pontuações do ranking.
Caso contrário, exclua a função programada e pule para a próxima implementação.
Clique nos três pontos ao lado da coleção de pontuações para excluir os dados do banco de dados do Firestore e se preparar para a próxima seção.
5. Implementar um ranking de árvores em tempo real
Essa abordagem funciona armazenando os 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 podemos percorrer movendo-nos pelos documentos. Isso permite realizar uma pesquisa binária (ou n-ária) para a classificação de uma determinada pontuação. Como isso funciona?
Para começar, vamos distribuir as pontuações em intervalos aproximadamente iguais, o que exige algum conhecimento dos valores das pontuações registradas pelos usuários. Por exemplo, se você estiver criando um ranking de classificação de habilidade em um jogo competitivo, as classificações dos usuários quase sempre serã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, vamos dividir os buckets de maneira uniforme.
Neste exemplo, vamos usar três buckets para simplificar, mas provavelmente você vai descobrir que, se usar essa implementação em um app real, mais buckets vão gerar resultados mais rápidos. Uma árvore mais rasa significa, em média, menos buscas de coleta e menos disputa de bloqueio.
A classificação de um jogador é dada pela soma do número de jogadores com pontuações mais altas, mais um para o próprio jogador. Cada coleção em scores
armazena 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 percorrer essa árvore procurando uma pontuação e acompanhando a soma das pontuações maiores. Quando encontrarmos nossa pontuação, também teremos a soma correta.
A escrita é muito mais complicada. Primeiro, precisamos fazer todas as gravações em uma transação para evitar inconsistências de dados quando várias gravações ou leituras ocorrem ao mesmo tempo. Também precisamos manter todas as condições descritas acima ao percorrer a árvore para gravar os novos documentos. Por fim, como temos toda a complexidade da árvore dessa nova abordagem combinada com a necessidade de armazenar todos os documentos originais, o custo de armazenamento vai aumentar um pouco, mas ainda será 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 de banco de dados resultante será parecida com esta, com a estrutura de árvore claramente visível e as folhas 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 a parte difícil já foi feita, 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 são deixadas como um exercício extra. Tente adicionar e buscar pontuações no console JS com os métodos leaderboard.addScore(id, score)
e leaderboard.getRank(id)
e veja como o 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 ranking pode ter problemas de disputa de bloqueio, já que as transações exigem bloqueio de leitura e gravação em documentos para garantir que eles permaneçam consistentes.
- Em segundo lugar, o Firestore impõe um limite de profundidade de subcoleção de 100. Isso significa que você precisa evitar a criação de subárvores após 100 pontuações empatadas, o que essa implementação não faz.
- Por fim, esse ranking é dimensionado de forma logarítmica apenas no caso ideal em que a árvore está balanceada. Se ela estiver desbalanceada, o pior caso de desempenho desse ranking será linear novamente.
Quando terminar, exclua as coleções scores
e players
no console do Firebase. Em seguida, vamos para a última implementação do ranking.
6. Implementar um ranking estocástico (probabilístico)
Ao executar o código de inserção, você pode notar que, se ele for executado muitas vezes em paralelo, as funções vão começar a falhar com uma mensagem de erro relacionada à disputa de bloqueio de transação. Existem maneiras de contornar isso que não vamos abordar neste codelab, mas, se você não precisar de uma classificação exata, poderá abandonar toda a complexidade da abordagem anterior por algo mais simples e rápido. Vamos ver como podemos retornar uma classificação estimada para as pontuações dos jogadores em vez de um ranking exato e como isso muda a lógica do nosso banco de dados.
Para essa abordagem, vamos dividir o ranking em 100 buckets, cada um representando aproximadamente 1% das pontuações que esperamos receber. Essa abordagem funciona mesmo sem o conhecimento da distribuição de pontuações. Nesse caso, não há como garantir uma distribuição aproximadamente uniforme de pontuações em todo o agrupamento. No entanto, vamos alcançar maior precisão nas 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, encontramos o agrupamento para ela e incrementamos a contagem. Ao buscar uma classificação, somamos os intervalos à frente dela e fazemos uma aproximação dentro do nosso intervalo em vez de pesquisar mais. Isso nos dá pesquisas e inserções de tempo constante muito boas e exige 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ê vai notar que esse código de inserção tem uma lógica para inicializar o estado do banco de dados na parte de cima com um aviso para não fazer algo assim em produção. O código de 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, resultando em vários buckets duplicados.
Implante suas funções e execute uma inserção para inicializar todos os intervalos com uma contagem de zero. Ele vai retornar um erro, que pode ser ignorado 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 conferir a estrutura dos nossos dados no console do Firebase. A estrutura resultante é muito mais simples do que nossa última implementação, embora sejam superficialmente semelhantes.
leaderboard.addScores();
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 a interpolação linear nos intervalos, vamos receber resultados muito precisos. O desempenho do ranking não vai diminuir à medida que aumentamos o número de usuários, e não precisamos nos preocupar (tanto) com a disputa de bloqueios ao atualizar as contagens.
7. Adendo: trapaça
Espere aí, você pode estar pensando, se eu estiver gravando valores no meu codelab usando o console JS de uma guia do navegador, qualquer um dos meus jogadores não pode simplesmente mentir para o ranking e dizer que conseguiu uma pontuação alta que não alcançou de forma justa?
Sim, é possível. Se quiser evitar trapaças, a maneira mais robusta de fazer isso é desativar as gravações do cliente no banco de dados usando regras de segurança, proteger o acesso ao Cloud Functions para que os clientes não possam chamá-los diretamente e, em seguida, validar as ações no jogo no servidor antes de enviar atualizações de pontuação para o ranking.
É importante observar que essa estratégia não é uma panaceia contra trapaças. Com um incentivo grande o suficiente, os trapaceiros podem encontrar maneiras de burlar as validações do lado do servidor, e muitos videogames grandes e de sucesso estão constantemente jogando gato e rato com os trapaceiros para identificar novas trapaças e impedir que elas se proliferem. Uma consequência difícil desse fenômeno é que a validação do lado do servidor para cada jogo é inerentemente personalizada. Embora o Firebase ofereça ferramentas de proteção contra abusos, como o App Check, que impede que um usuário copie seu jogo usando um cliente simples com script, o Firebase não oferece nenhum serviço que equivalha a uma proteção abrangente contra trapaças.
Qualquer coisa que não seja a validação do lado do servidor vai resultar, em um jogo popular o suficiente ou com uma barreira baixa o suficiente para trapaças, em um ranking em que os valores mais altos são todos de trapaceiros.
8. Parabéns
Parabéns! Você criou quatro rankings diferentes no Firebase. Dependendo das necessidades de exatidão e velocidade do seu jogo, você poderá escolher uma opção que funcione para você a um custo razoável.
Em seguida, confira os programas de aprendizado para jogos.