Back to Frontend
Evergreen··15 min read

Build Snake from Scratch with Vanilla JS

A complete guide to building the classic Snake game using only HTML, CSS, and vanilla JavaScript. Covers game loops, collision detection, growing mechanics, and responsive canvas rendering. Interview-ready implementation.

javascriptvanilla-jsinterviewsgamecanvashtmlcss
Share

Build Snake from Scratch with Vanilla JS

Snake is a classic interview question that tests fundamentally different skills than static UI components. You're building a real-time game loop — something that runs continuously, processes input asynchronously, and updates state at fixed intervals. It requires understanding of timing, collision detection, and coordinate-based rendering.

By the end, you'll have:

  • Smooth game looprequestAnimationFrame with fixed timestep updates
  • Snake movement — grows when eating food, dies on collision
  • Collision detection — walls and self-collision
  • Score tracking — with high score persistence via localStorage
  • Pause/resume — spacebar to toggle
  • Responsive canvas — adapts to container size
  • Keyboard and touch controls — works on mobile

Why Interviewers Ask This

Snake tests concepts that Tic-Tac-Toe and Connect Four don't:

  1. Game loop architecture — continuous updates vs. event-driven interaction
  2. Queue/deque data structure — the snake body is a queue (add to front, remove from back)
  3. Collision detection — boundary checking and self-intersection
  4. Input buffering — handling rapid key presses without corrupting direction state
  5. Timing control — decoupling render rate from game speed

Step 1: HTML Structure

Snake uses <canvas> instead of DOM elements. A grid of 20×20 cells rendered via canvas is far more performant than managing hundreds of DOM nodes that update every frame.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Snake</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="game">
    <h1>Snake</h1>
 
    <div class="scoreboard">
      <span class="score">Score: <strong id="score">0</strong></span>
      <span class="high-score">Best: <strong id="high-score">0</strong></span>
    </div>
 
    <div class="canvas-container">
      <canvas id="canvas" width="400" height="400"></canvas>
      <div class="overlay" id="overlay">
        <div class="overlay-text">
          <p id="overlay-message">Press any arrow key to start</p>
          <button class="btn" id="start-btn">Start Game</button>
        </div>
      </div>
    </div>
 
    <div class="controls">
      <button class="btn" id="pause-btn">Pause</button>
      <div class="speed-control">
        <label for="speed">Speed:</label>
        <input type="range" id="speed" min="3" max="20" value="8" />
      </div>
    </div>
 
    <div class="touch-controls" id="touch-controls">
      <button class="touch-btn" data-dir="up" aria-label="Move up">↑</button>
      <div class="touch-row">
        <button class="touch-btn" data-dir="left" aria-label="Move left">←</button>
        <button class="touch-btn" data-dir="down" aria-label="Move down">↓</button>
        <button class="touch-btn" data-dir="right" aria-label="Move right">→</button>
      </div>
    </div>
  </div>
 
  <script src="script.js"></script>
</body>
</html>

Why Canvas? For Tic-Tac-Toe and Connect Four, DOM elements make sense — the board is static and interactions are click-based. But Snake updates every ~100ms with potentially dozens of cells changing. Canvas gives us direct pixel control with a single draw call per frame instead of updating individual DOM nodes.


Step 2: CSS Styling

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
 
body {
  font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  background: #1a1a2e;
  color: #e0e0e0;
}
 
.game {
  text-align: center;
}
 
h1 {
  font-size: 2rem;
  margin-bottom: 0.5rem;
  color: #4ecca3;
}
 
.scoreboard {
  display: flex;
  justify-content: center;
  gap: 2rem;
  margin-bottom: 1rem;
  font-size: 1.1rem;
}
 
.scoreboard strong {
  color: #4ecca3;
}
 
.canvas-container {
  position: relative;
  display: inline-block;
  border-radius: 8px;
  overflow: hidden;
}
 
canvas {
  display: block;
  background: #0f3460;
  border-radius: 8px;
}
 
.overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(15, 52, 96, 0.9);
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  transition: opacity 0.2s ease;
}
 
.overlay.hidden {
  opacity: 0;
  pointer-events: none;
}
 
.overlay-text {
  text-align: center;
}
 
.overlay-text p {
  font-size: 1.25rem;
  margin-bottom: 1rem;
}
 
.controls {
  margin-top: 1rem;
  display: flex;
  gap: 1rem;
  justify-content: center;
  align-items: center;
}
 
.speed-control {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
 
.speed-control input {
  accent-color: #4ecca3;
}
 
.btn {
  padding: 0.5rem 1.25rem;
  font-size: 1rem;
  border: 2px solid #4ecca3;
  background: transparent;
  color: #4ecca3;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s ease;
}
 
.btn:hover {
  background: #4ecca3;
  color: #1a1a2e;
}
 
/* --- Touch Controls --- */
 
.touch-controls {
  margin-top: 1.5rem;
  display: none; /* shown on touch devices via JS */
  flex-direction: column;
  align-items: center;
  gap: 0.25rem;
}
 
.touch-controls.visible {
  display: flex;
}
 
.touch-row {
  display: flex;
  gap: 0.25rem;
}
 
.touch-btn {
  width: 56px;
  height: 56px;
  font-size: 1.5rem;
  background: #16213e;
  border: 2px solid #4ecca3;
  color: #4ecca3;
  border-radius: 8px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
 
.touch-btn:active {
  background: #4ecca3;
  color: #1a1a2e;
}

Step 3: JavaScript — Full Implementation

(function () {
  'use strict';
 
  // --- Constants ---
  const GRID_SIZE = 20; // 20x20 grid
  const CANVAS_SIZE = 400;
  const CELL_SIZE = CANVAS_SIZE / GRID_SIZE; // 20px per cell
 
  // Directions as [dx, dy] where x is column, y is row
  const DIR = {
    up: { x: 0, y: -1 },
    down: { x: 0, y: 1 },
    left: { x: -1, y: 0 },
    right: { x: 1, y: 0 },
  };
 
  const OPPOSITE = { up: 'down', down: 'up', left: 'right', right: 'left' };
 
  // --- State ---
  let snake;          // Array of {x, y} — head is index 0
  let direction;      // Current direction name ('up', 'down', etc.)
  let nextDirection;  // Buffered next direction (prevents 180° turns)
  let food;           // {x, y}
  let score;
  let highScore;
  let gameState;      // 'waiting' | 'playing' | 'paused' | 'gameover'
  let speed;          // moves per second
  let lastMoveTime;
  let animFrameId;
 
  // --- DOM References ---
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d');
  const scoreEl = document.getElementById('score');
  const highScoreEl = document.getElementById('high-score');
  const overlay = document.getElementById('overlay');
  const overlayMsg = document.getElementById('overlay-message');
  const startBtn = document.getElementById('start-btn');
  const pauseBtn = document.getElementById('pause-btn');
  const speedSlider = document.getElementById('speed');
  const touchControls = document.getElementById('touch-controls');
 
  // --- Initialization ---
 
  function init() {
    // Snake starts in the center, 3 segments long, moving right
    const centerX = Math.floor(GRID_SIZE / 2);
    const centerY = Math.floor(GRID_SIZE / 2);
    snake = [
      { x: centerX, y: centerY },
      { x: centerX - 1, y: centerY },
      { x: centerX - 2, y: centerY },
    ];
 
    direction = 'right';
    nextDirection = 'right';
    score = 0;
    highScore = Number(localStorage.getItem('snake-high-score')) || 0;
    speed = Number(speedSlider.value);
    gameState = 'waiting';
    lastMoveTime = 0;
 
    scoreEl.textContent = score;
    highScoreEl.textContent = highScore;
    overlayMsg.textContent = 'Press any arrow key to start';
    startBtn.textContent = 'Start Game';
    overlay.classList.remove('hidden');
 
    spawnFood();
    draw();
 
    // Show touch controls on touch devices
    if ('ontouchstart' in window) {
      touchControls.classList.add('visible');
    }
  }
 
  // --- Food Spawning ---
 
  function spawnFood() {
    const occupied = new Set(snake.map((s) => `${s.x},${s.y}`));
 
    let pos;
    do {
      pos = {
        x: Math.floor(Math.random() * GRID_SIZE),
        y: Math.floor(Math.random() * GRID_SIZE),
      };
    } while (occupied.has(`${pos.x},${pos.y}`));
 
    food = pos;
  }
 
  // --- Game Loop ---
 
  function gameLoop(timestamp) {
    if (gameState !== 'playing') return;
 
    animFrameId = requestAnimationFrame(gameLoop);
 
    const moveInterval = 1000 / speed;
    if (timestamp - lastMoveTime < moveInterval) return;
    lastMoveTime = timestamp;
 
    update();
    draw();
  }
 
  function update() {
    // Apply buffered direction
    direction = nextDirection;
 
    // Calculate new head position
    const head = snake[0];
    const dir = DIR[direction];
    const newHead = {
      x: head.x + dir.x,
      y: head.y + dir.y,
    };
 
    // Check wall collision
    if (
      newHead.x < 0 ||
      newHead.x >= GRID_SIZE ||
      newHead.y < 0 ||
      newHead.y >= GRID_SIZE
    ) {
      gameOver();
      return;
    }
 
    // Check self collision (skip the tail — it will move out of the way)
    for (let i = 0; i < snake.length - 1; i++) {
      if (snake[i].x === newHead.x && snake[i].y === newHead.y) {
        gameOver();
        return;
      }
    }
 
    // Move snake: add new head
    snake.unshift(newHead);
 
    // Check food collision
    if (newHead.x === food.x && newHead.y === food.y) {
      // Grow: don't remove tail
      score++;
      scoreEl.textContent = score;
 
      if (score > highScore) {
        highScore = score;
        highScoreEl.textContent = highScore;
        localStorage.setItem('snake-high-score', highScore);
      }
 
      spawnFood();
    } else {
      // Normal move: remove tail
      snake.pop();
    }
  }
 
  // --- Rendering ---
 
  function draw() {
    // Clear canvas
    ctx.fillStyle = '#0f3460';
    ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
 
    // Draw grid lines (subtle)
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
    ctx.lineWidth = 1;
    for (let i = 0; i <= GRID_SIZE; i++) {
      const pos = i * CELL_SIZE;
      ctx.beginPath();
      ctx.moveTo(pos, 0);
      ctx.lineTo(pos, CANVAS_SIZE);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(0, pos);
      ctx.lineTo(CANVAS_SIZE, pos);
      ctx.stroke();
    }
 
    // Draw food
    ctx.fillStyle = '#e94560';
    ctx.beginPath();
    ctx.arc(
      food.x * CELL_SIZE + CELL_SIZE / 2,
      food.y * CELL_SIZE + CELL_SIZE / 2,
      CELL_SIZE / 2 - 2,
      0,
      Math.PI * 2
    );
    ctx.fill();
 
    // Draw snake
    for (let i = 0; i < snake.length; i++) {
      const segment = snake[i];
      const isHead = i === 0;
 
      // Gradient from bright head to dimmer tail
      const brightness = 1 - (i / snake.length) * 0.5;
      const r = Math.round(78 * brightness);
      const g = Math.round(204 * brightness);
      const b = Math.round(163 * brightness);
      ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
 
      const padding = isHead ? 1 : 2;
      ctx.fillRect(
        segment.x * CELL_SIZE + padding,
        segment.y * CELL_SIZE + padding,
        CELL_SIZE - padding * 2,
        CELL_SIZE - padding * 2
      );
 
      // Draw eyes on head
      if (isHead) {
        ctx.fillStyle = '#1a1a2e';
        const eyeSize = 3;
        const dir = DIR[direction];
 
        let eye1x, eye1y, eye2x, eye2y;
        const cx = segment.x * CELL_SIZE + CELL_SIZE / 2;
        const cy = segment.y * CELL_SIZE + CELL_SIZE / 2;
 
        if (dir.x !== 0) {
          // Moving horizontally
          eye1x = cx + dir.x * 4;
          eye1y = cy - 4;
          eye2x = cx + dir.x * 4;
          eye2y = cy + 4;
        } else {
          // Moving vertically
          eye1x = cx - 4;
          eye1y = cy + dir.y * 4;
          eye2x = cx + 4;
          eye2y = cy + dir.y * 4;
        }
 
        ctx.beginPath();
        ctx.arc(eye1x, eye1y, eyeSize, 0, Math.PI * 2);
        ctx.fill();
        ctx.beginPath();
        ctx.arc(eye2x, eye2y, eyeSize, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }
 
  // --- Game State Transitions ---
 
  function startGame() {
    if (gameState === 'gameover' || gameState === 'waiting') {
      if (gameState === 'gameover') init();
      gameState = 'playing';
      overlay.classList.add('hidden');
      lastMoveTime = performance.now();
      animFrameId = requestAnimationFrame(gameLoop);
    }
  }
 
  function gameOver() {
    gameState = 'gameover';
    cancelAnimationFrame(animFrameId);
    overlayMsg.textContent = `Game Over! Score: ${score}`;
    startBtn.textContent = 'Play Again';
    overlay.classList.remove('hidden');
  }
 
  function togglePause() {
    if (gameState === 'playing') {
      gameState = 'paused';
      cancelAnimationFrame(animFrameId);
      pauseBtn.textContent = 'Resume';
      overlayMsg.textContent = 'Paused';
      startBtn.textContent = 'Resume';
      overlay.classList.remove('hidden');
    } else if (gameState === 'paused') {
      gameState = 'playing';
      pauseBtn.textContent = 'Pause';
      overlay.classList.add('hidden');
      lastMoveTime = performance.now();
      animFrameId = requestAnimationFrame(gameLoop);
    }
  }
 
  // --- Input Handling ---
 
  function setDirection(dir) {
    // Prevent 180° reversal
    if (OPPOSITE[dir] === direction) return;
    nextDirection = dir;
  }
 
  document.addEventListener('keydown', (e) => {
    switch (e.key) {
      case 'ArrowUp':
      case 'w':
        e.preventDefault();
        setDirection('up');
        break;
      case 'ArrowDown':
      case 's':
        e.preventDefault();
        setDirection('down');
        break;
      case 'ArrowLeft':
      case 'a':
        e.preventDefault();
        setDirection('left');
        break;
      case 'ArrowRight':
      case 'd':
        e.preventDefault();
        setDirection('right');
        break;
      case ' ':
        e.preventDefault();
        if (gameState === 'playing' || gameState === 'paused') {
          togglePause();
        }
        return;
    }
 
    // Any arrow key starts the game
    if (gameState === 'waiting' || gameState === 'gameover') {
      startGame();
    }
  });
 
  // Touch controls
  document.querySelectorAll('.touch-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      const dir = btn.dataset.dir;
      setDirection(dir);
      if (gameState === 'waiting' || gameState === 'gameover') {
        startGame();
      }
    });
  });
 
  startBtn.addEventListener('click', () => {
    if (gameState === 'paused') {
      togglePause();
    } else {
      startGame();
    }
  });
  pauseBtn.addEventListener('click', togglePause);
 
  speedSlider.addEventListener('input', () => {
    speed = Number(speedSlider.value);
  });
 
  // --- Start ---
  init();
})();

Step 4: Code Walkthrough

The Game Loop

The heart of Snake is the game loop — a function that runs every frame and decides when to update the game:

function gameLoop(timestamp) {
  if (gameState !== 'playing') return;
 
  animFrameId = requestAnimationFrame(gameLoop);
 
  const moveInterval = 1000 / speed;
  if (timestamp - lastMoveTime < moveInterval) return;
  lastMoveTime = timestamp;
 
  update();
  draw();
}

Why not just setInterval? requestAnimationFrame (rAF) is synchronized with the browser's repaint cycle (~60fps). It automatically pauses when the tab is hidden (saving battery), provides a high-resolution timestamp for smooth timing, and prevents tearing.

Fixed timestep: The snake doesn't move 60 times per second — it moves speed times per second. We check if enough time has elapsed since the last move before calling update(). This decouples the game's logical speed from the render framerate.

Snake as a Queue

The snake body is an array where index 0 is the head. Each frame:

  1. Calculate where the head should move
  2. unshift a new head onto the front
  3. pop the tail off the back (unless the snake just ate food)
snake.unshift(newHead); // add to front
 
if (ateFood) {
  // Don't remove tail — snake grows
} else {
  snake.pop(); // remove from back
}

This is a queue (FIFO). The snake "moves" by adding a new head and removing the old tail — but if it eats food, we skip the removal, so it grows by one segment.

Input Buffering

A subtle but critical detail: we buffer the next direction rather than applying it immediately.

let direction;      // what the snake is currently moving
let nextDirection;  // what the player wants
 
function setDirection(dir) {
  if (OPPOSITE[dir] === direction) return; // prevent 180°
  nextDirection = dir;
}
 
function update() {
  direction = nextDirection; // apply at the start of update
  // ... move snake
}

Why buffer? If the snake is moving right and the player presses Up then Left in quick succession (within one game tick), without buffering:

  • Press Up: direction = 'up' (valid, not opposite of 'right')
  • Press Left: direction = 'left' (valid, not opposite of 'up')
  • But the snake hasn't actually moved up yet! From the snake's perspective, it's still going right, and 'left' is the opposite.

The buffer ensures direction changes are validated against the current actual direction, not the last requested direction.

Collision Detection

Wall collision — check if the new head is outside bounds:

if (newHead.x < 0 || newHead.x >= GRID_SIZE ||
    newHead.y < 0 || newHead.y >= GRID_SIZE) {
  gameOver();
}

Self collision — check if the new head overlaps any body segment:

for (let i = 0; i < snake.length - 1; i++) {
  if (snake[i].x === newHead.x && snake[i].y === newHead.y) {
    gameOver();
  }
}

We skip the last segment (snake.length - 1) because the tail is about to move away. If we checked it, the snake couldn't chase its own tail (which should be valid).

Food Spawning

Food must appear on a cell not occupied by the snake:

function spawnFood() {
  const occupied = new Set(snake.map((s) => `${s.x},${s.y}`));
 
  let pos;
  do {
    pos = {
      x: Math.floor(Math.random() * GRID_SIZE),
      y: Math.floor(Math.random() * GRID_SIZE),
    };
  } while (occupied.has(`${pos.x},${pos.y}`));
 
  food = pos;
}

We use a Set of coordinate strings for O(1) collision lookup. The do-while loop retries until we find a free cell.

Edge case: When the snake fills most of the board, random spawning becomes slow. For a production game, you'd collect all empty cells into an array and pick a random index. For an interview, mention this optimization but the random approach is fine for typical board sizes.


Step 5: Interview Variations

"Implement wrap-around walls instead of death"

Instead of game over on wall collision, teleport to the opposite side:

const newHead = {
  x: (head.x + dir.x + GRID_SIZE) % GRID_SIZE,
  y: (head.y + dir.y + GRID_SIZE) % GRID_SIZE,
};
// Remove wall collision check entirely

The modulo operator with the addition of GRID_SIZE handles negative values correctly (e.g., -1 + 20 = 19, so the snake wraps from the left edge to the right).

"Add power-ups"

Extend the food system with different types:

const POWER_UPS = {
  food: { color: '#e94560', effect: () => { score++; } },
  speed: { color: '#f5c542', effect: () => { speed = Math.min(speed + 2, 20); } },
  slow: { color: '#00d2ff', effect: () => { speed = Math.max(speed - 2, 3); } },
  shrink: { color: '#9b59b6', effect: () => {
    if (snake.length > 3) {
      snake.pop(); snake.pop();
    }
  }},
};
 
let currentPowerUp = 'food';
 
function spawnFood() {
  // 80% regular food, 20% random power-up
  const types = Object.keys(POWER_UPS);
  currentPowerUp = Math.random() < 0.8
    ? 'food'
    : types[Math.floor(Math.random() * types.length)];
 
  // ... spawn position logic
}

"Support multiple food items at once"

Change food from a single object to an array:

let foods = [];
 
function spawnFood() {
  const occupied = new Set([
    ...snake.map((s) => `${s.x},${s.y}`),
    ...foods.map((f) => `${f.x},${f.y}`),
  ]);
 
  let pos;
  do {
    pos = {
      x: Math.floor(Math.random() * GRID_SIZE),
      y: Math.floor(Math.random() * GRID_SIZE),
    };
  } while (occupied.has(`${pos.x},${pos.y}`));
 
  foods.push(pos);
}
 
function update() {
  // ... move snake
 
  const eatenIndex = foods.findIndex(
    (f) => f.x === newHead.x && f.y === newHead.y
  );
 
  if (eatenIndex !== -1) {
    foods.splice(eatenIndex, 1);
    score++;
    spawnFood();
    // Don't pop tail
  } else {
    snake.pop();
  }
}

"Optimize rendering with dirty rectangles"

Instead of redrawing the entire canvas every frame, only redraw changed cells:

function draw() {
  // Only clear and redraw cells that changed
  const toClear = [...removedCells, ...movedCells];
  const toDraw = [newHead, ...newFood];
 
  for (const cell of toClear) {
    ctx.fillStyle = '#0f3460';
    ctx.fillRect(cell.x * CELL_SIZE, cell.y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
  }
 
  for (const cell of toDraw) {
    drawCell(cell);
  }
}

For a 20×20 grid this optimization isn't necessary, but for larger grids (100×100+) it makes a meaningful difference. In an interview, mention this as a follow-up optimization.


Common Mistakes in Interviews

  1. Using setInterval instead of requestAnimationFrame. setInterval doesn't sync with browser repaints and doesn't automatically pause when the tab is hidden. Use rAF with a time accumulator.

  2. Allowing 180° turns. If the snake is moving right and the player presses left, the snake would collide with itself instantly. Always check against the current direction, not the last input.

  3. Not buffering input. Processing direction changes immediately can cause the 180° bug when two keys are pressed within the same game tick.

  4. Checking self-collision against the tail. The tail moves away in the same tick. If you include it in the collision check, the snake can never safely follow its own tail.

  5. Food spawning on the snake. Always verify the new food position doesn't overlap any snake segment. Random retry is fine for small boards; for large boards, pick from the set of empty cells.

  6. Forgetting e.preventDefault() on arrow keys. Without this, the page scrolls while playing.


Key Takeaway

Snake is your introduction to the game loop pattern: a continuous cycle of read-input → update-state → render that runs at a fixed rate. This pattern appears far beyond games — animation systems, real-time dashboards, collaborative editing, and physics simulations all use the same structure. The key insight is separating tick rate (how often state changes) from frame rate (how often you draw), giving you precise control over timing without being at the mercy of browser performance.

You might also like