Skip to content

Fundamentos do Loop de Jogo com Canvas 2D

O loop de jogo é o coração do seu jogo. Ele é executado continuamente, atualizando o estado do jogo e desenhando quadros tão rápido quanto o navegador permite. Faça isso direito e todo o resto ficará mais fácil. Faça errado e você ficará procurando bugs que só aparecem em determinadas máquinas.

Experimente (use as setas ou WASD):

A versão mais simples

Aqui está um loop de jogo reduzido ao essencial:

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

function gameLoop() {
  update()
  render()
  requestAnimationFrame(gameLoop)
}

function update() {
  // Game logic here
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Drawing here
}

gameLoop()

Isso funciona, mas há um problema. A função update não sabe quanto tempo passou desde o último quadro. Em um monitor de 144 Hz, ela é executada duas vezes mais rápido do que em um monitor de 60 Hz. A física do seu jogo se comportará de maneira diferente em máquinas diferentes.

O delta time corrige problemas de taxa de quadros

Para que seu jogo seja executado na mesma velocidade em qualquer lugar, você precisa acompanhar quanto tempo passou entre os quadros. Isso é chamado de delta time, geralmente abreviado como dt.

js
let lastTime = 0

function gameLoop(currentTime) {
  const dt = (currentTime - lastTime) / 1000 // convert to seconds
  lastTime = currentTime
  
  update(dt)
  render()
  
  requestAnimationFrame(gameLoop)
}

function update(dt) {
  player.x += player.speed * dt // now works at any frame rate
}

requestAnimationFrame(gameLoop)

O callback de requestAnimationFrame recebe o timestamp atual em milissegundos. Eu divido por 1.000 para obter segundos porque isso facilita os cálculos. Uma velocidade de 200 significa 200 pixels por segundo, o que é intuitivo.

Um detalhe importante: no primeiro quadro, lastTime é 0, então dt será enorme. Algumas pessoas inicializam lastTime com performance.now() antes de iniciar o loop ou ignoram a atualização do primeiro quadro.

Por que usar requestAnimationFrame em vez de setInterval

Você talvez esteja se perguntando por que usamos requestAnimationFrame em vez de setInterval. Há alguns motivos.

Primeiro, requestAnimationFrame sincroniza com a taxa de atualização da tela, proporcionando uma animação suave e sem tearing. Segundo, ele pausa automaticamente quando a aba fica oculta, economizando bateria e CPU. Terceiro, o navegador consegue otimizá-lo melhor.

Esse comportamento de pausa geralmente é o que você deseja, mas significa que o jogo congela quando a pessoa muda de aba. Se você estiver criando um jogo multijogador ou algo que precise continuar em execução, será necessário usar outra abordagem.

Passo de tempo fixo para física

O delta time variável funciona para movimentos simples, mas causa problemas em simulações de física. Se um quadro demorar demais, seu personagem poderá atravessar uma parede porque a verificação de colisão não detectou o momento do impacto.

A solução é usar um passo de tempo fixo. Você acumula o tempo e executa as atualizações de física em uma frequência fixa, independentemente da velocidade com que os quadros são renderizados.

js
const FIXED_DT = 1 / 60 // 60 physics updates per second
let accumulator = 0
let lastTime = 0

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

Esse Math.min(..., 0.25) é importante. Se a aba estava oculta e voltar a ficar visível, frameTime poderá ser enorme. Sem esse limite, o loop while executaria centenas de atualizações para tentar compensar o atraso, travando o jogo. Limitar o valor a 0,25 segundo significa que você nunca executará mais de 15 atualizações de física por quadro.

Organizando o estado do jogo

À medida que seu jogo cresce, você precisa de um lugar para armazenar todos os dados. Gosto de usar um único objeto que contenha tudo:

js
const game = {
  state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
  score: 0,
  player: {
    x: 100,
    y: 100,
    vx: 0,
    vy: 0,
    speed: 200,
    width: 32,
    height: 32,
  },
  entities: [],
}

Isso facilita salvar e carregar o estado do jogo, reiniciar quando o jogador morre e depurar registrando game no console. Algumas pessoas preferem classes, mas um objeto simples funciona bem para jogos pequenos e médios.

Juntando tudo

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0

const input = { left: false, right: false, up: false, down: false }

const game = {
  player: { x: 100, y: 100, speed: 200 },
}

function update(dt) {
  const p = game.player
  
  if (input.left) p.x -= p.speed * dt
  if (input.right) p.x += p.speed * dt
  if (input.up) p.y -= p.speed * dt
  if (input.down) p.y += p.speed * dt
  
  // Keep in bounds
  p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
  p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}

function render() {
  ctx.fillStyle = '#1a1a2e'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#4ade80'
  ctx.fillRect(game.player.x, game.player.y, 32, 32)
}

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

// Input handling
window.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})

window.addEventListener('keyup', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})

requestAnimationFrame(gameLoop)

Este é um loop de jogo completo e executável. O jogador se move com as setas ou WASD, permanece dentro dos limites e tudo funciona em uma velocidade constante, independentemente da taxa de quadros. Você pode copiar este código como ponto de partida para qualquer jogo com Canvas 2D.

Observe como o tratamento de entrada está separado do loop de jogo. Os listeners de eventos apenas definem flags, e a função de atualização lê essas flags. Esse desacoplamento é importante porque os eventos de teclado são disparados em momentos imprevisíveis, enquanto você quer que a lógica do jogo seja executada em uma frequência previsível.

Carregando e desenhando sprites

js
const sprites = {}

async function loadSprite(name, url) {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => {
      sprites[name] = img
      resolve(img)
    }
    img.src = url
  })
}

function drawSprite(name, x, y, width, height) {
  const sprite = sprites[name]
  if (sprite) {
    ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
  }
}

As imagens são carregadas de forma assíncrona, então você precisa esperar por elas antes de iniciar o jogo. Um padrão comum é carregar todos os sprites em uma função loadAssets que retorna uma Promise e iniciar o loop de jogo somente depois que ela for resolvida.

A função drawSprite verifica se o sprite existe antes de desenhá-lo. Isso evita erros caso você tente desenhar algo que ainda não foi carregado.

Sprites animados

js
class AnimatedSprite {
  constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
    this.image = image
    this.frameWidth = frameWidth
    this.frameHeight = frameHeight
    this.frameCount = frameCount
    this.frameDuration = 1 / fps
    this.currentFrame = 0
    this.elapsed = 0
  }
  
  update(dt) {
    this.elapsed += dt
    if (this.elapsed >= this.frameDuration) {
      this.currentFrame = (this.currentFrame + 1) % this.frameCount
      this.elapsed = 0
    }
  }
  
  draw(ctx, x, y) {
    ctx.drawImage(
      this.image,
      this.currentFrame * this.frameWidth, 0,
      this.frameWidth, this.frameHeight,
      x, y,
      this.frameWidth, this.frameHeight
    )
  }
}

Essa classe pressupõe que sua sprite sheet tenha os quadros dispostos horizontalmente em uma única linha. Cada quadro tem o mesmo tamanho. O método update avança pelos quadros com base no tempo decorrido, e draw usa a versão de nove argumentos de drawImage para recortar apenas o quadro atual.

Você a usaria assim:

js
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// In update:
playerWalk.update(dt)

// In render:
playerWalk.draw(ctx, player.x, player.y)

Detecção de colisão

js
function rectCollision(a, b) {
  return (
    a.x < b.x + b.width &&
    a.x + a.width > b.x &&
    a.y < b.y + b.height &&
    a.y + a.height > b.y
  )
}

function circleCollision(a, b) {
  const dx = a.x - b.x
  const dy = a.y - b.y
  const distance = Math.sqrt(dx * dx + dy * dy)
  return distance < a.radius + b.radius
}

// In update:
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}

A colisão entre retângulos, também chamada de AABB (caixa delimitadora alinhada aos eixos), é a opção mais simples e rápida. Use-a para a maioria dos casos. A colisão entre círculos é melhor para objetos redondos ou quando você quer que as colisões sejam mais tolerantes.

Para poucas entidades, não há problema em verificar cada par. Se você tiver centenas de entidades, será melhor usar particionamento espacial, como quadtrees ou uma grade, mas esse é um assunto para outro tutorial.

Uma câmera simples

js
const camera = {
  x: 0,
  y: 0,
  
  follow(target) {
    this.x = target.x - canvas.width / 2
    this.y = target.y - canvas.height / 2
  }
}

function render() {
  ctx.save()
  ctx.translate(-camera.x, -camera.y)
  
  // Draw world
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // Draw UI (not affected by camera)
  drawUI()
}

O segredo está em ctx.save() e ctx.restore(). Você salva o estado atual da transformação, aplica o deslocamento da câmera com translate, desenha tudo no espaço do mundo e então restaura o estado para que os elementos da interface não sejam afetados pela câmera.

Para suavizar o movimento da câmera, você pode interpolar em direção ao alvo em vez de saltar diretamente para ele. Algo como this.x += (targetX - this.x) * 0.1 produz aquele agradável efeito de atraso.

Pausando o jogo

js
let paused = false

window.addEventListener('keydown', (e) => {
  if (e.code === 'Escape') {
    paused = !paused
  }
})

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  if (!paused) {
    accumulator += frameTime
    while (accumulator >= FIXED_DT) {
      update(FIXED_DT)
      accumulator -= FIXED_DT
    }
  }
  
  render()
  
  if (paused) {
    renderPauseOverlay()
  }
  
  requestAnimationFrame(gameLoop)
}

function renderPauseOverlay() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#fff'
  ctx.font = '32px sans-serif'
  ctx.textAlign = 'center'
  ctx.fillText('PAUSADO', canvas.width / 2, canvas.height / 2)
}

A ideia principal é parar de acumular tempo enquanto o jogo está pausado, mas continuar renderizando. Assim, a sobreposição de pausa aparece imediatamente e o jogo continua respondendo ao comando para retomar.

Você pode ampliar esse padrão para lidar com vários estados do jogo, como menus, cenas cinematográficas e telas de fim de jogo. Basta verificar o estado atual e direcionar a execução para diferentes funções de atualização e renderização.

Erros comuns

Alguns problemas que já vi confundirem muita gente:

Esquecer de limpar o canvas. Se você não chamar clearRect no início da renderização, desenhará sobre o quadro anterior e produzirá rastros na imagem.

Não limitar o delta time. Se alguém mudar de aba e voltar, um delta time enorme poderá quebrar a física ou fazer o jogo tentar "compensar o atraso" por vários segundos.

Desenhar antes de as imagens carregarem. O Canvas ignora silenciosamente chamadas de drawImage com imagens que ainda não foram carregadas. Seus sprites simplesmente não aparecerão, sem nenhuma mensagem de erro.

Usar setInterval para o loop. Ele não sincroniza com a tela nem pausa quando a aba fica oculta. Use requestAnimationFrame.

Misturar coordenadas do mundo e da tela. Ao adicionar uma câmera, acompanhe se uma posição está no espaço do mundo ou no espaço da tela. Cliques do mouse chegam em coordenadas da tela e precisam ser convertidos.

Próximos passos

Este tutorial aborda os fundamentos. Quando você estiver familiarizado com esses padrões, talvez queira explorar:

Canvas responsivo para jogos para lidar com diferentes tamanhos de tela e telas de alta densidade de pixels.

Tratamento de entrada em jogos para controles por toque, gamepads e sistemas de entrada mais sofisticados.

Renderização de pixel art para obter gráficos em pixel nítidos e sem desfoque.

Bibliotecas de física para jogos para adicionar física de corpos rígidos, colisões e juntas ao loop do seu jogo.

Se você estiver decidindo entre Canvas 2D, WebGL e WebGPU para seu projeto, o guia Stack de tecnologias para jogos web em 2026 explica as vantagens e desvantagens de cada opção. A Comparação de engines para jogos web aborda frameworks que cuidam do loop de jogo para você.

Recursos externos