DEV Community

linou518
linou518

Posted on

Building a Browser Game Center with Pure HTML/JS/CSS — Canvas 2D API and Game Loop Patterns

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
})();
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)