Em jogos web, cada milissegundo importa. Um frame drop durante uma esquiva pode significar game over. Diferente de aplicações tradicionais onde "rápido o suficiente" basta, jogos precisam manter 60 frames por segundo consistentes — sem picos, sem pausas, sem stuttering.
Neste tutorial, analisamos as técnicas de performance usadas no Navistron — um jogo Canvas 2D que renderiza 270+ draw calls por frame com meteoros, projéteis, partículas e HUD, tudo em JavaScript puro. Cada técnica é verificada no código-fonte real, com trade-offs honestos.
Game Loop: requestAnimationFrame com Delta Time
A base de todo jogo web é o game loop — o ciclo infinito que atualiza a lógica e desenha cada frame. O Navistron usa requestAnimationFrame (rAF) como motor do loop:
- rAF sincroniza com o monitor — roda a 60 FPS em telas de 60 Hz, 120 FPS em telas de 120 Hz. Sem desperdício de CPU
- rAF pausa em abas inativas — quando o jogador muda de aba, o loop para automaticamente. Sem consumo de bateria em background
- Estrutura simples — uma função
loop(ts)que calcula delta time, chamaupdate(dt)edraw()em sequência, e agenda o próximo frame
O delta time (dt) é calculado como a diferença entre timestamps em segundos: (ts - lastTime) / 1000. Isso garante que a simulação roda na mesma velocidade independentemente da taxa de quadros — um monitor de 144 Hz vê movimentos suaves, não acelerados.
Clamping de Delta Time: Protegendo Contra Lag Spikes
Um detalhe crítico que muitos tutoriais ignoram: o clamping de delta time. O Navistron aplica Math.min(dt, 0.05) — limitando o delta a 50 milissegundos (equivalente a 20 FPS mínimo).
Por que isso importa? Quando o jogador muda de aba e volta 10 segundos depois, o rAF entrega um dt de 10 — sem clamping, todos os objetos "teleportariam" pelo cenário. Com o limite de 0.05, a simulação nunca avança mais que 1/20 de segundo por frame, independentemente da pausa real.
Outra técnica do Navistron: na inicialização, dois frames de rAF são encadeados antes de começar o loop — o primeiro apenas define lastTime, evitando um delta gigante no primeiro frame.
Resolução Fixa com CSS Scaling
O canvas do Navistron opera em resolução fixa de 540×720 pixels (proporção 3:4), independentemente do dispositivo. A adaptação visual é feita exclusivamente via CSS — canvas.style.width e canvas.style.height são ajustados para preencher o viewport mantendo a proporção.
Essa decisão tem impacto direto em performance:
| Abordagem | Resolução Efetiva (Retina 2×) | Pixels Processados |
|---|---|---|
| Resolução fixa 540×720 | 540×720 | 388.800 |
| DPR-aware (devicePixelRatio) | 1080×1440 | 1.555.200 |
| Full HD nativo | 1920×1080 | 2.073.600 |
A resolução fixa processa 4× menos pixels que a alternativa DPR-aware em telas Retina. Para um jogo com estética pixel-art/retro, a perda de nitidez é imperceptível — e o ganho de desempenho é enorme, especialmente em dispositivos móveis.
Uma variável SCALE (razão entre o tamanho CSS e os 540px lógicos) é usada exclusivamente para converter coordenadas de input (mouse/touch) para o espaço do canvas.
Renderização 100% Procedural: Zero Imagens
O Navistron não carrega nenhuma imagem externa durante o gameplay. Todos os elementos visuais são desenhados proceduralmente com a Canvas 2D API:
- Nave — caminhos (
ctx.lineTo), gradientes radiais para brilho do motor, chama de propulsão, casco com gradiente, cockpit - Meteoros — polígonos gerados aleatoriamente no spawn com
pts(ângulo e raio por vértice), aura com gradiente radial, barra de HP - Projéteis — gradiente radial + dois arcos concêntricos
- Mísseis — trilha com polyline, corpo com gradiente, ponta triangular rotacionada
- Partículas — arcos com
shadowBlurpara efeito de glow - Boosts — gradientes + caractere Unicode (★ e ⬆)
- Estrelas — 180 arcos individuais com paralaxe
Zero Image(), zero drawImage(), zero sprite sheets. Essa abordagem elimina latência de carregamento, problemas de CORS, e a complexidade de gerenciar atlas de sprites. O trade-off é mais trabalho de CPU por frame para criar gradientes — mas em canvas 540×720, o custo é aceitável.
Pipeline de Renderização: 15 Camadas em Um Canvas
Tudo é renderizado em um único canvas, um único contexto 2D, em ordem precisa de trás para frente (painter's algorithm). A função draw() executa 15 camadas por frame:
- Screen shake — translação do contexto se
shakeTimer > 0(impacto visual em colisões) - Background — preenchimento sólido com cor tintada pelo tier atual
- Nebulosas — 3 blobs com
createRadialGradientpara profundidade visual - Campo de estrelas — 180 estrelas com paralaxe individual
- Meteoros — aura, polígono, gradiente, contorno, barra de HP (3–5 draw calls cada)
- Boosts — gradientes + texto Unicode
- Projéteis — gradiente radial + arcos
- Mísseis — trilha, gradiente, triângulo rotacionado
- Partículas — arcos com shadowBlur
- Debris — fragmentos com trilha, gravidade e rotação
- Shockwave — onda de impacto na morte (arcos com shadowBlur)
- Screen flash — flash branco decrescente na morte
- Nave — motor, chama, casco, cockpit, asas (~10 draw calls)
- Indicadores de spread — pontos abaixo da nave
- Crosshair/Dificuldade — mira (desktop) + texto de dificuldade
Em mid-game típico, o total chega a 270–300 draw calls por frame. Sem batching, sem cache de camadas, sem composição offscreen — cada entidade é redesenhada individualmente a cada frame. É a abordagem mais simples e, para a escala do Navistron, suficiente.
Colisão por Distância Quadrada: Eliminando Math.sqrt
O Navistron usa detecção de colisão círculo-contra-círculo, otimizada com distância ao quadrado — evitando completamente Math.sqrt():
A função circleDist(ax, ay, ar, bx, by, br) calcula dx*dx + dy*dy e compara com (ar+br)*(ar+br). Matematicamente equivalente à distância euclidiana, mas sem a raiz quadrada — que é a operação mais cara em detecção de colisão.
As verificações ocorrem em quatro pares por frame:
| Par de Colisão | Complexidade | Raio Especial |
|---|---|---|
| Projéteis × Meteoros | O(b × m) | Meteoro: r × 0.82 |
| Mísseis × Meteoros | O(missiles × m) | — |
| Nave × Meteoros | O(m) | Meteoro: r × 0.72 (hitbox menor) |
| Nave × Boosts | O(boosts) | Nave: raio 24 |
Note os fatores de ajuste: meteoros usam 82% do raio visual para colisão com projéteis, e apenas 72% para colisão com a nave — criando um hitbox "generoso" que perdoa colisões tangenciais. Essa é uma técnica clássica de game design: a hitbox da nave é menor que o sprite visual para evitar mortes injustas.
Gestão de Entidades: Arrays + filter()
Todas as entidades do jogo — projéteis, meteoros, partículas, boosts, mísseis, debris — são armazenadas em arrays simples. Novos objetos são criados como object literals e adicionados via push().
A remoção usa o padrão Array.filter() a cada frame:
particles = particles.filter(p => p.life > 0)bullets = bullets.filter(b => b.life > 0 && ...)meteors = meteors.filter(m => !m.dead && m.y < H+120)
Esse padrão é simples e legível, mas cria 6 novos arrays por frame — gerando pressão no garbage collector. Alternativas como manter um pool de objetos reutilizáveis ou usar splice() evitam a criação de novos arrays, mas aumentam a complexidade do código. Para jogos com menos de 200 entidades simultâneas, o filter() é performático o suficiente.
Campo de Estrelas com Paralaxe e Reciclagem
O background do Navistron renderiza 180 estrelas com paralaxe — cada uma com velocidade individual (0.1 a 0.7), criando ilusão de profundidade. As estrelas não são destruídas e recriadas: quando uma estrela ultrapassa a parte inferior da tela, ela é reciclada — volta ao topo com nova posição X aleatória.
Essa reciclagem é uma forma primitiva de object pooling: o array de estrelas é alocado uma única vez em initStars() e reutilizado indefinidamente. Cada estrela é um objeto mutável atualizado in-place a cada frame.
A velocidade de scroll das estrelas escala com o tier atual: 1 + powerTier × 0.45. No tier VII, as estrelas movem 4.15× mais rápido, reforçando a sensação de velocidade crescente.
Input: Event-Based, Sem Polling
O Navistron não faz polling de input. Em vez de verificar o estado do teclado/mouse a cada frame, usa listeners de eventos que atualizam um objeto pointer compartilhado:
- Mouse —
mousemoveemouseleaveno canvas atualizampointer.x,pointer.yepointer.active - Touch —
touchstart,touchmove,touchendcom{ passive: false }ee.preventDefault()para bloquear gestos do navegador - Offset de toque — constante
TOUCH_OFFSET_Y = -140eleva a nave 140px acima do dedo, para que o jogador veja a nave em dispositivos touch
O objeto pointer é mutado in-place (nunca recriado) — evitando alocação de novos objetos. O game loop simplesmente lê pointer.x e pointer.y, aplicando suavização exponencial via lerp: 1 - Math.exp(-SHIP_LERP × dt), onde SHIP_LERP = 12.
CSS também contribui: touch-action: none no canvas e no container root impede que o navegador interprete gestos de scroll, zoom ou swipe-back durante o jogo.
React Bypass: Canvas Fora do Ciclo de Render
Integrar um game loop Canvas com React é um desafio de performance — re-renders do React podem interferir no frame rate. O Navistron resolve isso com zero interferência:
- O componente React renderiza apenas o DOM estático (canvas, overlays, HUD divs) — uma vez
- Um
useRefgarante queinitGame()é chamado uma única vez, mesmo no StrictMode (que invoca effects duas vezes em desenvolvimento) - Todo o game loop roda em JavaScript vanilla —
requestAnimationFrame, manipulação direta do canvas viactx, atualização de HUD viadocument.getElementById().textContent - Nenhum
setState, nenhum re-render React durante gameplay — zero overhead do virtual DOM
Essa arquitetura trata o React como um bootstrapper: ele monta o DOM inicial e sai do caminho. O jogo assume o controle total do canvas e dos elementos de HUD. É a abordagem mais performática possível para jogos Canvas em ecossistemas React/Next.js.
Dificuldade Progressiva e Impacto em Performance
O sistema de dificuldade do Navistron escala com tier e score: TIER_DIFF[tier] × (1 + score × 0.0007). No tier VII, o multiplicador base é 14.0 — e com score alto, ultrapassa 20.
Isso afeta diretamente a performance:
- Mais meteoros — spawn a cada ~0.3s no tier VII, com 45% de chance de spawnar dois simultâneos quando a dificuldade excede 8
- Mais projéteis — fire rate de 0.06s no tier VII (~17 tiros/segundo), gerando dezenas de projéteis simultâneos
- Mais partículas — cada meteoro destruído gera 14–30 partículas. Um tier-up explode todos os meteoros de uma vez, potencialmente gerando +300 partículas instantaneamente
- Mais colisões — 15+ meteoros × 50+ projéteis = 750 verificações por frame
No pico de dificuldade, o jogo processa 15+ meteoros, 50+ projéteis, 5 mísseis guiados e 100+ partículas — com draw calls e gradientes individuais para cada um. Manter 60 FPS nesse cenário valida a decisão de resolução fixa em 540×720.
Partículas: Explosão, Debris e shadowBlur
O sistema de partículas do Navistron usa alocação direta: cada evento gera objetos literais adicionados ao array particles. Os três gatilhos principais são:
- spawnExplosion — 14 partículas (pequena) ou 30 (grande), com hue override opcional
- spawnShipDeathExplosion — 60 partículas + 12 debris com trilha, gravidade e rotação
- spawnSecondaryExplosions — 54 partículas adicionais (3 bursts × 18) para a animação de morte prolongada
Cada partícula é desenhada com shadowBlur = 8, que cria um efeito de glow convincente mas é uma das operações mais caras do Canvas 2D — o navegador precisa aplicar um filtro Gaussian blur em cada elemento. Com 100+ partículas, esse é o custo mais pesado do pipeline de renderização.
O sistema não impõe limite máximo de partículas. Nos picos (tier-up com muitos meteoros), o count pode ultrapassar 300 — mas como o life de cada partícula é curto (~0.3-1.0s), o pico é transitório.
FAQ — Perguntas Frequentes sobre Performance em Jogos Web
Por que usar resolução fixa em vez de devicePixelRatio?
DPR-aware rendering multiplica a resolução do canvas pela densidade de pixels do dispositivo. Em telas Retina (2× ou 3×), isso significa processamento de 4× a 9× mais pixels por frame. Para jogos com estética retro/pixel como o Navistron, a diferença visual é irrelevante, mas o ganho de performance é substancial — especialmente em dispositivos móveis com GPU limitada.
Object pooling é realmente necessário para jogos Canvas 2D?
Depende da escala. O Navistron funciona bem sem pooling porque mantém menos de ~200 entidades simultâneas e usa resolução fixa de 540×720. Para jogos com milhares de entidades (bullet hell extremo, simulações de partículas), pooling se torna essencial para evitar pausas de garbage collection. O sintoma clássico é um "micro-stutter" a cada 1-2 segundos — se isso aparecer, pooling é a primeira otimização a implementar.
Qual a vantagem de renderizar tudo proceduralmente sem imagens?
Três vantagens: (1) zero latência de carregamento — o jogo fica jogável imediatamente, sem preloader; (2) zero problemas de CORS, formatos, ou atlas de sprites; (3) flexibilidade total — cores, tamanhos e formas podem variar dinamicamente sem trocar sprites. O trade-off é mais CPU por frame para criar gradientes, mas em resoluções de 540×720 é aceitável.
Como evitar lag ao mudar de aba e voltar ao jogo?
Quando o jogador sai da aba, o requestAnimationFrame pausa automaticamente. Ao voltar, o próximo frame recebe um delta time enorme (segundos ou minutos). Sem proteção, todos os objetos "teleportam". A solução é clamping de delta time: limitar o máximo a 50ms (ou 33ms) para que a simulação avance no máximo 1/20 de segundo por frame. O Navistron aplica Math.min(dt, 0.05) em toda chamada do loop.
Posso usar WebGL em vez de Canvas 2D para melhor performance?
Sim, WebGL oferece performance significativamente superior para jogos com muitas entidades, batching de sprites, e efeitos de shader. Porém, a complexidade de implementação aumenta drasticamente — shaders GLSL, gerenciamento de buffers, matrix transforms manuais. Para jogos 2D com menos de 500 entidades e estética simples, Canvas 2D é suficiente e muito mais rápido de implementar. O Navistron prova que é possível manter 60 FPS com 300 draw calls por frame usando apenas Canvas 2D em resolução fixa.
