JavaScript Canvas Game Development Basics

Baguette Tools · February 2026 · 14 min read
JavaScript Canvas Game Dev Tutorial

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:

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.

Related Articles