Building browser games from scratch is one of the most satisfying ways to learn JavaScript. Unlike typical web projects, games demand a tight feedback loop between user input, physics, rendering, and state management — all running at 60 frames per second. This article walks through how the classic arcade games on this site were built using the HTML5 Canvas API.
Why HTML5 Canvas?
The Canvas API gives you a 2D drawing surface directly in the browser. You draw pixels, shapes, text, and images imperatively, rather than manipulating the DOM. That makes it ideal for games because:
- You control exactly what gets drawn, and when
- No layout reflows or CSS side effects
- Performance scales well even with hundreds of moving objects
- The same code works on desktop and mobile (with touch event handling)
Every game on this site uses a single <canvas> element and a vanilla JavaScript game loop.
The Game Loop
All games share the same fundamental pattern: a loop that runs continuously, updating state and redrawing the canvas each frame.
function gameLoop(timestamp) {
const delta = timestamp - lastTime;
lastTime = timestamp;
update(delta); // move things
draw(); // render the frame
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
requestAnimationFrame ties the loop to the browser’s refresh rate (typically 60 fps) and pauses automatically when the tab is in the background, which saves battery life on mobile.
Snake: State Machines and Grid Coordinates
Snake is a great starting project because the game state is simple: an array of grid cells (the snake’s body), a food position, and a direction.
The key insight is working in grid coordinates rather than pixels. The snake occupies a 20×20 grid, and each cell maps to 20 pixels. On each tick, the head moves one cell in the current direction, a new cell is prepended to the body array, and the tail cell is removed — unless food was just eaten, in which case the tail stays.
function update() {
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
if (collidesWithWall(head) || collidesWithSelf(head)) {
gameOver();
return;
}
snake.unshift(head);
if (head.x === food.x && head.y === food.y) {
score++;
placeFood(); // don't remove tail — snake grows
} else {
snake.pop(); // remove tail
}
}
The trickiest part is preventing the player from reversing direction (going left while moving right would immediately cause a self-collision). The solution is to only accept a new direction if it isn’t the direct opposite of the current one.
Tetris: Piece Rotation and Collision
Tetris introduces more complexity: seven differently shaped pieces (tetrominoes), each of which can be rotated. Pieces are stored as 2D arrays of 0s and 1s.
const T_PIECE = [
[0, 1, 0],
[1, 1, 1],
[0, 0, 0]
];
Rotating a piece 90° clockwise is a standard matrix transpose-and-reverse operation:
function rotate(matrix) {
const n = matrix.length;
return matrix.map((row, i) =>
row.map((_, j) => matrix[n - 1 - j][i])
);
}
Before applying a rotation or movement, the game checks whether the resulting position would overlap with existing blocks or extend beyond the board boundaries. If it does, the move is rejected.
Clearing lines requires scanning for rows where every cell is filled, removing them, and shifting all rows above downward. This is where keeping the board as a 2D array pays off.
Pong and Breakout: Physics and Bouncing
Pong and Breakout both involve a ball bouncing off surfaces. The ball has a position (x, y) and a velocity (vx, vy). Each frame, the position is updated by the velocity:
ball.x += ball.vx * delta;
ball.y += ball.vy * delta;
Bouncing off a wall simply negates the relevant velocity component:
if (ball.y < 0 || ball.y + ball.radius > canvas.height) {
ball.vy *= -1;
}
Collision with a paddle or a brick is slightly more nuanced: you need to determine which face of the rectangle was hit to decide whether to negate vx or vy. The approach used here tests whether the ball’s center falls within the horizontal or vertical span of the brick before impact.
In Breakout, each brick has an additional property: whether it has been destroyed. Destroyed bricks are skipped during rendering and collision checks. When all bricks are gone, the level is complete.
Handling Mobile Touch Controls
A game that only works with a keyboard is inaccessible on mobile. Each game maps touch events to the appropriate input. For Snake, swipe direction determines the next move:
canvas.addEventListener('touchstart', e => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
});
canvas.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) > Math.abs(dy)) {
setDirection(dx > 0 ? 'right' : 'left');
} else {
setDirection(dy > 0 ? 'down' : 'up');
}
});
For Pong and Breakout, the paddle follows the touch X coordinate directly.
Persisting High Scores
LocalStorage lets you persist data between sessions without any server. Saving and loading a high score is a single line in both directions:
// Save
localStorage.setItem('snakeHighScore', score);
// Load
const highScore = parseInt(localStorage.getItem('snakeHighScore') || '0', 10);
The key is always providing a sensible default (here '0') so the game works correctly on first launch before any score has been set.
What I Learned
Rebuilding these classics from scratch reveals why they are remembered as great designs. Every mechanic is deceptively simple to describe but requires careful implementation:
- Snake’s grid movement makes collision trivially cheap
- Tetris’s piece system makes rotation and stacking elegant
- Pong’s paddle AI is one line: move toward the ball but cap the speed so the player can win
- Breakout’s layout of bricks is just a nested loop
If you want to learn JavaScript deeply, build a game. The immediate visual feedback makes debugging fast, and the constraints of real-time rendering force you to write efficient, well-structured code.
All the games on this site are open source on GitHub. Feel free to explore the source code, fork it, or suggest improvements.