Skip to content

Canvas 2D Game Loop Fundamentals

The game loop is the heartbeat of your game. It runs continuously, updating game state and drawing frames as fast as the browser allows. Get this right and everything else becomes easier. Get it wrong and you'll chase bugs that only appear on certain machines.

Try it (use arrow keys or WASD):

The Simplest Version

Here's a game loop stripped to its essentials:

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()

This works, but it has a problem. The update function doesn't know how much time has passed since the last frame. On a 144Hz monitor, it runs twice as fast as on a 60Hz monitor. Your game physics will behave differently on different machines.

Delta Time Fixes Frame Rate Issues

To make your game run at the same speed everywhere, you need to track how much time passed between frames. That's called delta time, usually abbreviated 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)

The requestAnimationFrame callback receives the current timestamp in milliseconds. I divide by 1000 to get seconds because it makes the math easier. A speed of 200 means 200 pixels per second, which is intuitive.

One thing to watch out for: on the first frame, lastTime is 0, so dt will be huge. Some people initialize lastTime with performance.now() before starting the loop, or they skip the first frame's update.

Why requestAnimationFrame Instead of setInterval

You might wonder why we use requestAnimationFrame instead of setInterval. There are a few reasons.

First, requestAnimationFrame syncs with the display's refresh rate, so you get smooth animation without tearing. Second, it automatically pauses when the tab is hidden, which saves battery and CPU. Third, the browser can optimize it better.

That pause behavior is usually what you want, but it means your game freezes when the user switches tabs. If you're building a multiplayer game or something that needs to keep running, you'll need a different approach.

Fixed Timestep for Physics

Variable delta time works for simple movement, but it causes problems with physics simulations. If a frame takes too long, your character might tunnel through a wall because the collision check missed the moment of impact.

The solution is a fixed timestep. You accumulate time and run physics updates at a fixed rate, regardless of how fast frames are rendering.

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)
}

That Math.min(..., 0.25) is important. If the tab was hidden and then comes back, frameTime could be huge. Without the cap, the while loop would run hundreds of updates trying to catch up, freezing the game. Capping at 0.25 seconds means you'll never run more than 15 physics updates per frame.

Organizing Game State

As your game grows, you need somewhere to put all the data. I like to use a single object that holds everything:

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: [],
}

This makes it easy to save and load game state, reset when the player dies, and debug by logging game to the console. Some people prefer classes, but a plain object works fine for small to medium games.

Putting It All Together

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)

This is a complete, runnable game loop. The player moves with arrow keys or WASD, stays within bounds, and everything runs at a consistent speed regardless of frame rate. You can copy this as a starting point for any Canvas 2D game.

Notice how input handling is separate from the game loop. The event listeners just set flags, and the update function reads those flags. This decoupling is important because keyboard events fire at unpredictable times, and you want your game logic to run at a predictable rate.

Loading and Drawing 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)
  }
}

Images load asynchronously, so you need to wait for them before starting your game. A common pattern is to load all sprites in a loadAssets function that returns a Promise, then start the game loop only after that resolves.

The drawSprite function checks if the sprite exists before drawing. This prevents errors if you try to draw something that hasn't loaded yet.

Animated Sprites

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
    )
  }
}

This class assumes your sprite sheet has frames laid out horizontally in a single row. Each frame is the same size. The update method advances through frames based on elapsed time, and draw uses the nine-argument version of drawImage to clip out just the current frame.

You'd use it like this:

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

// In update:
playerWalk.update(dt)

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

Collision Detection

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)
  }
}

Rectangle collision (also called AABB, for axis-aligned bounding box) is the simplest and fastest option. Use it for most things. Circle collision is better for round objects or when you want collisions to feel more forgiving.

For a small number of entities, checking every pair is fine. If you have hundreds of entities, you'll want spatial partitioning (quadtrees or a grid), but that's a topic for another tutorial.

A Simple Camera

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()
}

The trick is ctx.save() and ctx.restore(). You save the current transform state, apply the camera offset with translate, draw everything in world space, then restore so UI elements aren't affected by the camera.

For smoother camera movement, you can lerp toward the target instead of snapping directly to it. Something like this.x += (targetX - this.x) * 0.1 gives you that nice trailing effect.

Pausing the Game

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('PAUSED', canvas.width / 2, canvas.height / 2)
}

The key insight is that you stop accumulating time when paused, but you keep rendering. This way the pause overlay appears immediately and the game stays responsive to the unpause input.

You could extend this pattern to handle multiple game states like menus, cutscenes, and game over screens. Just check the current state and route to different update/render functions.

Common Mistakes

A few things I've seen trip people up:

Forgetting to clear the canvas. If you don't call clearRect at the start of render, you'll draw on top of the previous frame and get smearing.

Not capping delta time. If someone switches tabs and comes back, a huge delta time can break your physics or cause the game to "catch up" for seconds.

Drawing before images load. Canvas silently ignores drawImage calls with images that haven't loaded. Your sprites just won't appear, with no error message.

Using setInterval for the loop. It doesn't sync with the display and doesn't pause when the tab is hidden. Use requestAnimationFrame.

Mixing world and screen coordinates. When you add a camera, keep track of whether a position is in world space or screen space. Mouse clicks come in screen space and need to be converted.

What's Next

This tutorial covers the fundamentals. Once you're comfortable with these patterns, you might want to explore:

Responsive game canvas for handling different screen sizes and high DPI displays.

Game input handling for touch controls, gamepads, and more sophisticated input systems.

Pixel art rendering for crisp pixel graphics without blurring.