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:

  1. Leitura do nomedocument.getElementById('name-input').value.trim(). Se vazio, usa "ANONYMOUS".
  2. Sanitização client-sidename.trim().toUpperCase().slice(0, 20) — remove espaços, converte para maiúsculas, limita a 20 caracteres.
  3. EnvioPOST /api/scores com Content-Type: application/json, corpo com 8 campos (name + os 7 de gameplay).
  4. Sanitização server-side — A API re-aplica String(body.name || 'ANONYMOUS').toUpperCase().slice(0, 20) e converte cada campo numérico com Number() com defaults seguros.
  5. Timestamp — O servidor adiciona playedAt: new Date() — timestamp confiável gerado server-side.
  6. Inserçãodb.collection('scores').insertOne(record) no banco navistron.
  7. Resposta — Retorna o documento inserido com id: result.insertedId.toString().
  8. Highlight — O ID retornado é armazenado em lastSavedId para 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 com type: "Unknown". O sendBeacon é fire-and-forget: o navegador garante o envio mesmo durante a destruição da página.
  • Novo jogo — A função startGame() verifica se existe pendingSession não resolvida. Se sim, usa AnonymousDB.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 de process.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 adaptertry/catch retorna { id: null } se o fetch falhar. O jogo continua normalmente.
  • saveScore()try/catch loga warning no console e define lastSavedId = null. O leaderboard abre sem highlight.
  • API Routestry/catch loga erro via console.error e retorna { error: '...' } com status 500.
  • saveBeacon()try/catch silencioso. Se o beacon falhar, nenhum erro é propagado — não há como tratar no contexto de beforeunload.
  • BotõessetButtonLoading() 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 executar deleteOne(). 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 com Number(), strings com String(). Usa updateOne com $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.