Building a Browser Game Center with Pure HTML/JS/CSS — Canvas 2D API and Game Loop Patterns
The request was simple: "Build a snake game."
But once I started writing, "might as well add Tetris too" crept in, and it ended up as a single-page app where you can switch between two games. Zero external libraries. A single HTML file. About 500 lines. This article summarizes the implementation patterns I focused on during that process.
Foundation: IIFE Namespacing
To prevent variable name collisions between Snake and Tetris, I wrapped each game in an Immediately Invoked Function Expression (IIFE).
// Snake
(function(){
const cv = document.getElementById('snake-canvas');
const ctx = cv.getContext('2d');
const CELL=20, COLS=30, ROWS=25;
// ...all snake variables stay in here
})();
// Tetris
(function(){
const cv = document.getElementById('tetris-canvas');
const ctx = cv.getContext('2d');
// ...all tetris variables stay in here
})();
Prevents global scope pollution while keeping everything in a single file. Since single-file distribution was the goal, this was the right call.
Game Loop Design
Snake is a "tick at fixed intervals" game, so setInterval is the natural choice.
let loop = null;
function start() {
if (loop) clearInterval(loop);
loop = setInterval(tick, speed); // speed = 250ms, decreases as level rises
}
function tick() {
// 1. Apply buffered input
dir = nextDir;
// 2. Advance the snake
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
// 3. Collision detection
if (isWall(head) || onSnake(head)) { gameOver(); return; }
// 4. Check if food was eaten
if (head.x === food.x && head.y === food.y) {
score += 10; spawnFood();
} else {
snake.pop(); // Remove tail if not eating
}
snake.unshift(head); // Add new head
// 5. Draw
draw();
}
The key is separating input acceptance from input application. If you update dir immediately on keydown, pressing the opposite direction key twice within one tick causes a self-collision. Buffer it in nextDir and apply it at the start of each tick.
Tetris Uses requestAnimationFrame
Tetris needs hard drop (spacebar for instant placement), which requires checking state every frame. So I went with requestAnimationFrame.
let lastTime = 0;
let dropCounter = 0;
function gameLoop(time = 0) {
const delta = time - lastTime;
lastTime = time;
dropCounter += delta;
if (dropCounter > dropInterval) {
drop();
dropCounter = 0;
}
draw();
rafId = requestAnimationFrame(gameLoop);
}
Shortening dropInterval based on level increases speed. Using delta gives frame-rate-independent drop speed.
Mobile Support: Touch Swipes
Added swipe controls so mobile users without keyboards can play.
let touchStart = null;
canvas.addEventListener('touchstart', e => {
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchend', e => {
if (!touchStart) return;
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
if (Math.abs(dx) > Math.abs(dy)) {
// Horizontal swipe
setDir(dx > 0 ? {x:1,y:0} : {x:-1,y:0});
} else {
// Vertical swipe
setDir(dy > 0 ? {x:0,y:1} : {x:0,y:-1});
}
touchStart = null;
});
Add passive: false to cancel page scroll. Forgetting this results in the screen scrolling along with swipes — terrible UX.
Canvas Drawing Details
Drawing the Snake's Eyes
function drawEyes(head, dir) {
const cx = head.x * CELL + CELL/2;
const cy = head.y * CELL + CELL/2;
const eyeOffset = CELL * 0.25;
// Place two eyes perpendicular to direction of travel
const ex = dir.y !== 0 ? eyeOffset : 0;
const ey = dir.x !== 0 ? eyeOffset : 0;
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx - ex + dir.x*3, cy - ey + dir.y*3, 2.5, 0, Math.PI*2);
ctx.arc(cx + ex + dir.x*3, cy + ey + dir.y*3, 2.5, 0, Math.PI*2);
ctx.fill();
}
Body Opacity Gradient
snake.forEach((seg, i) => {
const alpha = 1 - (i / snake.length) * 0.6; // Head 1.0 → Tail 0.4
ctx.fillStyle = `rgba(74, 222, 128, ${alpha})`;
ctx.fillRect(seg.x*CELL+1, seg.y*CELL+1, CELL-2, CELL-2);
});
These details are what turn "something that moves" into "something you want to play."
Persisting High Scores
// On game over
if (score > best) {
best = score;
localStorage.setItem('snake-best', best);
}
localStorage persists on the same origin. Best scores survive reloads. No backend needed.
Reflections
Setting a goal of "working prototype in 30 minutes" makes you question whether deciding on a framework is worth the time. The Canvas 2D API is perfectly writable with vanilla JS, and once you learn the game loop patterns, they generalize well.
Since everything fits in a single HTML file, distribution is literally "open this file." No Vite, no webpack required.
Sometimes building without a bundler is exactly right.
Tags: #javascript #canvas #gamedev #html5 #frontend #webgame
Top comments (0)