# 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.

URL: https://www.back4app.com/blog/pt/como-construir-um-backend-de-jogo-escalavel-leaderboards-e-cloud-saves
Publicado: 2026-11-05 · Testado: 2026-10-23
Testado em: 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
Publisher: Back4app Engineering

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](https://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](https://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.

| Classe | Uma linha por | Quem lê | Quem escreve |
|---|---|---|---|
| `Player` | usuário | só o dono | Cloud Code |
| `Score` | envio aceito, só acrescenta (append-only) | só o dono | Cloud Code |
| `Best` | jogador e nível | todo mundo | Cloud Code |
| `Save` | jogador e slot (0 a 2) | só o dono | Cloud Code |
| `RejectedScore` | envio rejeitado | só a master key | Cloud Code |

| Função | Exige login | Faz |
|---|---|---|
| `ensurePlayer` | sim | cria o perfil na primeira chamada, atualiza nome de exibição e país depois |
| `submitScore` | sim | valida, registra rejeições, acrescenta um `Score`, faz upsert em `Best` |
| `leaderboard` | não | top-N de `Best`, geral ou semanal, posições com empates compartilhados |
| `myRank` | sim | 1 + contagem das linhas de `Best` com pontuação maior |
| `putSave` | sim | escrita otimista; responde `STALE_SAVE` com a versão atual quando está atrasado |
| `getSave` | sim | versã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](/blog/blog-assets/game-backend-accounts-leaderboards-cloud-saves/01-account-and-scores-acme.jpg)

## 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.

```js
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](/blog/link-multiple-sign-in-methods-to-one-account) 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ção | Rejeita | Registrado como |
|---|---|---|
| teto por nível, padrão 10.000 × nível | pontuações impossíveis | `CEILING_EXCEEDED` |
| um contador de sessão que precisa aumentar por jogador, consumido até por envios rejeitados | replays e sessões fora de ordem | `COUNTER_NOT_MONOTONIC` |
| dez envios por 60 s por jogador, aceitos ou rejeitados | enxurradas | `RATE_LIMITED` |
| um nonce de cliente por envio | uma requisição repetida conta uma vez e recebe a resposta original | não é rejeição |
| login obrigatório, escritas só pela função | inserções diretas, linhas forjadas | Parse 119 |

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](/blog/blog-assets/game-backend-accounts-leaderboards-cloud-saves/03-ceiling-rejected-acme.jpg)

## 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](/blog/blog-assets/game-backend-accounts-leaderboards-cloud-saves/02-leaderboard-live-acme.jpg)

## 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 volta | Rodada 1: só índices do schema | Rodada 2: mais os índices em `_created_at` | Rodada 3: mais o hint na contagem |
|---|---|---|---|
| top-100 geral | 4.560 / 5.237 ms | 428 / 520 ms | 468 / 518 ms |
| top-100 desta semana | 4.617 / 5.322 ms | 426 / 516 ms | 426 / 510 ms |
| minha posição geral, jogador na posição 545.293 | 2.446 / 3.043 ms | 2.355 / 2.984 ms | 2.365 / 2.685 ms |
| minha posição desta semana, mesmo jogador | 2.456 / 3.068 ms | 2.359 / 2.764 ms | 2.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 jogador | p50 de `myRank` |
|---|---|
| 9.998, posição 36 | 1.205 ms |
| 9.000, posição 34.671 | 1.313 ms |
| 5.000, posição 206.194 | 2.032 ms |
| 100, posição 783.956 | 4.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 cliente | Resposta do backend ao vivo |
|---|---|
| qualquer função de jogador sem sessão | 400, código 209, `UNAUTHENTICATED` |
| `leaderboard` sem sessão | 200, é pública |
| `POST /classes/Score` com sessão | 119 |
| `PUT /classes/Best` na sua própria linha | 119 |
| ler o `Save` ou o `Player` de outro jogador | não encontrado; as próprias linhas legíveis, contadores protegidos |
| `GET /classes/RejectedScore` com sessão | 119 |
| `POST /classes/Cheat`, uma classe que não existe | **201 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](https://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.
