O Navistron roda suavemente a 60fps num canvas de 540×720 com centenas de entidades simultâneas — sem sprites, sem WebGL, sem framework de game engine. A otimização acontece em três camadas: renderização do jogo (Canvas 2D), carregamento da página (Next.js com Server Components) e entrega (CDN da Vercel). Neste artigo, exploramos cada técnica com os valores exatos do código-fonte.
Canvas Fixo 540×720 com CSS Scaling
A decisão arquitetural mais impactante para performance é a resolução interna fixa. O canvas do Navistron opera sempre em 540×720 pixels (proporção 3:4), independente do tamanho da tela. Um fator SCALE = containerWidth / 540 é calculado no resizeCanvas(), e o canvas é escalado via CSS — não via redimensionamento da superfície de desenho.
Isso significa que em uma tela 4K (3840×2160), o jogo não desenha 3840×2160 pixels. Ele desenha 540×720 = ~389.000 pixels e o navegador faz o upscale via GPU. Comparado a renderizar nativamente em 4K (~8 milhões de pixels), isso é uma redução de ~95% no fill rate. Todas as coordenadas, hitboxes e velocidades operam nessa resolução fixa, garantindo física consistente em qualquer dispositivo.
Game Loop: requestAnimationFrame com Delta Time Cap
O game loop usa requestAnimationFrame com delta time calculado e capped:
const dt = Math.min((ts - lastTime) / 1000, 0.05)
O cap de 0.05 segundos (50ms = 20 FPS mínimo) previne "explosões de física" — quando o jogador troca de aba e volta, o dt poderia ser 5-10 segundos, teleportando todos os objetos. Com o cap, o jogo nunca simula mais que 50ms por frame, mesmo que o frame real tenha demorado mais.
A inicialização do loop também é otimizada: o primeiro requestAnimationFrame define lastTime = ts sem executar update/draw, evitando um delta time gigante no primeiro frame. Cada frame segue a sequência: update(dt) → draw() → requestAnimationFrame(loop).
Renderização Procedural: Zero Sprites, Zero HTTP Requests
O Navistron não carrega nenhuma imagem para o jogo. Tudo é desenhado proceduralmente com primitivos do Canvas 2D: arc(), fillRect(), beginPath()/lineTo(), createRadialGradient() e ellipse(). A nave usa 4 gradientes radiais por frame, cada meteoro tem polígono + gradiente + crateras, cada bala tem gradiente + core branco.
O benefício é duplo: zero latência de carregamento (sem HTTP requests para spritesheets) e escalabilidade perfeita (primitivos vetoriais ficam suaves em qualquer resolução). A desvantagem — ~13+ chamadas createRadialGradient por frame — é aceitável na resolução compacta de 540×720.
Background sem clearRect: Overdraw Inteligente
O canvas não usa clearRect() para limpar o frame anterior. Em vez disso, o background é desenhado com fillRect(-16, -16, W+32, H+32) — um retângulo ligeiramente maior que o canvas (32px de margem) para cobrir qualquer bleeding sub-pixel. A cor de fundo muda dinamicamente com o tier (rgb(tr, 1, ...)), e 3 gradientes radiais com hsla em alpha baixo (0.12–0.18) criam efeitos de nebulosa.
A ordem de renderização segue o painter's algorithm (back-to-front): background → 180 estrelas → meteoros → boosts → balas → mísseis → partículas → debris → shockwave → nave → HUD. Nenhuma entidade precisa de depth sorting — a ordem é fixa por tipo.
Entidades: Filtros .filter() por Frame e Limites Rígidos
Cada tipo de entidade tem cleanup por frame via .filter():
- Partículas —
particles = particles.filter(p => p.life > 0). Explosão normal gera 14 (pequena) ou 30 (grande) partículas. Morte da nave gera 60 + 3×18 = 114 partículas - Balas —
bullets.filter(b => b.life > 0 && bounds check). Combinam life check + 4 bounds checks num único filter, evitando iterar duas vezes - Mísseis — Máximo de 5 mísseis simultâneos (
Math.min(5, ...)). Trail capped em 12 posições viashift() - Debris — 12 fragmentos por destruição, trail de 8 posições com
shift(). Ambos os arrays (debris e trails) filtrados por frame - Meteoros —
meteors.filter(m => !m.dead && m.y < H+120). Dead flag + off-screen check num único filter
O sistema não usa object pooling — partículas são criadas com objetos literais e descartadas pelo garbage collector. Com a resolução compacta e limites rígidos nas quantidades, a alocação é aceitável. Os vértices de cada meteoro (8–12 pontos do polígono) são pré-calculados no spawn e reutilizados em todos os frames subsequentes.
Colisão sem Math.sqrt: Distância Quadrática
A função de colisão usa a otimização clássica de distância quadrática:
function circleDist(ax,ay,ar,bx,by,br) { const dx=ax-bx, dy=ay-by; return dx*dx+dy*dy <= (ar+br)*(ar+br); }
Em vez de calcular Math.sqrt(dx² + dy²) <= ar + br, compara diretamente dx² + dy² <= (ar+br)². O Math.sqrt() é uma das operações mais caras da CPU — eliminá-lo reduz o custo de cada check de colisão significativamente.
O sistema faz 4 tipos de checagem por frame: balas × meteoros (O(n×m) bruto com if(b.hit) continue como early exit), mísseis × meteoros (com break no primeiro hit), nave × meteoros (apenas se ship.invincible <= 0), e nave × boosts (raio generoso de 24px). Não há spatial partitioning (quadtree, grid) — desnecessário na arena compacta de 540×720 com dezenas (não milhares) de entidades.
Server Components: Zero JavaScript para 90% das Páginas
No Next.js 15, componentes são Server Components por padrão. O Navistron tem 62+ páginas e apenas 3 são client components (marcadas com 'use client'): play/page.js (1.314 linhas), dashboard/page.js (481 linhas) e admin/page.js (722 linhas).
Todo o resto — home, ranking, blog (23 artigos), stats (4 variações), sponsors, 500+ perfis de jogador — são Server Components puros. O JavaScript deles nunca é enviado ao navegador. O client recebe apenas HTML + CSS. Isso resulta num First Load JS mínimo para a vasta maioria das páginas.
Os 5 componentes reutilizáveis (Header, Footer, Breadcrumb, RankingTable, JsonLd) também são Server Components — o menu hamburger mobile funciona com CSS puro (checkbox hack), sem JavaScript.
Zero Web Fonts: Courier New Nativo
O Navistron não carrega nenhuma web font. A família tipográfica é 'Courier New', monospace — uma fonte pré-instalada em todos os sistemas operacionais (Windows, macOS, Linux, iOS, Android). Isso elimina completamente:
- FOIT/FOUT (Flash of Invisible/Unstyled Text) — sem fontes para baixar, sem flash
- Requests HTTP extras — zero downloads de .woff2
- Impacto no LCP — texto renderizado instantaneamente
- CLS por troca de fonte — métricas idênticas entre fallback e fonte final (são a mesma)
A fonte monospace combina com a estética arcade/retro do jogo, tornando a limitação técnica uma decisão de design coerente.
Imagens WebP com next/image: Lazy Loading e CLS Zero
O projeto usa apenas 11 imagens no total (5 OG, 1 hero, 5 screenshots), todas em formato WebP — já comprimidas antes do processamento do Next.js. O componente next/image adiciona:
- priority — apenas em hero images (above-the-fold), ativa preload no
<head> - loading="lazy" — em todas as imagens below-the-fold
- width + height explícitos — previne CLS (o navegador reserva o espaço antes da imagem carregar)
- quality={80} — reduz tamanho mantendo qualidade visual aceitável
- sizes responsivo —
"(max-width: 768px) 100vw, 800px"para srcset otimizado por viewport
ISR: Revalidação Granular por Tipo de Conteúdo
Cada tipo de página do Navistron tem seu próprio intervalo de revalidação ISR, otimizado para a frequência de mudança dos dados:
- Stats diário — 120s (2 min). Dados mudam a cada partida
- Ranking / Stats geral e mensal — 300s (5 min)
- Stats anual / Sponsors — 600s (10 min). Dados mais estáveis
- Home / Player profiles — 3.600s (1 hora)
- Blog — SSG puro via
generateStaticParams()(rebuild only)
Os data fetchers usam Promise.all() extensivamente: a home carrega top 5 + sponsors em paralelo, o ranking carrega top 100 + sponsors, e as stats executam 9 aggregations MongoDB simultâneas. A API de stats faz 10 aggregations paralelas com $group, $bucket e $dateToString.
FAQ — Perguntas Frequentes sobre Performance
O jogo roda a 60fps?
Sim. O requestAnimationFrame sincroniza com a taxa de atualização do monitor (geralmente 60Hz). O delta time cap de 50ms garante que, mesmo em frames lentos, a física não "explode". Em dispositivos com tela de 120Hz+, o jogo roda a 120fps com dt proporcionalmente menor — a física é frame-rate independent graças ao movimento multiplicado por dt.
Por que não usar object pooling para partículas?
O Navistron cria partículas como objetos literais simples ({ x, y, vx, vy, life, ... }) e as descarta via .filter(). Com a resolução compacta de 540×720 e limites rígidos (máximo ~114 partículas numa explosão de morte), a alocação é rápida o suficiente. Object pooling adicionaria complexidade (pool sizing, reset de estado) sem ganho perceptível nessa escala.
O brute-force O(n×m) na colisão não é lento?
Não nesta escala. Com ~20 balas, ~5 mísseis e ~10 meteoros simultâneos, são ~250 checks por frame — cada um sendo apenas 3 multiplicações e 2 somas (sem sqrt). Spatial partitioning (quadtree, grid hash) tem overhead de construção que superaria o ganho em arenas com menos de ~100 entidades. O early exit (if(b.hit) continue) reduz ainda mais o trabalho real.
Como o jogo funciona sem sprites ou assets?
Tudo é desenhado com Canvas 2D API: arc() para círculos, beginPath()/lineTo() para polígonos, createRadialGradient() para efeitos de brilho, ellipse() para crateras. Os vértices dos meteoros são pré-calculados no spawn. Não há HTTP requests para assets de jogo — o tempo de carregamento é efetivamente zero.
Qual o impacto de ter apenas 6 dependências?
O mongodb é server-only (nunca no bundle do client). chart.js e react-chartjs-2 são carregados apenas no dashboard. Isso significa que o bundle de produção da maioria das páginas contém apenas React + Next.js runtime. A página do jogo em si tem zero dependências npm no client — apenas 1.314 linhas de JavaScript vanilla dentro de um wrapper React.
