Building a Snake Game
How to Build a Classic Snake Game with Vanilla JavaScript (No Frameworks, Just Pure Vibes)
So, you want to build Snake. You know, that game your parents used to play on their Nokia 3310? The one that mysteriously consumed hours of your life? Well, strap in, because we're about to recreate that digital dopamine hit using nothing but HTML, CSS, and vanilla JavaScript—no React, no Vue, no whatever framework is trendy this week.
Why Build Snake?
Before we dive into the code, let's talk about why Snake is actually the perfect project for learning game development:
- Simple rules → Easy to understand logic
- Clear collision detection → You hit a wall or yourself, you die (just like real life, metaphorically)
- Visual feedback → You can see the results immediately
- Endless tweaking potential → Speed, colors, food spawning, high scores... it never ends
Plus, there's something satisfying about making a game that actually works. It's not just a to-do list app for the 47th time.
The Architecture: Breaking It Down
Here's the thing about game development—even simple games need structure. We're going to separate concerns into clean modules:
┌─────────────┐
│ Input │ (keyboard listening)
│ Handler │
└──────┬──────┘
│
▼
┌─────────────────┐
│ Game State & │ (the brains)
│ Game Loop │
└─────┬───────────┘
│
▼
┌──────────────────┐
│ Snake, Food, │ (game objects)
│ Position │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ Renderer │ (the display)
└──────────────────┘Think of it like a kitchen: the Input is the order, Game is the cooking, and Renderer is plating it up pretty. If you dump everything into one file, you'll have spaghetti code. And not the good Italian kind.
Step 1: The HTML Canvas Setup
First, we need a stage for our snake to perform on. Enter: the HTML5 <canvas> element.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake Game - Vanilla JS</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="gameWrapper">
<div id="gameContainer">
<canvas id="gameCanvas" width="400" height="400"></canvas>
</div>
<div id="instructionsPanel">
<h2>🐍 Snake Game</h2>
<div id="scoreDisplay">Score: <span id="score">0</span></div>
<div id="bestScoreDisplay">Best Score: <span id="best-score">0</span></div>
<div id="gameStatus">Press SPACE to start</div>
<div id="instructions">
<p><strong>Arrow Keys</strong> or <strong>WASD</strong> to move</p>
<p><strong>SPACE</strong> to pause/resume</p>
<p><strong>R</strong> to restart</p>
</div>
</div>
</div>
<!-- Scripts loaded in order -->
<script src="js/Position.js"></script>
<script src="js/Snake.js"></script>
<script src="js/Food.js"></script>
<script src="js/Renderer.js"></script>
<script src="js/Input.js"></script>
<script src="js/Game.js"></script>
<script src="js/main.js"></script>
</body>
</html>Key points:
- Canvas is 400x400 (nice power of 2, and 20px grid = 20x20 cells)
- Scripts load in dependency order (important! Don't load Game.js before Position.js or you'll have a bad time)
- UI elements for score and status updates
Step 2: The Position Helper Class
Before we make the snake, we need a simple way to represent locations on the grid. This is where Position comes in—basically a fancy tuple.
// js/Position.js
class Position {
constructor(x, y) {
this.x = x;
this.y = y;
}
// Check if this position equals another
equals(other) {
return this.x === other.x && this.y === other.y;
}
// Clone to avoid reference weirdness
clone() {
return new Position(this.x, this.y);
}
}This tiny class saves us from accidentally mutating positions all over the place. Trust me, you want this.
Step 3: The Snake Class
Now we build the serpent itself. The snake is a chain of Position objects, with the first one being the head.
// js/Snake.js
class Snake {
constructor(initialX = 14, initialY = 10) {
this.reset(initialX, initialY);
}
reset(startX, startY) {
// Start with 5 segments: head + 4 body pieces
this.segments = [
new Position(startX, startY), // Head
new Position(startX - 1, startY), // Body
new Position(startX - 2, startY),
new Position(startX - 3, startY),
new Position(startX - 4, startY) // Tail
];
this.direction = new Position(0, 0); // Not moving yet
this.nextDirection = new Position(0, 0); // Buffered input
}
getHead() {
return this.segments[0];
}
setDirection(dx, dy) {
// Prevent snake from reversing into itself
// (can't go left if you're moving right, etc)
const isOpposite =
(this.direction.x === -dx && this.direction.y === -dy);
if (!isOpposite) {
this.nextDirection = new Position(dx, dy);
}
}
move() {
// Update actual direction from buffered input
if (this.nextDirection.x !== 0 || this.nextDirection.y !== 0) {
this.direction = this.nextDirection.clone();
}
// Create new head
const head = this.getHead();
const newHead = new Position(
head.x + this.direction.x,
head.y + this.direction.y
);
// Add new head and remove tail (creates movement)
this.segments.unshift(newHead);
this.segments.pop();
}
grow() {
// Add a new segment at the tail (don't remove anything)
const tail = this.segments[this.segments.length - 1];
this.segments.push(tail.clone());
}
// Check if head collides with body
collidesWith(position) {
return this.segments.some(segment => segment.equals(position));
}
}The logic:
move()shifts the entire snake forward—add a segment to the front, remove from the backgrow()adds a segment when food is eaten (no removal)- Direction buffering prevents the "instant death" problem where you die hitting your own body because inputs registered in the same tick
Step 4: The Food Class
Food spawning is hilariously simple. Just pick a random grid cell and make sure it's not inside the snake.
// js/Food.js
class Food {
constructor(gridCount) {
this.gridCount = gridCount;
this.position = null;
this.spawn();
}
spawn() {
// Keep trying random positions until we find one not occupied by snake
// (or just do it once and hope—depends on your chaos tolerance)
let randomX, randomY, attempts = 0;
do {
randomX = Math.floor(Math.random() * this.gridCount);
randomY = Math.floor(Math.random() * this.gridCount);
attempts++;
} while (attempts < 100 && this.isOccupiedBySnake(randomX, randomY));
this.position = new Position(randomX, randomY);
}
// This method needs to be defined or we have to pass the snake
// For now, we'll handle collision checking in Game.js
isOccupiedBySnake(x, y) {
// Placeholder—Game.js will pass the snake to check
return false;
}
}Actually, let's make it cleaner by letting the Game handle the validation:
// js/Food.js (simpler version)
class Food {
constructor(gridCount) {
this.gridCount = gridCount;
this.position = new Position(
Math.floor(Math.random() * gridCount),
Math.floor(Math.random() * gridCount)
);
}
spawn() {
this.position = new Position(
Math.floor(Math.random() * this.gridCount),
Math.floor(Math.random() * this.gridCount)
);
}
}Much better. Simplicity is a feature, not a bug.
Step 5: The Renderer Class
This is where we actually draw stuff to the canvas. No fancy libraries, just raw canvas API.
// js/Renderer.js
class Renderer {
constructor(canvasId, gridSize, gridCount, colors) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.gridSize = gridSize; // Pixel size per grid cell (20px)
this.gridCount = gridCount; // Number of cells (20x20 = 400 cells)
this.colors = colors;
}
clear() {
// Fill background (dark blue-ish)
this.ctx.fillStyle = '#001a4d';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw grid for visibility (optional but nice)
this.ctx.strokeStyle = '#003366';
this.ctx.lineWidth = 0.5;
for (let i = 0; i <= this.gridCount; i++) {
const pos = i * this.gridSize;
// Vertical lines
this.ctx.beginPath();
this.ctx.moveTo(pos, 0);
this.ctx.lineTo(pos, this.canvas.height);
this.ctx.stroke();
// Horizontal lines
this.ctx.beginPath();
this.ctx.moveTo(0, pos);
this.ctx.lineTo(this.canvas.width, pos);
this.ctx.stroke();
}
}
drawSnake(snake) {
snake.segments.forEach((segment, index) => {
// Head is darker, body is lighter
if (index === 0) {
this.ctx.fillStyle = this.colors.SNAKE_HEAD;
} else {
this.ctx.fillStyle = this.colors.SNAKE_BODY;
}
const x = segment.x * this.gridSize;
const y = segment.y * this.gridSize;
this.ctx.fillRect(x, y, this.gridSize - 1, this.gridSize - 1);
});
}
drawFood(food) {
this.ctx.fillStyle = this.colors.FOOD;
const x = food.position.x * this.gridSize;
const y = food.position.y * this.gridSize;
this.ctx.beginPath();
this.ctx.arc(
x + this.gridSize / 2,
y + this.gridSize / 2,
this.gridSize / 2 - 1,
0,
Math.PI * 2
);
this.ctx.fill();
}
drawGameOver(score, bestScore) {
// Semi-transparent overlay
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Text
this.ctx.fillStyle = this.colors.TEXT;
this.ctx.font = 'bold 30px Arial';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText('GAME OVER', this.canvas.width / 2, this.canvas.height / 2 - 40);
this.ctx.font = '16px Arial';
this.ctx.fillText(`Score: ${score}`, this.canvas.width / 2, this.canvas.height / 2 + 10);
this.ctx.fillText(`Best: ${bestScore}`, this.canvas.width / 2, this.canvas.height / 2 + 35);
this.ctx.fillText('Press R to restart', this.canvas.width / 2, this.canvas.height / 2 + 70);
}
}The canvas API is actually pretty straightforward once you get the hang of it. fillRect for rectangles, arc for circles. That's like 80% of what you need.
Step 6: The Input Handler
Keyboard input is handled separately so it doesn't clutter the game logic.
// js/Input.js
class InputHandler {
constructor(game) {
this.game = game;
this.setupListeners();
}
setupListeners() {
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowUp':
case 'w':
case 'W':
this.handleMove(0, -1);
break;
case 'ArrowDown':
case 's':
case 'S':
this.handleMove(0, 1);
break;
case 'ArrowLeft':
case 'a':
case 'A':
this.handleMove(-1, 0);
break;
case 'ArrowRight':
case 'd':
case 'D':
this.handleMove(1, 0);
break;
case ' ': // Space to pause
e.preventDefault();
this.game.togglePause();
break;
case 'r':
case 'R':
this.game.restart();
break;
default:
break;
}
});
}
handleMove(dx, dy) {
if (!this.game.hasStarted) {
this.game.start();
}
this.game.snake.setDirection(dx, dy);
}
}Step 7: The Game Class (The Main Event)
This is where everything comes together. The Game class manages the game loop, collision detection, and state.
// js/Game.js
class Game {
constructor(config) {
this.config = config;
this.gridCount = config.CANVAS_SIZE / config.GRID_SIZE;
// Initialize components
this.renderer = new Renderer(
config.canvasId,
config.GRID_SIZE,
this.gridCount,
config.COLORS
);
this.snake = new Snake(
Math.floor(this.gridCount / 2),
Math.floor(this.gridCount / 2)
);
this.food = new Food(this.gridCount);
this.input = new InputHandler(this);
// Game state
this.score = 0;
this.bestScore = localStorage.getItem('snakeBestScore') || 0;
this.gameSpeed = config.SPEED.INITIAL;
this.hasStarted = false;
this.isPaused = false;
this.gameLoopId = null;
// UI
this.scoreElement = document.getElementById('score');
this.bestScoreElement = document.getElementById('best-score');
this.statusElement = document.getElementById('gameStatus');
this.updateScoreUI();
this.renderer.clear();
this.render();
}
start() {
if (this.hasStarted) return;
this.hasStarted = true;
this.isPaused = false;
this.statusElement.textContent = '▶ Playing - Press SPACE to pause';
this.startGameLoop();
}
startGameLoop() {
if (this.gameLoopId) clearInterval(this.gameLoopId);
this.gameLoopId = setInterval(
() => this.update(),
this.gameSpeed
);
}
stopGameLoop() {
if (this.gameLoopId) {
clearInterval(this.gameLoopId);
this.gameLoopId = null;
}
}
togglePause() {
if (!this.hasStarted) return;
this.isPaused = !this.isPaused;
if (this.isPaused) {
this.stopGameLoop();
this.statusElement.textContent = '⏸ Paused - Press SPACE to resume';
} else {
this.startGameLoop();
this.statusElement.textContent = '▶ Playing - Press SPACE to pause';
}
}
update() {
// Move snake
this.snake.move();
// Check if food was eaten
if (this.snake.getHead().equals(this.food.position)) {
this.snake.grow();
this.food.spawn();
// Make sure food didn't spawn inside snake
while (this.snake.collidesWith(this.food.position)) {
this.food.spawn();
}
this.score += this.config.SCORE_PER_FOOD;
this.updateScoreUI();
this.speedUp();
}
// Check collisions
const head = this.snake.getHead();
// Wall collision
if (head.x < 0 || head.x >= this.gridCount ||
head.y < 0 || head.y >= this.gridCount) {
this.gameOver();
return;
}
// Self collision (check body, not head)
for (let i = 1; i < this.snake.segments.length; i++) {
if (this.snake.segments[i].equals(head)) {
this.gameOver();
return;
}
}
this.render();
}
speedUp() {
const newSpeed = Math.max(
this.config.SPEED.MIN,
this.gameSpeed - this.config.SPEED.INCREASE
);
if (newSpeed !== this.gameSpeed) {
this.gameSpeed = newSpeed;
this.startGameLoop(); // Restart with new speed
}
}
gameOver() {
this.stopGameLoop();
// Update best score
if (this.score > this.bestScore) {
this.bestScore = this.score;
localStorage.setItem('snakeBestScore', this.bestScore);
this.updateScoreUI();
}
this.renderer.clear();
this.renderer.drawSnake(this.snake);
this.renderer.drawFood(this.food);
this.renderer.drawGameOver(this.score, this.bestScore);
this.statusElement.textContent = '☠️ Game Over - Press R to restart';
}
restart() {
this.score = 0;
this.gameSpeed = this.config.SPEED.INITIAL;
this.hasStarted = false;
this.isPaused = false;
this.stopGameLoop();
this.snake.reset(
Math.floor(this.gridCount / 2),
Math.floor(this.gridCount / 2)
);
this.food.spawn();
this.updateScoreUI();
this.statusElement.textContent = 'Press SPACE to start';
this.renderer.clear();
this.render();
}
updateScoreUI() {
this.scoreElement.textContent = this.score;
this.bestScoreElement.textContent = this.bestScore;
}
render() {
this.renderer.clear();
this.renderer.drawSnake(this.snake);
this.renderer.drawFood(this.food);
}
}The key here is the update loop: move → check collisions → render. Every. Single. Frame.
Step 8: Configuration & Bootstrap
Finally, tie it all together in a config and main file:
// js/main.js
const CONFIG = {
CANVAS_SIZE: 400,
GRID_SIZE: 20,
COLORS: {
SNAKE_HEAD: 'darkblue',
SNAKE_BODY: 'white',
FOOD: 'orange',
TEXT: 'white'
},
SPEED: {
INITIAL: 150, // ms between moves
MIN: 80, // fastest the game gets
INCREASE: 5 // how much faster per food
},
SCORE_PER_FOOD: 10,
GAME_OVER_DELAY: 2000,
canvasId: 'gameCanvas'
};
document.addEventListener('DOMContentLoaded', () => {
window.game = new Game(CONFIG);
});Step 9: Styling (Keep It Simple)
/* css/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#gameWrapper {
display: flex;
gap: 30px;
background: rgba(255, 255, 255, 0.1);
padding: 30px;
border-radius: 15px;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
backdrop-filter: blur(4px);
}
#gameContainer {
display: flex;
justify-content: center;
align-items: center;
}
canvas {
border: 3px solid white;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
background: #001a4d;
}
#instructionsPanel {
color: white;
min-width: 250px;
}
#instructionsPanel h2 {
margin-bottom: 20px;
font-size: 24px;
}
#scoreDisplay,
#bestScoreDisplay {
margin-bottom: 15px;
font-size: 18px;
}
#gameStatus {
margin: 20px 0;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
font-size: 14px;
}
#instructions {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.3);
font-size: 14px;
line-height: 1.8;
}
#instructions p {
margin-bottom: 8px;
}How to Run It
- Create this folder structure:
- Paste the code above into the respective files.
- Serve locally (because canvas has some fun CORS quirks):
- Open
http://localhost:8000in your browser. - Press SPACE and start eating virtual food like your life depends on it.
snake-game/
├── index.html
├── css/
│ └── style.css
├── js/
│ ├── Position.js
│ ├── Snake.js
│ ├── Food.js
│ ├── Renderer.js
│ ├── Input.js
│ ├── Game.js
│ └── main.js
└── public/
└── logo.png (optional)python3 -m http.server 8000Gotchas & Tips
🔴 The Reverse Direction Problem
Your snake can't instantly reverse into itself. We handle this with direction buffering—the game checks if your current direction is opposite to your input and ignores it. Saves lives (metaphorically).
🔴 Script Loading Order
Your scripts must load in the right order. Position.js first, then classes that depend on it. If you get "Position is not defined," you loaded things wrong.
🔴 Canvas Context is Global
Once you grab canvas.getContext('2d'), you're working with a stateful object. Be careful with fillStyle and strokeStyle—they persist between calls.
💡 LocalStorage for High Scores
We use localStorage to persist the best score. It's not cloud storage, but it survives refreshes. Delete your browser data and it's gone. Such is life.
💡 Grid vs Pixel Coordinates
Everything internally uses grid coordinates (0-19). The Renderer converts to pixels. This separation is chef's kiss.
What's Next?
Want to make it more interesting? Try these:
- Power-ups: Invincibility, speed boost, double points
- Obstacles: Walls in the middle of the board
- Multiplayer: Two snakes, one board (chaos)
- Difficulty Levels: Easier starting speed, harder obstacles
- Sound: Add audio for eating food, game over, etc.
- Mobile Touch Controls: Arrow buttons for phone players
- Different Game Modes: Endless, timed, survival
Final Thoughts
Building Snake with vanilla JavaScript is genuinely satisfying. You're not fighting a framework; you're just building something that works. The code is readable, the performance is snappy (20ms game loop is nothing), and most importantly, it's fun.
And hey, next time someone asks "can you build a game?" you can confidently say yes. Then maybe they'll pay you in snacks. Or actual money. Preferably actual money.
Now go forth and snake responsibly. 🐍
— Built with pure JavaScript, zero frameworks, and a reasonable amount of caffeine.