~/blog/making_of_snake
cd ..
Ξ~/blog/making_of_snakecat making_of_snake.md

Building a Snake Game

Published:2025-10-25
Read time:12 min read

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:

  1. Simple rules → Easy to understand logic
  2. Clear collision detection → You hit a wall or yourself, you die (just like real life, metaphorically)
  3. Visual feedback → You can see the results immediately
  4. 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 back
  • grow() 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

  1. Create this folder structure:
  2. 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)
  3. Paste the code above into the respective files.
  4. Serve locally (because canvas has some fun CORS quirks):
  5. python3 -m http.server 8000
  6. Open http://localhost:8000 in your browser.
  7. Press SPACE and start eating virtual food like your life depends on it.

  8. Gotchas & 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.

#JavaScript#Games#Tutorial