March 26, 202612 min read

Build a Browser Game with JavaScript and Canvas

Step-by-step tutorial building a space shooter game using HTML5 Canvas and vanilla JavaScript. Covers game loop, sprite rendering, collision detection, keyboard input, and score tracking.

javascript game canvas tutorial beginner
Ad 336x280

Most "JavaScript game tutorials" give you 40 lines of code that draw a square on screen and call it a day. That's not a game. A game has a loop, state management, collision detection, input handling, and some notion of winning or losing. All of those concepts are fundamental programming patterns that happen to be way more fun to learn when pixels are moving on screen.

We're building a space shooter -- a ship at the bottom, enemies spawning at the top, bullets flying, explosions happening. By the end, you'll have a playable game under 400 lines of vanilla JavaScript. No frameworks, no build tools, no npm install. Just a browser and a text editor.

Project Setup

One HTML file. That's it.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Space Shooter</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
    canvas { border: 1px solid #333; }
  </style>
</head>
<body>
  <canvas id="game" width="480" height="640"></canvas>
  <script src="game.js"></script>
</body>
</html>

And one game.js file where everything lives.

The Game Loop

This is the heartbeat of every game. The loop runs roughly 60 times per second, and each iteration does three things: update state, clear the screen, draw everything.

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");

const GAME_WIDTH = 480;
const GAME_HEIGHT = 640;

let lastTime = 0;

function gameLoop(timestamp) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;

update(deltaTime);
render();

requestAnimationFrame(gameLoop);
}

function update(dt) {
// Game logic goes here
}

function render() {
ctx.fillStyle = "#0a0a1a";
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
// Drawing goes here
}

requestAnimationFrame(gameLoop);

Why requestAnimationFrame instead of setInterval? Two reasons: it syncs to the monitor's refresh rate (no screen tearing), and it automatically pauses when the tab is hidden (no wasted CPU).

The deltaTime parameter matters because not every frame takes exactly 16.67ms. If you move your ship 5 pixels per frame without accounting for delta time, the game runs faster on a 144Hz monitor than a 60Hz one. We'll use delta time to keep movement consistent.

Input Handling

Games need responsive input. The naive approach -- checking keydown events and acting immediately -- creates stuttery movement. Instead, track which keys are currently held down:

const keys = {};

window.addEventListener("keydown", (e) => {
keys[e.code] = true;
e.preventDefault();
});

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

function isKeyDown(code) {
return keys[code] === true;
}

Now we can check isKeyDown("ArrowLeft") every frame for smooth, continuous movement. The preventDefault() stops arrow keys from scrolling the page.

The Player Ship

const player = {
  x: GAME_WIDTH / 2,
  y: GAME_HEIGHT - 60,
  width: 40,
  height: 40,
  speed: 0.35, // pixels per millisecond
  color: "#4fc3f7",
  lives: 3,
  score: 0,
  shootCooldown: 0,
  shootDelay: 200, // ms between shots
};

function updatePlayer(dt) {
if (isKeyDown("ArrowLeft") || isKeyDown("KeyA")) {
player.x -= player.speed * dt;
}
if (isKeyDown("ArrowRight") || isKeyDown("KeyD")) {
player.x += player.speed * dt;
}

// Clamp to screen bounds
player.x = Math.max(player.width / 2, Math.min(GAME_WIDTH - player.width / 2, player.x));

// Shooting
player.shootCooldown -= dt;
if (isKeyDown("Space") && player.shootCooldown <= 0) {
spawnBullet(player.x, player.y - player.height / 2);
player.shootCooldown = player.shootDelay;
}
}

function drawPlayer() {
// Draw a simple triangle ship
ctx.fillStyle = player.color;
ctx.beginPath();
ctx.moveTo(player.x, player.y - player.height / 2);
ctx.lineTo(player.x - player.width / 2, player.y + player.height / 2);
ctx.lineTo(player.x + player.width / 2, player.y + player.height / 2);
ctx.closePath();
ctx.fill();

// Engine glow
ctx.fillStyle = "#ff6f00";
ctx.beginPath();
ctx.moveTo(player.x - 8, player.y + player.height / 2);
ctx.lineTo(player.x, player.y + player.height / 2 + 12 + Math.random() * 6);
ctx.lineTo(player.x + 8, player.y + player.height / 2);
ctx.closePath();
ctx.fill();
}

The Math.random() on the engine flame gives it a flickering effect -- a tiny detail that makes the game feel alive.

Bullets

Bullets are stored in a simple array. Each frame, we move them upward and remove any that leave the screen.

const bullets = [];

function spawnBullet(x, y) {
bullets.push({
x,
y,
width: 4,
height: 12,
speed: 0.6,
color: "#ffeb3b",
});
}

function updateBullets(dt) {
for (let i = bullets.length - 1; i >= 0; i--) {
bullets[i].y -= bullets[i].speed * dt;
if (bullets[i].y + bullets[i].height < 0) {
bullets.splice(i, 1);
}
}
}

function drawBullets() {
bullets.forEach((b) => {
ctx.fillStyle = b.color;
ctx.fillRect(b.x - b.width / 2, b.y - b.height / 2, b.width, b.height);

// Glow effect
ctx.shadowColor = b.color;
ctx.shadowBlur = 8;
ctx.fillRect(b.x - b.width / 2, b.y - b.height / 2, b.width, b.height);
ctx.shadowBlur = 0;
});
}

Notice we iterate backwards when removing elements. Forward iteration with splice skips elements because indices shift after removal. This is a classic bug in game development.

Enemies

Enemies spawn at random x positions along the top and drift downward. We'll use a spawn timer so they appear at regular intervals.

const enemies = [];
let enemySpawnTimer = 0;
let enemySpawnInterval = 1200; // ms
let difficultyTimer = 0;

function spawnEnemy() {
const size = 30 + Math.random() * 20;
enemies.push({
x: size + Math.random() (GAME_WIDTH - size 2),
y: -size,
width: size,
height: size,
speed: 0.08 + Math.random() * 0.12,
health: size > 40 ? 2 : 1,
color: size > 40 ? "#e53935" : "#ff7043",
});
}

function updateEnemies(dt) {
// Spawn new enemies
enemySpawnTimer -= dt;
if (enemySpawnTimer <= 0) {
spawnEnemy();
enemySpawnTimer = enemySpawnInterval;
}

// Increase difficulty over time
difficultyTimer += dt;
if (difficultyTimer > 10000) {
enemySpawnInterval = Math.max(300, enemySpawnInterval - 100);
difficultyTimer = 0;
}

// Move enemies
for (let i = enemies.length - 1; i >= 0; i--) {
enemies[i].y += enemies[i].speed * dt;

// Remove if off screen (player missed it)
if (enemies[i].y > GAME_HEIGHT + enemies[i].height) {
enemies.splice(i, 1);
}
}
}

function drawEnemies() {
enemies.forEach((e) => {
ctx.fillStyle = e.color;
ctx.fillRect(e.x - e.width / 2, e.y - e.height / 2, e.width, e.height);

// Draw an X pattern on the enemy
ctx.strokeStyle = "#000";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(e.x - e.width / 4, e.y - e.height / 4);
ctx.lineTo(e.x + e.width / 4, e.y + e.height / 4);
ctx.moveTo(e.x + e.width / 4, e.y - e.height / 4);
ctx.lineTo(e.x - e.width / 4, e.y + e.height / 4);
ctx.stroke();
});
}

The difficulty ramp is subtle but important. The spawn interval decreases every 10 seconds, capping at 300ms. This keeps the game challenging without becoming impossible instantly.

Collision Detection

AABB (Axis-Aligned Bounding Box) collision is the simplest approach and works perfectly for rectangular shapes:

function checkCollision(a, b) {
  return (
    a.x - a.width / 2 < b.x + b.width / 2 &&
    a.x + a.width / 2 > b.x - b.width / 2 &&
    a.y - a.height / 2 < b.y + b.height / 2 &&
    a.y + a.height / 2 > b.y - b.height / 2
  );
}

Four conditions, all must be true. If any one fails, the rectangles don't overlap. This runs in O(1) per pair, which is fine for hundreds of objects. You'd only need spatial partitioning (quadtrees, grid hashing) if you had thousands.

Now wire up collision checks:

function handleCollisions() {
  // Bullets hitting enemies
  for (let i = bullets.length - 1; i >= 0; i--) {
    for (let j = enemies.length - 1; j >= 0; j--) {
      if (checkCollision(bullets[i], enemies[j])) {
        enemies[j].health--;
        bullets.splice(i, 1);

if (enemies[j].health <= 0) {
spawnExplosion(enemies[j].x, enemies[j].y);
player.score += enemies[j].width > 40 ? 200 : 100;
enemies.splice(j, 1);
}
break; // Bullet is gone, stop checking
}
}
}

// Enemies hitting player
for (let i = enemies.length - 1; i >= 0; i--) {
if (checkCollision(enemies[i], player)) {
spawnExplosion(enemies[i].x, enemies[i].y);
enemies.splice(i, 1);
player.lives--;
if (player.lives <= 0) {
gameOver = true;
}
}
}
}

Particle Explosions

Explosions are just a burst of small particles that fade out. This is a common pattern across all 2D games.

const particles = [];

function spawnExplosion(x, y) {
const count = 15 + Math.floor(Math.random() * 10);
for (let i = 0; i < count; i++) {
const angle = Math.random() Math.PI 2;
const speed = 0.1 + Math.random() * 0.3;
particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 400 + Math.random() * 300,
maxLife: 400 + Math.random() * 300,
size: 2 + Math.random() * 4,
color: ["#ff6f00", "#ffeb3b", "#ff3d00", "#fff"][Math.floor(Math.random() * 4)],
});
}
}

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 drawParticles() {
particles.forEach((p) => {
const alpha = p.life / p.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
});
ctx.globalAlpha = 1;
}

The alpha fade tied to remaining life is what makes explosions look natural. Full opacity at spawn, transparent at death.

HUD and Game State

let gameOver = false;
let gameStarted = false;

function drawHUD() {
// Score
ctx.fillStyle = "#fff";
ctx.font = "18px monospace";
ctx.textAlign = "left";
ctx.fillText(Score: ${player.score}, 10, 30);

// Lives
ctx.textAlign = "right";
ctx.fillText(Lives: ${"♥".repeat(player.lives)}, GAME_WIDTH - 10, 30);

// Wave info
ctx.textAlign = "center";
ctx.font = "12px monospace";
ctx.fillStyle = "#666";
ctx.fillText(Spawn rate: ${enemySpawnInterval}ms, GAME_WIDTH / 2, 20);
}

function drawStartScreen() {
ctx.fillStyle = "#0a0a1a";
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);

ctx.fillStyle = "#4fc3f7";
ctx.font = "bold 36px monospace";
ctx.textAlign = "center";
ctx.fillText("SPACE SHOOTER", GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40);

ctx.fillStyle = "#aaa";
ctx.font = "16px monospace";
ctx.fillText("Arrow keys / WASD to move", GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20);
ctx.fillText("Space to shoot", GAME_WIDTH / 2, GAME_HEIGHT / 2 + 50);
ctx.fillText("Press ENTER to start", GAME_WIDTH / 2, GAME_HEIGHT / 2 + 100);
}

function drawGameOverScreen() {
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);

ctx.fillStyle = "#e53935";
ctx.font = "bold 36px monospace";
ctx.textAlign = "center";
ctx.fillText("GAME OVER", GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40);

ctx.fillStyle = "#fff";
ctx.font = "20px monospace";
ctx.fillText(Final Score: ${player.score}, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 10);

ctx.fillStyle = "#aaa";
ctx.font = "16px monospace";
ctx.fillText("Press ENTER to restart", GAME_WIDTH / 2, GAME_HEIGHT / 2 + 60);
}

Starfield Background

A scrolling starfield sells the illusion of movement. Three layers at different speeds create a parallax effect:

const stars = [];

function initStars() {
for (let i = 0; i < 100; i++) {
stars.push({
x: Math.random() * GAME_WIDTH,
y: Math.random() * GAME_HEIGHT,
size: Math.random() * 2 + 0.5,
speed: 0.02 + Math.random() * 0.06,
});
}
}

function updateStars(dt) {
stars.forEach((s) => {
s.y += s.speed * dt;
if (s.y > GAME_HEIGHT) {
s.y = 0;
s.x = Math.random() * GAME_WIDTH;
}
});
}

function drawStars() {
stars.forEach((s) => {
ctx.fillStyle = rgba(255, 255, 255, ${s.size / 2.5});
ctx.fillRect(s.x, s.y, s.size, s.size);
});
}

initStars();

Wiring It All Together

Now connect everything into the main update and render functions:

function resetGame() {
  player.x = GAME_WIDTH / 2;
  player.y = GAME_HEIGHT - 60;
  player.lives = 3;
  player.score = 0;
  player.shootCooldown = 0;
  enemies.length = 0;
  bullets.length = 0;
  particles.length = 0;
  enemySpawnTimer = 0;
  enemySpawnInterval = 1200;
  difficultyTimer = 0;
  gameOver = false;
  gameStarted = true;
}

window.addEventListener("keydown", (e) => {
if (e.code === "Enter") {
if (!gameStarted || gameOver) {
resetGame();
}
}
});

function update(dt) {
if (!gameStarted || gameOver) return;

updateStars(dt);
updatePlayer(dt);
updateBullets(dt);
updateEnemies(dt);
updateParticles(dt);
handleCollisions();
}

function render() {
ctx.fillStyle = "#0a0a1a";
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);

drawStars();

if (!gameStarted) {
drawStartScreen();
return;
}

drawBullets();
drawEnemies();
drawPlayer();
drawParticles();
drawHUD();

if (gameOver) {
drawGameOverScreen();
}
}

Common Pitfalls

MistakeWhy It BreaksFix
Using setInterval for the loopDoesn't sync to display refresh, causes tearingUse requestAnimationFrame
Moving pixels per frame (not per ms)Speed varies with framerateMultiply by deltaTime
Forward iteration with spliceSkips elements after removalIterate backwards
Not clamping player positionShip flies off screenMath.max/Math.min bounds
Forgetting ctx.globalAlpha = 1Everything after particles becomes transparentReset alpha after drawing
Creating objects every frameGC pressure, stutteringObject pool or reuse arrays

Performance Considerations

For a game this size, performance isn't an issue. But once you scale to hundreds of enemies or complex particle systems, keep these in mind:

  • Object pooling: Instead of push/splice, pre-allocate arrays and toggle an active flag. Avoids garbage collection pauses.
  • Spatial partitioning: If collision checks become O(n^2), use a grid. Divide the screen into cells, only check entities in the same or neighboring cells.
  • OffscreenCanvas: For complex static backgrounds, render once to an offscreen canvas, then drawImage each frame.
  • Minimize state changes: Batch draws by color/style. Changing fillStyle every draw call is slower than drawing all red things, then all blue things.

Where to Go From Here

The full game above runs at about 350 lines. From here, you could add:

  • Power-ups that drop from destroyed enemies (spread shot, shield, speed boost)
  • Enemy types with different movement patterns (zigzag, homing, formations)
  • Boss fights every N points with health bars
  • Sound effects using the Web Audio API
  • High score persistence with localStorage
  • Mobile support with touch controls
Each of these features uses the same patterns: an array of objects, update logic per frame, render logic, and collision checks. The CodeUp tutorials section on codeup.dev covers more advanced game patterns if you want to push further into WebGL or game physics.

The whole point of building a game like this isn't the game itself. It's internalizing the game loop pattern, understanding frame-based animation, and getting comfortable with spatial math. Those skills transfer directly to data visualization, interactive UI components, and animation libraries.

Ad 728x90