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.
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
| Mistake | Why It Breaks | Fix |
|---|---|---|
Using setInterval for the loop | Doesn't sync to display refresh, causes tearing | Use requestAnimationFrame |
| Moving pixels per frame (not per ms) | Speed varies with framerate | Multiply by deltaTime |
Forward iteration with splice | Skips elements after removal | Iterate backwards |
| Not clamping player position | Ship flies off screen | Math.max/Math.min bounds |
Forgetting ctx.globalAlpha = 1 | Everything after particles becomes transparent | Reset alpha after drawing |
| Creating objects every frame | GC pressure, stuttering | Object 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 anactiveflag. 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
drawImageeach frame. - Minimize state changes: Batch draws by color/style. Changing
fillStyleevery 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
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.