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.
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 loop —
requestAnimationFramewith 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:
- Game loop architecture — continuous updates vs. event-driven interaction
- Queue/deque data structure — the snake body is a queue (add to front, remove from back)
- Collision detection — boundary checking and self-intersection
- Input buffering — handling rapid key presses without corrupting direction state
- 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:
- Calculate where the head should move
unshifta new head onto the frontpopthe 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 entirelyThe 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
-
Using
setIntervalinstead ofrequestAnimationFrame.setIntervaldoesn't sync with browser repaints and doesn't automatically pause when the tab is hidden. Use rAF with a time accumulator. -
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.
-
Not buffering input. Processing direction changes immediately can cause the 180° bug when two keys are pressed within the same game tick.
-
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.
-
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.
-
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
Build Connect Four from Scratch with Vanilla JS
A complete guide to building Connect Four using only HTML, CSS, and vanilla JavaScript. Covers gravity-based piece dropping, win detection across rows/columns/diagonals, and AI opponent. Interview-ready implementation.
FrontendBuild Tic-Tac-Toe from Scratch with Vanilla JS
A step-by-step guide to building a complete Tic-Tac-Toe game using only HTML, CSS, and vanilla JavaScript. Covers game state management, win detection, and AI opponent — perfect for frontend interview prep.
FrontendBuild a Hierarchical Checkbox from Scratch with Vanilla JS
A complete guide to building a hierarchical (tri-state) checkbox tree using only HTML, CSS, and vanilla JavaScript. Covers parent-child propagation, indeterminate state, dynamic trees, and accessibility. Interview-ready implementation.