Loading
Create a Canvas-based Snake game with score tracking, increasing difficulty, and persistent high scores.
You're going to build a Snake game using the HTML Canvas API. The snake moves around the board, eats food to grow longer, and the game ends when it collides with a wall or itself. You'll add a scoring system, increasing speed per level, a game over screen, and high score persistence using localStorage.
This tutorial teaches core game development concepts: the game loop, state management, collision detection, input handling, and rendering. The Canvas API gives you pixel-level control over what appears on screen — no DOM manipulation, no CSS layouts, just direct drawing commands.
Everything is vanilla JavaScript. No libraries, no frameworks. One HTML file and one JavaScript file.
The canvas is 400x400 pixels. With a tile size of 20px, that gives us a 20x20 grid — 400 possible positions for the snake and food.
Separating direction from nextDirection prevents a critical bug: if the snake moves right and the player presses down then left within a single tick, the snake would reverse into itself. By buffering the input and applying it once per tick, we eliminate this.
The placeFood function uses a do-while loop to ensure food never spawns on the snake. As the snake gets longer, this becomes more important — without the collision check, food could appear under the snake's body and be instantly consumed.
The direction.y === 0 guard prevents the snake from reversing. If it's moving vertically (y !== 0), pressing up or down is ignored. WASD keys provide an alternative for players who prefer those controls.
The snake moves by adding a new head segment and removing the tail. When it eats food, we skip the pop() — the snake grows by one tile. Every 50 points increases the level, and each level reduces the tick interval by 12ms (capped at 50ms to prevent unplayable speeds).
The snake head is a different color from the body for visual clarity. The +1 and -2 on the rect dimensions create a 1px gap between segments, making the snake visually distinct from a solid bar. Food is rendered as a circle to differentiate it from the snake's square segments.
This is a variable-timestep game loop. requestAnimationFrame fires at the display's refresh rate (typically 60fps), but we only advance the game state when baseSpeed milliseconds have elapsed. This decouples the render rate from the game speed — the snake moves at a consistent pace regardless of the monitor's refresh rate.
Desktop has keyboard input. Mobile needs touch support.
The 10px dead zone prevents accidental direction changes from slight finger movements. The swipe direction is determined by comparing horizontal vs. vertical distance — whichever axis has more movement wins.
Open index.html in a browser and play. Use arrow keys or WASD to move. The snake speeds up every 50 points. Your high score persists across sessions via localStorage.
To take it further: add a pause feature (listen for Escape key, toggle a paused boolean in the game loop), implement wrap-around walls as an alternative mode (head appears on the opposite side instead of dying), or add power-ups that spawn randomly and grant effects like slow-motion or score multipliers. Each of these features exercises a different game development muscle — state management for pause, modular game rules for wall modes, and timed effects for power-ups.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Snake</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0f;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: system-ui, sans-serif;
color: #f0f0f0;
}
canvas {
border: 2px solid #1a1a2e;
border-radius: 4px;
}
.hud {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.hud span {
color: #6366f1;
font-weight: 600;
}
</style>
</head>
<body>
<div class="hud">
<div>Score: <span id="score">0</span></div>
<div>Level: <span id="level">1</span></div>
<div>High Score: <span id="high-score">0</span></div>
</div>
<canvas id="game" width="400" height="400"></canvas>
<script src="game.js"></script>
</body>
</html>// game.js
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const TILE = 20;
const COLS = canvas.width / TILE;
const ROWS = canvas.height / TILE;
const COLORS = {
background: "#0a0a0f",
grid: "#0f0f1a",
snakeHead: "#6366f1",
snakeBody: "#4f46e5",
food: "#10b981",
text: "#f0f0f0",
overlay: "rgba(10, 10, 15, 0.85)",
};
let snake = [];
let direction = { x: 1, y: 0 };
let nextDirection = { x: 1, y: 0 };
let food = { x: 0, y: 0 };
let score = 0;
let level = 1;
let highScore = parseInt(localStorage.getItem("snake-high-score") || "0", 10);
let gameOver = false;
let baseSpeed = 150; // milliseconds between ticks
let lastTick = 0;function init() {
snake = [
{ x: 5, y: 10 },
{ x: 4, y: 10 },
{ x: 3, y: 10 },
];
direction = { x: 1, y: 0 };
nextDirection = { x: 1, y: 0 };
score = 0;
level = 1;
gameOver = false;
baseSpeed = 150;
placeFood();
updateHUD();
}
function placeFood() {
let position;
do {
position = {
x: Math.floor(Math.random() * COLS),
y: Math.floor(Math.random() * ROWS),
};
} while (snake.some((seg) => seg.x === position.x && seg.y === position.y));
food = position;
}document.addEventListener("keydown", (e) => {
switch (e.key) {
case "ArrowUp":
case "w":
if (direction.y === 0) nextDirection = { x: 0, y: -1 };
break;
case "ArrowDown":
case "s":
if (direction.y === 0) nextDirection = { x: 0, y: 1 };
break;
case "ArrowLeft":
case "a":
if (direction.x === 0) nextDirection = { x: -1, y: 0 };
break;
case "ArrowRight":
case "d":
if (direction.x === 0) nextDirection = { x: 1, y: 0 };
break;
case " ":
if (gameOver) {
init();
requestAnimationFrame(gameLoop);
}
break;
}
});function update() {
direction = { ...nextDirection };
const head = {
x: snake[0].x + direction.x,
y: snake[0].y + direction.y,
};
// Wall collision
if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) {
gameOver = true;
onGameOver();
return;
}
// Self collision
if (snake.some((seg) => seg.x === head.x && seg.y === head.y)) {
gameOver = true;
onGameOver();
return;
}
snake.unshift(head);
// Food collision
if (head.x === food.x && head.y === food.y) {
score += 10 * level;
if (score % 50 === 0) {
level++;
baseSpeed = Math.max(50, 150 - (level - 1) * 12);
}
placeFood();
updateHUD();
} else {
snake.pop();
}
}function draw() {
// Background
ctx.fillStyle = COLORS.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Grid lines
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 0.5;
for (let x = 0; x < COLS; x++) {
ctx.beginPath();
ctx.moveTo(x * TILE, 0);
ctx.lineTo(x * TILE, canvas.height);
ctx.stroke();
}
for (let y = 0; y < ROWS; y++) {
ctx.beginPath();
ctx.moveTo(0, y * TILE);
ctx.lineTo(canvas.width, y * TILE);
ctx.stroke();
}
// Food
ctx.fillStyle = COLORS.food;
ctx.beginPath();
ctx.arc(food.x * TILE + TILE / 2, food.y * TILE + TILE / 2, TILE / 2 - 2, 0, Math.PI * 2);
ctx.fill();
// Snake
snake.forEach((seg, i) => {
ctx.fillStyle = i === 0 ? COLORS.snakeHead : COLORS.snakeBody;
ctx.fillRect(seg.x * TILE + 1, seg.y * TILE + 1, TILE - 2, TILE - 2);
});
}function gameLoop(timestamp) {
if (gameOver) return;
const elapsed = timestamp - lastTick;
if (elapsed >= baseSpeed) {
update();
lastTick = timestamp;
}
draw();
requestAnimationFrame(gameLoop);
}function onGameOver() {
if (score > highScore) {
highScore = score;
localStorage.setItem("snake-high-score", String(highScore));
}
updateHUD();
drawGameOver();
}
function drawGameOver() {
ctx.fillStyle = COLORS.overlay;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = COLORS.text;
ctx.font = "bold 32px system-ui";
ctx.textAlign = "center";
ctx.fillText("Game Over", canvas.width / 2, canvas.height / 2 - 30);
ctx.font = "18px system-ui";
ctx.fillText(`Score: ${score}`, canvas.width / 2, canvas.height / 2 + 10);
if (score >= highScore && score > 0) {
ctx.fillStyle = "#fbbf24";
ctx.fillText("New High Score!", canvas.width / 2, canvas.height / 2 + 40);
}
ctx.fillStyle = "#6b7280";
ctx.font = "14px system-ui";
ctx.fillText("Press SPACE to play again", canvas.width / 2, canvas.height / 2 + 75);
}
function updateHUD() {
document.getElementById("score").textContent = score;
document.getElementById("level").textContent = level;
document.getElementById("high-score").textContent = highScore;
}let touchStartX = 0;
let touchStartY = 0;
canvas.addEventListener("touchstart", (e) => {
e.preventDefault();
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
if (gameOver) {
init();
requestAnimationFrame(gameLoop);
}
});
canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
const dx = e.touches[0].clientX - touchStartX;
const dy = e.touches[0].clientY - touchStartY;
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
if (Math.abs(dx) > Math.abs(dy)) {
if (dx > 0 && direction.x === 0) nextDirection = { x: 1, y: 0 };
else if (dx < 0 && direction.x === 0) nextDirection = { x: -1, y: 0 };
} else {
if (dy > 0 && direction.y === 0) nextDirection = { x: 0, y: 1 };
else if (dy < 0 && direction.y === 0) nextDirection = { x: 0, y: -1 };
}
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
});// Initialize and start
updateHUD();
init();
requestAnimationFrame(gameLoop);