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 rectanglectx.strokeRect(x, y, w, h)- rectangle outline onlyctx.clearRect(x, y, w, h)- clear an area (used every frame)ctx.fillText(text, x, y)- textctx.drawImage(img, x, y, w, h)- image or sprite sheetctx.arc(x, y, radius, 0, Math.PI * 2)- circle (thenfill()orstroke())
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()andrender()on game objects. -
Player class - manages position, velocity, animation and player
state. Has
update(dt)anddraw(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.