Como construir um backend de jogo escalável: leaderboards e cloud saves

Jogadores anônimos que viram contas reais, leaderboards geral e semanal com empates tratados, cloud saves versionados e um envio de pontuação que diz do que protege. Semeado até um milhão de linhas e medido de fora.

Um card de leaderboard com dez primeiros lugares empatados em 9999 pontos entre um milhão de pontuações semeadas, um card Minha posição marcando número 52511 com 7780 pontos e uma linha de rejeição CEILING_EXCEEDED, score exceeds the ceiling for level 1

Tradução do original em inglês, testado em 23 de outubro de 2026. Código, números e datas são os do original. Read in English

A dúvida de backend de um desenvolvedor indie raramente é “qual engine”. É se um backend-as-a-service genérico consegue guardar contas de jogadores, um leaderboard que continua rápido enquanto a tabela de pontuações cresce e cloud saves com verificação de versão, sem um servidor para manter.

Este post constrói exatamente isso no Parse Server da Back4app, depois enche o leaderboard com um milhão de pontuações e cronometra tudo de fora.

A rodada de viabilidade foi em 20 de outubro de 2026; a construção, a semeadura e as medições abaixo, em 23 de outubro, contra a plataforma ao vivo. Os números vêm dessas rodadas; os arquivos por trás deles estão no repositório.

Teste, faça o deploy, leia: o cliente de demonstração roda em gamebackendstarter-nqa56pyp.b4a.run: jogue como convidado, envie uma pontuação, experimente os dois botões de trapaça, veja sua posição entre um milhão.

O repositório é github.com/templates-back4app/game-backend-starter, MIT.

O que o backend guarda, e quem pode tocar nele

Cinco classes, cada uma com um motivo para existir e uma regra sobre quem escreve nela: ninguém além do Cloud Code. O cliente entra, chama funções e lê a classe do leaderboard diretamente, porque ela é pública.

ClasseUma linha porQuem lêQuem escreve
Playerusuáriosó o donoCloud Code
Scoreenvio aceito, só acrescenta (append-only)só o donoCloud Code
Bestjogador e níveltodo mundoCloud Code
Savejogador e slot (0 a 2)só o donoCloud Code
RejectedScoreenvio rejeitadosó a master keyCloud Code
FunçãoExige loginFaz
ensurePlayersimcria o perfil na primeira chamada, atualiza nome de exibição e país depois
submitScoresimvalida, registra rejeições, acrescenta um Score, faz upsert em Best
leaderboardnãotop-N de Best, geral ou semanal, posições com empates compartilhados
myRanksim1 + contagem das linhas de Best com pontuação maior
putSavesimescrita otimista; responde STALE_SAVE com a versão atual quando está atrasado
getSavesimversão e blob; versão 0 e sem blob para um slot vazio

Escritas são recusadas duas vezes. As permissões por classe negam toda escrita de cliente, e um hook beforeSave em cada classe recusa qualquer requisição que não carregue a master key (chave mestra), então uma permissão alargada por engano não muda nada. Os testes ao vivo enviam essas escritas com as chaves que um cliente tem; todas responderam código 119.

As seções Account e Scores do cliente de demonstração: um convidado chamado acme-tester, os botões Play a round, Submit an impossible score e Replay, uma linha de rejeição CEILING_EXCEEDED e o top 10 com dez primeiros lugares empatados

O caminho principal em cinco chamadas

Antes das tabelas, o caminho que um cliente de jogo percorre, com o Parse JS SDK. Cada linha é uma requisição; todo o resto deste post é o que acontece em volta delas.

await Parse.AnonymousUtils.logIn();                       // 1. a guest account, no form
await Parse.Cloud.run('ensurePlayer', { displayName: 'acme-tester' });

const r = await Parse.Cloud.run('submitScore', {          // 2. a score, bounded by the server
  level: 1, score: 7780, sessionCounter: 1, clientNonce: crypto.randomUUID(),
});                                                       //    → { accepted, best, weekScore, replay }

const top = await Parse.Cloud.run('leaderboard', { level: 1, limit: 10 });   // 3. public, no session needed
const me  = await Parse.Cloud.run('myRank', { level: 1 });                   // 4. 1 + count(score > mine)

const slot = await Parse.Cloud.run('getSave', { slot: 0 });                  // 5. { version: 0, blob: null } when empty
await Parse.Cloud.run('putSave', { slot: 0, baseVersion: slot.version, blob: JSON.stringify(state) });
// a stale baseVersion answers STALE_SAVE with the current version instead of overwriting

Erros voltam como um erro do Parse cuja mensagem é um envelope JSON com um código como CEILING_EXCEEDED ou STALE_SAVE; um helper de dez linhas no cliente o desempacota.

Jogadores: anônimos primeiro, promovidos depois

Um jogador começa como usuário anônimo do Parse, criado pela primeira chamada com um id aleatório que o cliente guarda. Isso basta para enviar pontuações e salvar partidas. Quando o jogador quer manter o progresso, o cliente define usuário e senha no mesmo usuário; as linhas de Player, Best e Save apontam para o mesmo id e continuam ligadas. Um teste faz exatamente isso e confere as três.

Um detalhe do SDK decide se isso funciona: o signUp() do JS SDK em um usuário anônimo envia authData.anonymous = null junto com as credenciais, e é isso que transforma o convidado em conta em vez de criar uma segunda.

O teste REST puro envia o mesmo corpo, e o post sobre login social cobre o que acontece com sessões e métodos vinculados a partir daí.

Envio de pontuação: do que ele protege, com precisão

O servidor nunca viu a partida, então não tem como saber que 7.780 pontos foram conquistados. O que submitScore pode fazer é tornar os abusos óbvios caros ou impossíveis, e dizer quais:

VerificaçãoRejeitaRegistrado como
teto por nível, padrão 10.000 × nívelpontuações impossíveisCEILING_EXCEEDED
um contador de sessão que precisa aumentar por jogador, consumido até por envios rejeitadosreplays e sessões fora de ordemCOUNTER_NOT_MONOTONIC
dez envios por 60 s por jogador, aceitos ou rejeitadosenxurradasRATE_LIMITED
um nonce de cliente por enviouma requisição repetida conta uma vez e recebe a resposta originalnão é rejeição
login obrigatório, escritas só pela funçãoinserções diretas, linhas forjadasParse 119
Isso limita o abuso. Não torna as pontuações autoritativas no servidor, porque o servidor nunca viu o jogo. Um cliente que mente de forma plausível ainda entra no placar.

Validação de verdade precisa de um replay que o servidor possa reexecutar ou de uma simulação determinística, e isso é outro artigo. O que este dá a você é uma linha de RejectedScore para cada recusa, para que ao menos você veja quem está sondando.

O rate limiting é por jogador, não por dispositivo ou IP: um trapaceiro com muitas contas anônimas tem muitos orçamentos, e o README diz com o que combiná-lo.

A seção Scores depois do botão de trapaça: uma linha vermelha Rejected CEILING_EXCEEDED, score exceeds the ceiling for level 1, acima do top 10 e um card My rank marcando número 52511 com 7780

Leaderboards: uma linha por jogador e nível, dois números por linha

Score é histórico e nunca é varrida para montar um placar. Best guarda uma linha por jogador e nível com a melhor pontuação geral, a semana ISO do envio mais recente e a melhor dentro dessa semana. Todo envio aceito faz upsert nela. Um top-N é uma query indexada em Best; uma posição é uma contagem indexada.

Três decisões seguem daí, e cada uma tem um teste:

  • Placares semanais zeram sem job. O placar semanal filtra pela chave da semana atual; uma linha que ninguém tocou nesta semana não casa. O placar da semana passada sobrevive só para os jogadores que não jogaram desde então, porque não há tabela de histórico. Se você precisa de arquivo, essa tabela é a única adição.
  • Empates dividem a posição. A posição é 1 + o número de pontuações estritamente maiores, nas duas funções, conferida contra um ranking por força bruta de 500 linhas semeadas. A ordem de exibição entre iguais é pelo createdAt da linha de Best, que é quando o jogador entrou pela primeira vez no placar daquele nível, não quando alcançou a pontuação atual. A versão precisa é um timestamp bestAt escrito sempre que a pontuação melhora, indexado no lugar de createdAt; o template fica com a regra mais simples e diz isso.
  • Posição é uma contagem, nunca um skip. A rodada de viabilidade de 20 de outubro mediu skip=500000 no placar ordenado em 2,77 s, e uma ordenação com desempate em um campo sem índice falhou de vez com HTTP 500, o limite de ordenação em memória do MongoDB. Todo campo pelo qual um placar filtra ou ordena está em um índice, e a seção sobre o milhão de linhas, abaixo, diz o que foi preciso para isso ser verdade.

A seção de leaderboard do cliente de demonstração com o top 10 geral, todos empatados em 9999, ao lado de My rank marcando número 51814 com 7780, e o slot de Cloud save abaixo

Um milhão de pontuações, semeadas com a REST key

Semear um milhão de linhas exige a master key, a menos que o trabalho aconteça dentro do Cloud Code. Uma função seedBest, protegida por uma chave SETUP_ENABLED, recebe 50 linhas por chamada e as insere com direitos de master; o script envia 20.000 dessas chamadas por um token bucket a 16 requisições por segundo, porque o throttle do plano Free recusa clientes mais rápidos.

Esse throttle é a primeira coisa a saber sobre carga nesta plataforma: ele volta como HTTP 400 com código Parse 155, não como 429, então uma política de retry baseada em 429 nunca o vê. O semeador inspeciona o corpo e recua.

Nesta rodada, o milhão de linhas levou 2.864 s, 47,7 minutos, para exatamente 20.000 requisições com zero chamadas falhas. 150 linhas foram puladas pela chave de semente depois que uma requisição limitada foi repetida, que é o caminho de deduplicação fazendo seu trabalho.

Linhas semeadas não têm _User por trás, de propósito: criar um milhão de usuários a partir de uma chave de cliente não é possível nem desejável. O leaderboard as marca como seeded: true, e a posição de um jogador real as conta como qualquer outra.

Latência com 1.000.000 de linhas, medida três vezes

npm run bench entra como convidado, envia a única pontuação que a semeadura atribuiu a esse jogador e então roda 20 aquecimentos e 100 chamadas cronometradas por operação, de uma máquina fora dos EUA. A primeira rodada foi ruim, e as duas correções que ela forçou são a coisa mais útil deste post.

Operação, nível 1, 1.000.000 de linhas, p50 / p95 ida e voltaRodada 1: só índices do schemaRodada 2: mais os índices em _created_atRodada 3: mais o hint na contagem
top-100 geral4.560 / 5.237 ms428 / 520 ms468 / 518 ms
top-100 desta semana4.617 / 5.322 ms426 / 516 ms426 / 510 ms
minha posição geral, jogador na posição 545.2932.446 / 3.043 ms2.355 / 2.984 ms2.365 / 2.685 ms
minha posição desta semana, mesmo jogador2.456 / 3.068 ms2.359 / 2.764 ms2.363 / 2.592 ms

As três rodadas têm 52 minutos de intervalo, 100 chamadas cronometradas cada depois de 20 aquecimentos, zero erros; os CSVs têm o nome da rodada em docs/evidence/.

A rota entre esse cliente e a plataforma não foi constante naquela tarde: chamadas idênticas responderam em cerca de 0,4 s em uma janela de dez minutos e em cerca de 1,3 s na seguinte. Os valores absolutos pertencem àquele dia.

As diferenças entre as colunas, quatro segundos no top-100 e nenhuma na posição, são uma ordem de grandeza maiores que essa variação, e esse é o único motivo para ler alguma coisa nelas.

Mais uma ressalva fica ao lado desses números. Um milhão de linhas é um teste de volume de dados: um cliente, uma chamada por vez, depois de aquecimentos. Ele diz quanto uma query custa nesse tamanho de tabela. Não diz nada sobre muitos jogadores enviando ao mesmo tempo, e a rajada de 500 jogadores prevista no plano ainda não foi rodada.

Correção um: os nomes das chaves do índice. Parse.Schema cria índices no MongoDB com os nomes de campo que você passa, sem tradução. Um índice em createdAt indexa uma chave que nenhum documento tem, porque o MongoDB guarda essa coluna como _created_at.

Então o índice {level, score, createdAt} do schema atendia order by score, mas não o desempate order by score, createdAt, que caía numa ordenação de um milhão de linhas em memória: 6,6 s para a query crua contra 1,3 s sem o desempate.

O Index Manager da dashboard traduz o nome. Dois índices criados ali, em _created_at, levaram cerca de três minutos para construir sobre um milhão de linhas e trouxeram o top-100 de 4,56 s para 428 ms, ida e volta incluída.

A correção dois foi só meia correção. myRank é count where level = 1 and score > mine. Direto contra a REST API, o planner do MongoDB 3.6 rodava essa contagem em cerca de 3 s tanto com 298 linhas quanto com 300.000 casando, e um hint para o índice do schema a trouxe de volta a um intervalo de índice: 1,2 s de ida e volta com 298 resultados.

A função agora passa esse hint; o adaptador PostgreSQL o ignora, então os testes locais não mudam.

Mas a terceira coluna mostra que o hint não mexeu no número da função para um jogador na posição 545.293, e uma quarta medição diz por quê. Quatro jogadores descartáveis em quatro posições, dez chamadas cronometradas cada, no mesmo minuto em que o top-100 levava cerca de 1,3 s:

Pontuação e posição do jogadorp50 de myRank
9.998, posição 361.205 ms
9.000, posição 34.6711.313 ms
5.000, posição 206.1942.032 ms
100, posição 783.9564.124 ms

A contagem é linear no número de linhas acima do jogador, cerca de 3,7 µs por linha neste engine. No topo do placar isso sai de graça; no fundo de um placar de um milhão de linhas são quatro segundos. A consequência para o design está na próxima seção.

Por que o planner importa aqui aparece no próprio explain do MongoDB, que o Parse Server devolve para um find pela REST API. Para level = 1 and score > 9990 sem hint, o plano vencedor varre o índice semanal com a semana sem limites e filtra score depois de buscar cada documento: 1.000.013 chaves examinadas, 1.000.013 documentos examinados, 299 devolvidos, 2.508 ms.

Com hint=best_alltime os limites do índice ficam no próprio score. Os planos estão em docs/evidence/; uma contagem não pode ser explicada pela REST API, então os números de contagem acima continuam sendo medições sem plano.

O que fazer com uma contagem de posição linear

Mostre a posição exata onde é barato e aproxime onde não é. O topo do placar é a parte barata: um jogador na posição 36 não paga nada além da ida e volta. Para todos os outros, três opções custam menos que uma contagem de quatro segundos, e o repositório aponta onde cada uma entra.

  • Guarde em cache. Armazene a posição na linha de Best quando uma pontuação é aceita e atualize em um cronograma; a contagem roda uma vez por jogador por intervalo em vez de uma vez por tela.
  • Agrupe em faixas. Mantenha uma tabela pequena de limiares de pontuação por percentil, reconstruída por um job, e responda “top 12%” a partir dela em uma única consulta indexada.
  • Estime. Mantenha uma tabela pequena de limiares de pontuação (a cada 1% do placar, reconstruída por um job), encontre a faixa em que a pontuação do jogador cai com uma consulta e conte exatamente só dentro da janela dessa faixa. Início da faixa mais contagem da janela é uma posição global com erro limitado, rotulada “cerca de”, que é o que a maioria dos jogos mostra mesmo. Uma contagem de janela sozinha, sem a faixa, não é uma posição.

Nenhuma delas está no template, de propósito: a contagem como está escrita é correta, e a medição acima diz quando ela deixa de caber no orçamento.

Cloud saves, versionados

Um save é uma string JSON de no máximo 500 KB em um de três slots. putSave recebe a versão que o cliente carregou; se a versão do servidor for maior, ele responde STALE_SAVE com a versão atual, e nada é sobrescrito. getSave devolve versão 0 e nenhum blob para um slot vazio.

A ida e volta, a rejeição por versão antiga, a validação de slot e tamanho e a leitura só pelo dono são, cada uma, um teste, local e ao vivo.

Erros carregam um código sobre o qual o cliente pode agir. Erros do Parse são um número e uma string, então a string é um envelope JSON: {"code":"STALE_SAVE","currentVersion":3} dentro de um Parse 142, ou UNAUTHENTICATED dentro de um 209 para que clientes com SDK descartem uma sessão morta. Um helper pequeno no cliente e nos scripts o desempacota.

Testes de segurança, com as chaves do cliente

Um cliente tem o Application ID e uma chave de cliente, ambos públicos por design. Os testes enviam o que um cliente hostil enviaria com exatamente isso, e o backend ao vivo respondeu:

Tentativa, com o Application ID e uma chave de clienteResposta do backend ao vivo
qualquer função de jogador sem sessão400, código 209, UNAUTHENTICATED
leaderboard sem sessão200, é pública
POST /classes/Score com sessão119
PUT /classes/Best na sua própria linha119
ler o Save ou o Player de outro jogadornão encontrado; as próprias linhas legíveis, contadores protegidos
GET /classes/RejectedScore com sessão119
POST /classes/Cheat, uma classe que não existe201 no plano Free

A última linha é uma configuração da plataforma, não um bug do app, e merece um parágrafo próprio.

O que a construção revelou

  • O primeiro deploy de Cloud Code de um backend novo enviou config.js, mas não o main.js editado. O buffer não salvo do editor não faz parte do deploy; uma segunda edição e Deploy o enviaram. A prova é uma requisição: setupSchema respondeu 141 até então, depois o relatório completo do schema.
  • config.js só sobe com a pasta selecionada. O upload da dashboard cai no nó selecionado do explorador de arquivos; sem nada selecionado, o arquivo é descartado em silêncio.
  • As direções dos índices sobrevivem no MongoDB e não no adaptador PostgreSQL local, que mantém a ordem das colunas e descarta o -1. Inofensivo no PostgreSQL, onde um btree é lido nos dois sentidos. Os nomes das chaves são a armadilha real, acima: nada na rodada local poderia ter mostrado isso, porque o adaptador PostgreSQL mapeia a coluna e o do MongoDB não.
  • Cadastros com a master key ainda criam sessões. Uma semeadura que cria usuários com a master key também cria uma linha de _Session para cada um. A semeadura com REST key evita a questão não criando usuário nenhum.
  • A primeira chamada depois de um período ocioso leva cerca de 4,5 s. O Cloud Code no plano Free faz cold start; a mesma chamada de leaderboard respondeu em 1,1 s na requisição seguinte. Os aquecimentos fazem parte do bench por isso.
  • Uma mudança de plano refaz o deploy do container, e um app criado por upload não consegue refazer o próprio deploy. O cliente de demonstração foi criado com a origem Upload files do Containers; subir para o Starter disparou um redeploy que falhou com This deployment has no uploaded code enquanto o container antigo continuava servindo. Action → Redeploy latest code o reconstruiu a partir do código que a página Source Code ainda guardava.
  • O Cloud Code roda em Node 19.9. Nada aqui precisa de sintaxe mais nova, e a pasta cloud é CommonJS por um package.json próprio, porque o pacote raiz é ESM para os scripts.

Quanto custaria, como estimativa de requisições

Isto é uma estimativa derivada do uso medido e de preços com fonte, não uma fatura. Uma sessão de demonstração, do jeito que o cliente a joga, faz cerca de 11 requisições: login, ensurePlayer, um leaderboard, uma posição, cinco envios, uma leitura de save e uma escrita de save. Com 1.000 sessões assim por dia, são aproximadamente 330.000 requisições por mês.

A página de preços da Back4app, lida em 23 de outubro de 2026, lista o plano Free com 25 mil requisições por mês, o MVP a US$ 15 por app por mês no pagamento anual (US$ 25 no mensal) com 500 mil requisições e US$ 5 por 100 mil extras, e o Pay as you go a US$ 80 no anual (US$ 100 no mensal) com 5 milhões de requisições e US$ 2 por 100 mil extras.

Armazenamento é o outro limite, e no plano Free é o primeiro que um leaderboard semeado atinge. Depois do milhão de linhas em Best, a página Plan Usage da dashboard marcava 329 MB de armazenamento de banco contra os 250 MB incluídos: cerca de 260 MB de documentos mais 66 MB de índices, aproximadamente 330 bytes por linha no total.

A semeadura também gastou 20.000 das 25 mil requisições mensais.

O 1 GB do plano MVP guarda cerca de três milhões de linhas assim. Score, que mantém uma linha por envio aceito, e RejectedScore crescem por cima disso e são as tabelas a vigiar.

Fazendo o deploy

  1. Backend. Crie um app na dashboard da Back4app e copie o Application ID, a JavaScript key e a REST key para o .env. A master key fica na dashboard; nada aqui precisa dela.

  2. Cloud Code. Na página Cloud Code selecione a pasta cloud, faça o upload de cloud/config.js, cole cloud/main.js no editor, confirme Files pending deploy (2), Deploy, e verifique com uma requisição como acima.

  3. Schema. npm run setup chama setupSchema com a REST key: cinco classes, campos, permissões e índices em uma chamada. Ele recusa uma re-execução direta depois que Best tem linhas, a menos que você peça.

  4. Índices. Database → Index Manager → Best: adicione best_alltime_ca (level asc, score desc, createdAt asc) e best_weekly_ca (level asc, week asc, weekScore desc, createdAt asc). A coluna Fields precisa mostrar _created_at; os criados pelo schema com createdAt ficam, as contagens os usam.

  5. Testes, semeadura, bench. TEST_SERVER_URL=https://parseapi.back4app.com npm test em um app de staging, depois npm run seed -- --n 1000000 --levels 1 e npm run bench -- --level 1 --label back4app se você quiser os números da sua região.

  6. Feche a porta do setup. SETUP_ENABLED: false em cloud/config.js, upload, Deploy, e confirme que setupSchema responde 119. Enquanto está true, qualquer um com o Application ID pode semear linhas e ler ou apagar qualquer linha.

  7. Cliente web. Containers → Deploy a web app a partir do repositório, Node.js Buildpack, porta 8080, health check /healthz e três variáveis: APP_ID, JS_KEY, SERVER_URL.

Quando não fazer desse jeito

Se o seu jogo precisa que o servidor decida o resultado, rode a simulação ou junte jogadores em tempo real, você precisa de um game server, e este backend vira o banco de dados e o armazenamento de contas dele, em vez da coisa com que os jogadores falam.

Se “uma pessoa, uma conta” precisa valer entre dispositivos e reinstalações, o anônimo-primeiro precisa de um convite para criar conta logo cedo, porque um convidado que desinstala se foi.

E note o que este post não rodou: 500 jogadores simulados enviando dentro de uma janela de dez segundos, e idas e voltas de save com 50 KB e 500 KB, ambos no plano e ambos ainda por vir. Quando essas rodadas acontecerem, este parágrafo será substituído pelos resultados.

O repositório

github.com/templates-back4app/game-backend-starter, MIT. cloud/main.js guarda as seis funções, os helpers de setup e os hooks; cloud/config.js, os ajustes e o schema; scripts/, setup, semeadura e bench; public/, o cliente de demonstração.

test/ tem 19 testes que rodam localmente contra o parse-server com PostgreSQL e, com uma variável, contra o backend ao vivo.

docs/notes/build-diary.md registra as decisões e a rodada ao vivo, e docs/evidence/, os CSVs do bench e o log da semeadura por trás de cada número daqui.

Tagsgame-backendleaderboardcloud-saveparsecloud-codemongodbanonymous-usersindexesrate-limitbenchmark

Perguntas frequentes

O servidor valida que uma pontuação é real?

Não, e o post diz isso na primeira tela. O servidor nunca viu a partida. O que ele faz é limitar o abuso: um teto por nível, um contador de sessão que precisa aumentar, dez envios por minuto por jogador e um nonce para que uma requisição repetida conte uma vez. Toda rejeição é registrada. Um cliente modificado ainda pode enviar uma pontuação inventada plausível; validação de verdade precisa de um replay ou de uma simulação no servidor, o que está fora do escopo.

Quão rápida é uma query de top-100 com um milhão de pontuações?

Com os índices do próprio schema, o top-100 levou 4,56 s no p50, porque a chave de desempate estava com o nome errado; com o índice certo, criado na dashboard, 428 ms de ida e volta a partir de um cliente fora dos EUA. A contagem de posição é diferente: ela cresce com as linhas acima do jogador, 1,2 s na posição 36 e 4,1 s na posição 784.000 no MongoDB 3.6 do plano Free, e o post diz como guardar em cache, agrupar em faixas ou limitar essa contagem.

Por que a posição é uma contagem e não um índice em uma lista ordenada?

Porque posição = 1 + o número de pontuações estritamente maiores que a sua é uma única contagem indexada, enquanto 'pular até a minha linha' é uma paginação profunda que levou 2,77 segundos com um skip de 500.000 na nossa rodada de viabilidade. Empates dividem a posição por essa definição, e o leaderboard atribui posições do mesmo jeito, o que um teste confere contra um ranking por força bruta de 500 linhas.

Como os leaderboards semanais zeram sem um job agendado?

Cada linha de melhor pontuação carrega a semana ISO do envio mais recente e a melhor pontuação dentro dessa semana. O placar semanal filtra pela chave da semana atual, então uma linha que ninguém tocou nesta semana simplesmente não casa. Nada para agendar, nada para esquecer. A troca é que o placar da semana passada não fica arquivado; o post diz onde uma tabela de histórico entraria.

O que acontece quando dois dispositivos salvam no mesmo slot?

Um save carrega a versão que o dispositivo carregou. Se a versão do servidor for maior, ele responde STALE_SAVE com a versão atual em vez de sobrescrever, e o cliente decide. Isso protege contra um dispositivo desatualizado; dois dispositivos enviando a mesma versão no mesmo instante são outro caso, e o post diz que isso não foi testado. Um blob é uma string JSON de no máximo 500 KB, três slots por jogador.

Um cliente pode criar classes novas no plano Free?

Sim, e isso não pode ser desligado nesse plano. Client Class Creation fica em App Settings → Advanced Options e está fixo em Yes no plano Free; planos pagos podem definir No. As classes do próprio template estão protegidas de qualquer forma por permissões e hooks, o que os testes ao vivo confirmam, mas um cliente com o Application ID ainda consegue criar uma classe própria em um app Free.

Quanto custa um backend para uma startup ou um jogo pequeno?

Segundo a página de preços da Back4app em outubro de 2026: o plano Free inclui 25 mil requisições por mês; o MVP custa US$ 15 por app por mês no plano anual (US$ 25 no mensal) com 500 mil requisições; o Pay as you go custa US$ 80 por mês no anual com 5 milhões. Neste jogo, cerca de 11 requisições por sessão dão umas 330 mil requisições por mês com 1.000 sessões por dia, dentro do MVP. No plano Free, o primeiro limite foi o armazenamento: um milhão de linhas de leaderboard ocuparam 329 MB dos 250 MB incluídos.

Leitura complementar

Revisado e verificado por Back4app Engineering, Editorial review na Back4app

Testado em 23 de outubro de 2026 — Backend: Back4app, Parse Server 7.5.2 (MongoDB), Cloud Code em Node 19.9, plano Free · Cliente das medições: uma máquina fora dos EUA, Node 25, só REST key · Cliente web: JavaScript puro com o Parse JS SDK, Express no Back4app Containers · Verificação local: parse-server 7.5.4 em PostgreSQL 17.

Histórico de alterações

  • 27 de outubro de 2026 — Revisão após a revisão editorial: um exemplo de cinco chamadas logo no início; o benchmark de um milhão de linhas rotulado como teste de volume de dados, não de concorrência; a regra de desempate descrita com precisão; saída do explain do MongoDB adicionada atrás do achado da contagem; a receita de posição aproximada corrigida (faixa mais janela); a afirmação sobre armazenamento corrigida com os 329 MB medidos contra os 250 MB do plano Free.
  • 27 de outubro de 2026 — Título em inglês alterado de 'Game Backend on a BaaS: Player Accounts, a Leaderboard at 1,000,000 Scores and Cloud Saves on Parse Server'; a URL em inglês mudou de /blog/game-backend-accounts-leaderboards-cloud-saves, que agora redireciona para a nova.

Mais da Back4app Engineering

Backend · tutorial

Your Frontend Talks Straight to the Database. Here Is How to Lock It Down [Full Repo]

Backend · tutorial

Como encontrar o motorista mais próximo em um app estilo Uber

Backend · error-guide

Why Does the API Return 101, 119, 142 or 209? Every Backend Error Code, Reproduced [Full Repo]