O Navistron usa API Routes do Next.js como backend e MongoDB para persistência. Cada partida gera dados que podem seguir três caminhos até o banco de dados, com sanitização dupla, timestamps server-side e fallback via sendBeacon para garantir que nenhuma sessão se perca. Neste artigo, vamos percorrer todo o fluxo — do botão "Save & Ranking" até o insertOne no MongoDB.
O Fluxo Completo: Do Game Over ao insertOne
Quando a nave é destruída e a animação de morte termina, a função endGame() cria um objeto pendingSession com 7 campos de gameplay: score, tier (índice 0–6), tierName (ex: "TIER III"), boosts (total coletado), spread (nível de spread final), time (Math.floor(timeAlive) — segundos inteiros) e difficulty (multiplicador de dificuldade arredondado a 2 casas). A flag sessionResolved é marcada como false.
O overlay de game over aparece após 700ms com as estatísticas finais, um campo de texto para o nome do piloto (máximo 20 caracteres) e dois botões: "SAVE & RANKING" e "SKIP". A partir daqui, o destino dos dados depende da ação do jogador.
Caminho 1: Save & Ranking — Score Registrado
Ao clicar em "Save & Ranking" (ou pressionar Enter), o handler desabilita ambos os botões com setButtonLoading() para evitar cliques duplicados, muda o texto para "SAVING..." e inicia o fluxo assíncrono:
- Leitura do nome —
document.getElementById('name-input').value.trim(). Se vazio, usa "ANONYMOUS". - Sanitização client-side —
name.trim().toUpperCase().slice(0, 20)— remove espaços, converte para maiúsculas, limita a 20 caracteres. - Envio —
POST /api/scorescomContent-Type: application/json, corpo com 8 campos (name + os 7 de gameplay). - Sanitização server-side — A API re-aplica
String(body.name || 'ANONYMOUS').toUpperCase().slice(0, 20)e converte cada campo numérico comNumber()com defaults seguros. - Timestamp — O servidor adiciona
playedAt: new Date()— timestamp confiável gerado server-side. - Inserção —
db.collection('scores').insertOne(record)no banconavistron. - Resposta — Retorna o documento inserido com
id: result.insertedId.toString(). - Highlight — O ID retornado é armazenado em
lastSavedIdpara destaque no leaderboard.
Após o save, sessionResolved = true e pendingSession = null, impedindo que a sessão seja salva novamente como anônima. O leaderboard é carregado automaticamente.
Caminho 2: Skip — Sessão Anônima "Unregistered"
Ao clicar "Skip", se houver uma sessão pendente não resolvida, o handler envia os mesmos dados de gameplay para POST /api/anonymous-sessions com um campo adicional: type: "Unregistered". Isso indica que o jogador viu o game over e deliberadamente optou por não registrar seu nome.
A API de sessões anônimas valida o campo type: apenas "Unregistered" e "Unknown" são aceitos — qualquer outro valor é convertido para "Unknown". O documento é inserido na collection anonymous_sessions (separada de scores). O lastSavedId é definido como null, então nenhuma linha é destacada no leaderboard que é exibido em seguida.
Caminho 3: Abandono — sendBeacon e "Unknown"
Se o jogador fecha a aba, navega para outra página, ou inicia um novo jogo sem clicar em nenhum botão, o Navistron garante que os dados não se perdem usando dois mecanismos:
- beforeunload — Ao fechar a aba, o handler verifica se há sessão pendente e usa
navigator.sendBeacon('/api/anonymous-sessions', blob)para enviar os dados comtype: "Unknown". OsendBeaconé fire-and-forget: o navegador garante o envio mesmo durante a destruição da página. - Novo jogo — A função
startGame()verifica se existependingSessionnão resolvida. Se sim, usaAnonymousDB.save()(fetch normal) para salvar como"Unknown"antes de resetar o estado para a nova partida.
Esses fallbacks garantem que toda partida gera dados de telemetria, seja no ranking público ou nas estatísticas anônimas.
Sanitização Dupla: Cliente e Servidor
O nome do piloto passa por sanitização redundante — uma medida de defesa em profundidade:
- No cliente:
name.trim().toUpperCase().slice(0, 20) || 'ANONYMOUS' - No servidor:
String(body.name || 'ANONYMOUS').toUpperCase().slice(0, 20)
A sanitização server-side existe para proteger contra requisições maliciosas que contornem o cliente (ex: curl direto na API). Cada campo numérico também é protegido com Number() e defaults: score || 0, tier || 0, spread || 1 (default 1, não 0, pois todo jogador começa com spread 1), difficulty || 1 (dificuldade mínima).
Os 9 Campos Armazenados no MongoDB
Cada documento na collection scores contém exatamente 9 campos (além do _id gerado pelo MongoDB):
- name (String) — Piloto, até 20 caracteres maiúsculos. Default: "ANONYMOUS"
- score (Number) — Pontuação total. Default: 0
- tier (Number) — Índice do tier final (0–6). Default: 0
- tierName (String) — Nome legível ("TIER I" a "TIER VII"). Default: "TIER I"
- boosts (Number) — Total de boosts coletados. Default: 0
- spread (Number) — Nível de spread dos projéteis. Default: 1
- time (Number) — Segundos de sobrevivência (inteiro). Default: 0
- difficulty (Number) — Multiplicador de dificuldade (2 casas). Default: 1
- playedAt (Date) — Timestamp do servidor via
new Date()
A collection anonymous_sessions é quase idêntica, mas substitui name por type ("Unregistered" ou "Unknown").
Conexão MongoDB: Cache HMR e Cold Start
A conexão com o MongoDB é gerenciada pelo módulo mongodb.js com otimização para ambiente de desenvolvimento:
- Desenvolvimento — A promise de conexão é cacheada em
global._mongoClientPromise. Sem isso, cada Hot Module Replacement (HMR) do Next.js criaria uma nova conexão, esgotando o pool do MongoDB Atlas em minutos. - Produção — Um único
MongoClienté criado por cold start do Vercel. A promise resolvida é cacheada no nível do módulo, reutilizada entre requests do mesmo container. O URI de conexão vem deprocess.env.MONGODB_URI.
As opções do cliente são vazias ({}), usando os defaults do driver: connection pooling automático, retries, timeouts padrão. O banco de dados navistron é especificado em cada call site (client.db('navistron')), não na string de conexão.
Leaderboard Highlight: Destaque do Score Recém-Salvo
Após salvar o score, a função showLeaderboard() busca o top 100 via GET /api/scores e renderiza a tabela. Para cada linha, compara r.id com lastSavedId — o ID retornado pelo insertOne. A linha correspondente recebe a classe CSS highlight (fundo diferenciado) e, após 120ms de delay, a tabela rola automaticamente com scrollIntoView({ block: 'center', behavior: 'smooth' }).
Se o jogador clicou "Skip", lastSavedId é null e nenhuma linha é destacada. O leaderboard ainda é exibido, mas sem indicação de posição.
Tratamento de Erros em Cadeia
O sistema implementa tratamento de erros em múltiplas camadas, com degradação graciosa:
- DB adapter —
try/catchretorna{ id: null }se o fetch falhar. O jogo continua normalmente. - saveScore() —
try/catchloga warning no console e definelastSavedId = null. O leaderboard abre sem highlight. - API Routes —
try/catchloga erro viaconsole.errore retorna{ error: '...' }com status 500. - saveBeacon() —
try/catchsilencioso. Se o beacon falhar, nenhum erro é propagado — não há como tratar no contexto debeforeunload. - Botões —
setButtonLoading()desabilita ambos os botões durante o save (texto "SAVING...") e reabilita após, independente de sucesso ou falha.
Mesmo no pior cenário (servidor indisponível), o jogo nunca trava — o jogador pode continuar jogando enquanto os dados se perdem silenciosamente.
Admin Panel: CRUD Completo sobre Scores
O painel administrativo (protegido por senha via process.env.NAVISTRON_PASSWORD) oferece operações CRUD sobre a collection scores através de /api/admin/scores:
- GET — Lista todos os scores (sem limite de 100), autenticado via query param
?password=... - DELETE — Remove um score por ID. Valida
ObjectId.isValid(id)antes de executardeleteOne(). Retorna 404 se o documento não existir. - PUT — Edita campos específicos de um score. Apenas 8 campos são editáveis (
name, score, tier, tierName, boosts, spread, time, difficulty). Campos numéricos são re-tipados comNumber(), strings comString(). UsaupdateOnecom$set. Retorna 400 se nenhum campo válido for enviado.
Isso permite que o administrador corrija entradas com nomes ofensivos, remova scores fraudulentos, ou ajuste dados incorretos sem acesso direto ao MongoDB.
FAQ — Perguntas Frequentes sobre Persistência de Scores
O sendBeacon é confiável para salvar scores?
O sendBeacon é suportado por todos os navegadores modernos e é a forma recomendada de enviar dados durante beforeunload. É "best-effort" — funciona na grande maioria dos casos, mas pode falhar em crashes forçados do navegador. Como é usado apenas para sessões anônimas (não para scores registrados), a perda eventual de uma sessão Unknown é aceitável.
Por que a sanitização é feita duas vezes?
A sanitização no cliente melhora a experiência (o jogador vê o nome processado antes do envio), mas não é confiável contra manipulação. A sanitização no servidor é a fonte real de proteção — garante que mesmo requisições forjadas com curl ou ferramentas de desenvolvimento não inserem dados malformados no MongoDB.
O que acontece se o servidor estiver fora do ar?
O DB.save() retorna { id: null } e o saveScore() define lastSavedId = null. O leaderboard abre sem highlight. O jogador pode continuar jogando — o jogo nunca depende da API para funcionar. O score daquela partida é perdido permanentemente.
Como funciona o destaque do score no leaderboard?
O insertOne do MongoDB retorna insertedId, que é convertido para string e armazenado no cliente como lastSavedId. Ao renderizar o leaderboard, cada linha é comparada com esse ID. A linha correspondente recebe classe CSS highlight e a tabela rola automaticamente com scrollIntoView suave após 120ms de delay.
