JavaScript Canvas Game Development Basics
The HTML5 Canvas API and vanilla JavaScript are everything you need to build a game that runs in any browser. No framework, no build system, no package manager. One HTML file, one JavaScript file, and a browser. That simplicity is why Canvas remains the best starting point for learning game development in 2026, even with more powerful tools available.
This tutorial builds a working game from the ground up. You will learn how the game loop works, how to draw sprites, how to handle keyboard input, how to detect collisions, and how to put it all together into a simple platformer. By the end, you will have a playable game and the knowledge to build anything else on Canvas.
Canvas API Basics
The Canvas element gives you a 2D drawing surface that you control pixel by pixel through JavaScript. Start with the HTML:
<!DOCTYPE html>
<html>
<head>
<title>My Game</title>
<style>
* { margin: 0; padding: 0; }
canvas {
display: block;
background: #1a1a2e;
}
</style>
</head>
<body>
<canvas id="game" width="800" height="600"></canvas>
<script src="game.js"></script>
</body>
</html>
In your JavaScript file, grab the canvas and its 2D rendering context:
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// Drawing primitives
ctx.fillStyle = '#e94560';
ctx.fillRect(100, 100, 50, 50); // Filled rectangle
ctx.strokeStyle = '#0f3460';
ctx.lineWidth = 2;
ctx.strokeRect(200, 100, 50, 50); // Outlined rectangle
ctx.beginPath();
ctx.arc(400, 125, 25, 0, Math.PI * 2); // Circle
ctx.fillStyle = '#16213e';
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '24px monospace';
ctx.fillText('Score: 0', 10, 30); // Text
These four operations, filled rectangles, stroked rectangles, arcs, and text, are enough to build complete games. More complex shapes use beginPath(), moveTo(), lineTo(), and closePath(). Image sprites use drawImage(). But for learning, colored rectangles are your best friend because they keep the focus on game logic rather than asset loading.
The Game Loop
A game loop is the heartbeat of any game. It runs continuously, updating game state and redrawing the screen every frame. In the browser, the correct way to build a game loop is requestAnimationFrame, which synchronizes your loop with the display's refresh rate (typically 60 fps).
let lastTime = 0;
function gameLoop(timestamp) {
// Calculate delta time in seconds
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// 1. Process input (handled by event listeners)
// 2. Update game state
update(dt);
// 3. Clear and redraw
ctx.clearRect(0, 0, canvas.width, canvas.height);
render();
// 4. Request next frame
requestAnimationFrame(gameLoop);
}
// Start the loop
requestAnimationFrame(gameLoop);
The critical detail is delta time (dt). The time between frames varies based on system load and refresh rate. If you move a character by a fixed number of pixels per frame, the game runs faster on a 144 Hz monitor than a 60 Hz one. By multiplying movement by dt (time elapsed since last frame), you make the game run at the same speed regardless of frame rate.
// Wrong: frame-rate dependent
player.x += 5;
// Right: frame-rate independent
player.x += 300 * dt; // 300 pixels per second, regardless of fps
This is the single most important concept in game loop design. Every velocity, every timer, every animation should be multiplied by delta time.
Sprite Rendering
A sprite is any visual element in your game. For prototyping, draw sprites as colored rectangles. For polished games, load image files.
Rectangle Sprites
const player = {
x: 100,
y: 400,
width: 32,
height: 48,
color: '#e94560',
velocityX: 0,
velocityY: 0
};
function renderPlayer() {
ctx.fillStyle = player.color;
ctx.fillRect(player.x, player.y, player.width, player.height);
}
Image Sprites
const spriteImage = new Image();
spriteImage.src = 'player.png';
// Wait for the image to load before starting the game
spriteImage.onload = function() {
requestAnimationFrame(gameLoop);
};
function renderPlayer() {
// Draw entire image
ctx.drawImage(spriteImage, player.x, player.y);
// Or draw a specific region (sprite sheet)
// ctx.drawImage(spriteImage,
// srcX, srcY, srcWidth, srcHeight, // Source rectangle
// player.x, player.y, 32, 48 // Destination rectangle
// );
}
For sprite sheet animation, cycle through frames by changing the source rectangle over time:
const animation = {
frameWidth: 32,
frameHeight: 48,
frameCount: 4,
currentFrame: 0,
frameTimer: 0,
frameInterval: 0.15 // seconds per frame
};
function updateAnimation(dt) {
animation.frameTimer += dt;
if (animation.frameTimer >= animation.frameInterval) {
animation.frameTimer = 0;
animation.currentFrame = (animation.currentFrame + 1) % animation.frameCount;
}
}
function renderAnimatedSprite() {
const srcX = animation.currentFrame * animation.frameWidth;
ctx.drawImage(spriteImage,
srcX, 0, animation.frameWidth, animation.frameHeight,
player.x, player.y, player.width, player.height
);
}
Keyboard Input
The standard pattern for game input is to track which keys are currently held down using a Set or object, then check that state in the update function. Do not move the player inside the event handler directly, because keydown events fire repeatedly at the OS key repeat rate, which is not tied to your game loop.
const keys = new Set();
document.addEventListener('keydown', (e) => {
keys.add(e.code);
e.preventDefault(); // Prevent page scrolling with arrow keys
});
document.addEventListener('keyup', (e) => {
keys.delete(e.code);
});
function update(dt) {
const speed = 300; // pixels per second
if (keys.has('ArrowLeft') || keys.has('KeyA')) {
player.velocityX = -speed;
} else if (keys.has('ArrowRight') || keys.has('KeyD')) {
player.velocityX = speed;
} else {
player.velocityX = 0;
}
// Apply velocity
player.x += player.velocityX * dt;
player.y += player.velocityY * dt;
}
Using e.code instead of e.key is important. e.code refers to the physical key position on the keyboard, so WASD works correctly regardless of keyboard layout (AZERTY, QWERTZ, etc.).
Collision Detection
For rectangle-based games, Axis-Aligned Bounding Box (AABB) collision detection is all you need. Two rectangles overlap if and only if they overlap on both axes:
function rectsOverlap(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
);
}
This function returns true if rectangles a and b overlap at all. It works for player-enemy collisions, projectile hits, item pickups, and wall collisions. For a game with fewer than a few hundred entities, checking every pair every frame is perfectly fast. Optimization (spatial hashing, quad trees) only matters when you have thousands of collidable objects.
Platform Collision (One-Directional)
In a platformer, you need to know which side the collision happened on. The standard approach is to resolve horizontal and vertical movement separately:
function updatePlayer(dt) {
// Horizontal movement
player.x += player.velocityX * dt;
for (const platform of platforms) {
if (rectsOverlap(player, platform)) {
if (player.velocityX > 0) {
player.x = platform.x - player.width; // Push left
} else if (player.velocityX < 0) {
player.x = platform.x + platform.width; // Push right
}
player.velocityX = 0;
}
}
// Vertical movement
player.velocityY += gravity * dt;
player.y += player.velocityY * dt;
player.onGround = false;
for (const platform of platforms) {
if (rectsOverlap(player, platform)) {
if (player.velocityY > 0) {
player.y = platform.y - player.height; // Land on top
player.velocityY = 0;
player.onGround = true;
} else if (player.velocityY < 0) {
player.y = platform.y + platform.height; // Hit ceiling
player.velocityY = 0;
}
}
}
}
By splitting movement into two passes, each axis is resolved independently. This prevents corner cases where diagonal movement phases through walls.
Building a Simple Platformer
Let us combine everything into a working platformer. The player can move left and right, jump, and land on platforms.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const keys = new Set();
const gravity = 1200;
const jumpForce = -500;
const moveSpeed = 300;
const player = {
x: 100, y: 100,
width: 28, height: 40,
velocityX: 0, velocityY: 0,
onGround: false,
color: '#e94560'
};
const platforms = [
{ x: 0, y: 550, width: 800, height: 50, color: '#16213e' },
{ x: 200, y: 430, width: 150, height: 20, color: '#0f3460' },
{ x: 450, y: 330, width: 150, height: 20, color: '#0f3460' },
{ x: 150, y: 230, width: 150, height: 20, color: '#0f3460' },
{ x: 500, y: 150, width: 200, height: 20, color: '#0f3460' }
];
const coins = [
{ x: 260, y: 400, size: 12, collected: false },
{ x: 510, y: 300, size: 12, collected: false },
{ x: 210, y: 200, size: 12, collected: false },
{ x: 580, y: 120, size: 12, collected: false }
];
let score = 0;
document.addEventListener('keydown', (e) => { keys.add(e.code); e.preventDefault(); });
document.addEventListener('keyup', (e) => { keys.delete(e.code); });
function update(dt) {
// Horizontal input
if (keys.has('ArrowLeft') || keys.has('KeyA')) {
player.velocityX = -moveSpeed;
} else if (keys.has('ArrowRight') || keys.has('KeyD')) {
player.velocityX = moveSpeed;
} else {
player.velocityX = 0;
}
// Jump
if ((keys.has('ArrowUp') || keys.has('KeyW') || keys.has('Space'))
&& player.onGround) {
player.velocityY = jumpForce;
player.onGround = false;
}
// Gravity
player.velocityY += gravity * dt;
// Move horizontal, resolve collisions
player.x += player.velocityX * dt;
for (const p of platforms) {
if (rectsOverlap(player, p)) {
if (player.velocityX > 0) player.x = p.x - player.width;
else if (player.velocityX < 0) player.x = p.x + p.width;
player.velocityX = 0;
}
}
// Move vertical, resolve collisions
player.y += player.velocityY * dt;
player.onGround = false;
for (const p of platforms) {
if (rectsOverlap(player, p)) {
if (player.velocityY > 0) {
player.y = p.y - player.height;
player.onGround = true;
} else {
player.y = p.y + p.height;
}
player.velocityY = 0;
}
}
// Coin collection
for (const coin of coins) {
if (coin.collected) continue;
const coinRect = {
x: coin.x - coin.size,
y: coin.y - coin.size,
width: coin.size * 2,
height: coin.size * 2
};
if (rectsOverlap(player, coinRect)) {
coin.collected = true;
score += 100;
}
}
// Screen bounds
if (player.x < 0) player.x = 0;
if (player.x + player.width > canvas.width) {
player.x = canvas.width - player.width;
}
}
function rectsOverlap(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 render() {
// Platforms
for (const p of platforms) {
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, p.width, p.height);
}
// Coins
for (const coin of coins) {
if (coin.collected) continue;
ctx.beginPath();
ctx.arc(coin.x, coin.y, coin.size, 0, Math.PI * 2);
ctx.fillStyle = '#ffd700';
ctx.fill();
}
// Player
ctx.fillStyle = player.color;
ctx.fillRect(player.x, player.y, player.width, player.height);
// Score
ctx.fillStyle = '#fff';
ctx.font = '20px monospace';
ctx.fillText('Score: ' + score, 16, 32);
}
let lastTime = 0;
function gameLoop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
update(dt);
ctx.clearRect(0, 0, canvas.width, canvas.height);
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
Notice the Math.min(dt, 0.05) cap on delta time. If the browser tab loses focus and regains it, the timestamp jump could be several seconds, causing the player to teleport through walls. Capping delta time at 50ms (20 fps minimum) prevents physics explosions from large time steps.
This pattern, a player controlled by keyboard, affected by gravity, colliding with platforms, collecting items, is the foundation of countless games. SpaceCraft AI uses this same Canvas approach for a space-themed game with AI opponents, demonstrating how far you can take vanilla Canvas and JavaScript without any framework.
Adding Polish
Screen Shake
let shakeTimer = 0;
let shakeIntensity = 0;
function triggerShake(intensity, duration) {
shakeIntensity = intensity;
shakeTimer = duration;
}
function render() {
ctx.save();
if (shakeTimer > 0) {
const offsetX = (Math.random() - 0.5) * shakeIntensity;
const offsetY = (Math.random() - 0.5) * shakeIntensity;
ctx.translate(offsetX, offsetY);
}
// ... draw everything ...
ctx.restore();
}
// In update:
if (shakeTimer > 0) shakeTimer -= dt;
Particles
const particles = [];
function spawnParticles(x, y, count, color) {
for (let i = 0; i < count; i++) {
particles.push({
x, y,
vx: (Math.random() - 0.5) * 200,
vy: (Math.random() - 0.5) * 200,
life: 0.5 + Math.random() * 0.5,
size: 2 + Math.random() * 3,
color
});
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) particles.splice(i, 1);
}
}
function renderParticles() {
for (const p of particles) {
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, p.size, p.size);
}
ctx.globalAlpha = 1;
}
Spawn particles when the player lands, collects a coin, or takes damage. Small visual flourishes like these make the game feel responsive and alive with minimal code.
Deploying on GitHub Pages
One of the best things about Canvas games is deployment simplicity. Your game is static files: HTML, JavaScript, and optionally images and audio. Push to a GitHub repository, enable GitHub Pages in settings, and your game is live at username.github.io/repo-name.
git init
git add index.html game.js
git commit -m "Initial game release"
git remote add origin git@github.com:username/my-game.git
git push -u origin main
# Enable GitHub Pages in repository settings
No build step, no server, no configuration. Share the URL and anyone with a browser can play your game instantly. This zero-friction distribution is the fundamental advantage of browser games over native applications.
Where to Go from Here
With the fundamentals covered in this tutorial, you have several natural directions to explore:
- Procedural content: Instead of hand-placing platforms, generate them algorithmically. Our procedural generation guide covers the algorithms you would use for random level generation, from simple noise-based terrain to full dungeon generators.
- State management: Add a title screen, pause menu, game over screen, and high score table using a state machine pattern. Each state has its own update and render function, and the game loop delegates to the active state.
- Audio: The Web Audio API handles sound effects and music. Load audio files with
new Audio('sound.mp3')for simple effects, or use the AudioContext API for precise timing and effects processing. - Mobile support: Add touch controls with
touchstart,touchmove, andtouchendevents. Render virtual buttons on the canvas. Resize the canvas to fill the screen withwindow.innerWidthandwindow.innerHeight. - WebGL shaders: For advanced visual effects that the 2D Canvas API cannot handle, transition to WebGL rendering. Our browser game building guide covers framework options including Three.js for 3D.
The platformer code in this tutorial is under 150 lines. A complete game with multiple levels, enemies, animations, and a scoring system might be 500 to 1,000 lines. That is remarkably compact for a fully functional game, and it is one of the reasons vanilla Canvas remains a compelling choice. No dependencies to manage, no build system to configure, no framework updates to track. Just JavaScript and pixels.