When I first looked at the source code of a Canvas game, I expected something complex and monolithic. Instead it was surprisingly linear: a loop, a graphics context, a few objects. The complexity doesn't lie in the technology - it lies in understanding why things are structured a certain way.

This guide starts from zero and arrives at a working game. We won't use any external libraries. Every concept we cover applies to any 2D game - whether you want to rebuild Space Invaders, Pac-Man, or invent something entirely new.

What Is HTML5 Canvas and Why It's Perfect for Games

The <canvas> element is a bitmap drawing surface directly in the browser. No DOM, no child elements, no CSS acting on internal content: a rectangle of pixels on which you can draw anything via JavaScript.

This simplicity is exactly what makes it ideal for games. In games, rendering happens frame by frame: each frame erases everything and redraws from scratch. HTML DOM is optimized for documents that change rarely - not for updates at 60 frames per second. Canvas has no such problem: it's designed for continuous redrawing.

The minimal setup to get started:

<!-- HTML -->
<canvas id="game" width="800" height="600"></canvas>

<!-- JavaScript -->
const canvas = document.getElementById('game');
const ctx    = canvas.getContext('2d');

// Now you can draw:
ctx.fillStyle = '#FFD700';
ctx.fillRect(100, 100, 80, 80);  // x, y, width, height

The ctx object (the 2D rendering context) is your brush. It has methods to draw rectangles, circles, lines, text, images, arbitrary paths. Everything passes through it. Knowing it well is the first step to building any Canvas game.

The drawing operations you'll use 90% of the time:

  • ctx.fillRect(x, y, w, h) - filled rectangle
  • ctx.strokeRect(x, y, w, h) - rectangle outline only
  • ctx.clearRect(x, y, w, h) - clear an area (used every frame)
  • ctx.fillText(text, x, y) - text
  • ctx.drawImage(img, x, y, w, h) - image or sprite sheet
  • ctx.arc(x, y, radius, 0, Math.PI * 2) - circle (then fill() or stroke())

The Game Loop: requestAnimationFrame and Delta Time

The game loop is the most important concept in game development. It's the infinite cycle that keeps the game alive: updates logic, draws the result, repeats. 60 times per second, ideally. Without a game loop, your game is a screenshot.

In JavaScript, the correct way to build a game loop is with requestAnimationFrame:

function gameLoop(timestamp) {
  update(timestamp);  // update logic
  render();           // draw
  requestAnimationFrame(gameLoop);  // schedule next frame
}

requestAnimationFrame(gameLoop);  // start the loop

requestAnimationFrame is superior to setInterval for three reasons: it syncs with the monitor refresh rate (60/120Hz), automatically pauses when the tab is not visible, and provides a high-precision timestamp you can use to calculate delta time.

Delta time is the difference in time between one frame and the next. It's essential for making the game device-speed independent: on a slow computer the game runs at fewer frames per second, but objects must move at the same perceived speed.

let lastTime = 0;

function gameLoop(timestamp) {
  const dt = (timestamp - lastTime) / 1000;  // seconds since last frame
  lastTime = timestamp;

  update(dt);   // logic uses dt to calculate movement
  render();

  requestAnimationFrame(gameLoop);
}

// Example: an object moving at 200px/second
// regardless of frame rate
function update(dt) {
  player.x += player.speed * dt;  // 200 * 0.016 = 3.2px per frame at 60fps
}

"A game without delta time is a game that only works on the developer's computer."

- fundamental principle of the game loop

Drawing and Animating Sprites with Canvas 2D

Retro games use sprite sheets: single images containing all animation frames of a character, side by side. Instead of loading dozens of separate images, you load one image and show a different portion each frame.

The Canvas method for this is drawImage in its 9-parameter version:

// drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
//   sx,sy = frame coordinates in the sprite sheet (source)
//   sw,sh = frame dimensions in the sprite sheet
//   dx,dy = where to draw it on the canvas (destination)
//   dw,dh = output dimensions (you can scale)

const FRAME_W = 16;  // each frame is 16x16 pixels
const FRAME_H = 16;
let frame = 0;       // current animation frame

function drawPlayer(ctx, spriteSheet, x, y) {
  ctx.drawImage(
    spriteSheet,
    frame * FRAME_W, 0,   // sx, sy: jump to correct frame
    FRAME_W, FRAME_H,     // sw, sh: frame dimensions
    x, y,                 // dx, dy: position on canvas
    FRAME_W * 3, FRAME_H * 3  // dw, dh: scale 3x
  );
}

To animate, increment frame at regular intervals - not every game loop (60fps is too fast for a visible animation) but every N milliseconds:

const ANIM_SPEED = 150;  // ms between frames
let animTimer = 0;
const TOTAL_FRAMES = 4;

function update(dt) {
  animTimer += dt * 1000;  // convert to ms
  if (animTimer >= ANIM_SPEED) {
    frame = (frame + 1) % TOTAL_FRAMES;  // cycle through 0,1,2,3
    animTimer = 0;
  }
}

If you have no sprite sheet and are building a prototype, you can draw geometric shapes directly with Canvas. Space Invaders is built entirely with fillRect and arc - zero external images.

Handling Input: Keyboard, Touch and Key State

The most common beginner mistake: handling input directly in event listeners, moving the object inside keydown. The problem is that keydown fires once (then with delay) - not every frame. The result is jerky movement.

The correct pattern is the key state pattern: event listeners update a state (is this key pressed or not?), and the game loop reads that state every frame:

// Key state: true = pressed, false = released
const keys = {};

document.addEventListener('keydown', e => {
  keys[e.code] = true;
  e.preventDefault();  // prevent page scroll with arrow keys
});

document.addEventListener('keyup', e => {
  keys[e.code] = false;
});

// In the game loop, every frame:
function update(dt) {
  if (keys['ArrowLeft'])  player.x -= player.speed * dt;
  if (keys['ArrowRight']) player.x += player.speed * dt;
  if (keys['Space'])      player.shoot();
}

For mobile touch, the strategy is analogous: map touches to logical actions, not absolute coordinates. A simple approach is to register which screen zone was touched (left/right/center):

canvas.addEventListener('touchstart', e => {
  const touch = e.touches[0];
  const x = touch.clientX;
  const midX = canvas.width / 2;

  if (x < midX * 0.4)       keys['ArrowLeft']  = true;
  else if (x > midX * 1.6)  keys['ArrowRight'] = true;
  else                        keys['Space']      = true;
}, { passive: false });

canvas.addEventListener('touchend', () => {
  keys['ArrowLeft'] = keys['ArrowRight'] = keys['Space'] = false;
});

This approach unifies keyboard and touch in the same state, so the game loop doesn't need to distinguish between the two input methods.

Collision Detection: AABB, Circle and When to Use Them

Collision detection answers a simple question: are these two objects touching? The answer depends on the shape of the objects and the level of precision required. For 90% of retro 2D games, two techniques cover everything:

AABB - Axis-Aligned Bounding Box. Every object has a hitbox rectangle (x, y, width, height). Two rectangles overlap if and only if:

function collideAABB(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
  );
}

// Example: bullet hitting an enemy
if (collideAABB(bullet, enemy)) {
  enemy.destroy();
  bullet.active = false;
}

AABB is the fastest computationally and sufficient for most games. Space Invaders, Breakout, Tetris - all use AABB or a simplified variant.

Circle collision. For round objects (Pac-Man, asteroids, Pong balls), circular collision is more accurate: two circles overlap if the distance between their centers is less than the sum of their radii.

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

Note: Math.sqrt is computationally expensive. If you're checking many collisions per frame, you can optimize by comparing squared distances instead:

function collideCircleFast(a, b) {
  const dx = (a.x + a.radius) - (b.x + b.radius);
  const dy = (a.y + a.radius) - (b.y + b.radius);
  const sumR = a.radius + b.radius;
  return (dx * dx + dy * dy) < (sumR * sumR);  // no sqrt!
}

For more complex games, there is spatial partitioning (quadtree, spatial grid): instead of checking every object against all others (O(n²)), you divide the space into cells and only check objects in the same cell. Necessary when you have hundreds of enemies on screen - not for most retro games.

See These Concepts in Action

Space Invaders on PixelPrompt is built with exactly the techniques in this guide: game loop with requestAnimationFrame and delta time, key state pattern for input, AABB for bullet-alien collisions, pure Canvas 2D with no libraries. Open DevTools and read the code - it's all there.

Play Space Invaders →

From Zero to Game: Structure of a Real Project

Knowing the individual concepts isn't enough - you need to know how to put them together. A well-structured Canvas game has this basic architecture:

  • main.js (entry point) - creates the canvas, instantiates the game, starts the loop. Contains no gameplay logic.
  • Game class - manages global state: which screen is active (menu, game, game over), score, lives, current level. Contains the game loop and calls update() and render() on game objects.
  • Player class - manages position, velocity, animation and player state. Has update(dt) and draw(ctx) methods. Doesn't read input directly: receives key state as a parameter.
  • Enemy / Bullet / etc. - same pattern as Player. Each game entity is only responsible for itself. Collision detection is the Game class's responsibility (which has visibility over all objects).
  • InputManager - handles event listeners and exposes key state. Can manage keyboard and touch in the same object, so the rest of the game neither knows nor cares where input comes from.

The update(dt) / draw(ctx) pattern on every object is the heart of this architecture. The game loop becomes extremely simple:

update(dt) {
  this.player.update(dt, this.input.state);
  this.enemies.forEach(e => e.update(dt));
  this.bullets.forEach(b => b.update(dt));
  this.checkCollisions();
  this.cleanupInactive();
}

render() {
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
  this.player.draw(this.ctx);
  this.enemies.forEach(e => e.draw(this.ctx));
  this.bullets.forEach(b => b.draw(this.ctx));
  this.drawHUD();
}

Readable, predictable, extensible. When you want to add a power-up, you create a PowerUp class with the same update and draw methods, add it to the arrays, and the game loop handles it automatically.

One last practical piece of advice: build the simplest possible working version before adding any feature. A square moving with arrow keys that bounces off the walls is already a game. Every feature you add must have a precise purpose in the gameplay - don't add complexity because you can, but because it improves the experience.

The best retro games were excellent not because they had many features, but because the few features they had were perfectly balanced. Space Invaders has three mechanics: move, shoot, don't get hit. Everything else is a variation on those three themes. That simplicity isn't a limitation - it's game design at its best.

Enjoyed this HTML5 Canvas guide?

Every week we publish a new article spanning code, history and retro game design. From pixel art to 80s difficulty mechanics - subscribe so you don't miss the next one.

No spam. Quality bits only. Unsubscribe anytime.

Let's talk about it on Discord

Building a game with Canvas? Share your project and get tips from the community.

JOIN US