Snake is one of the best first games to build. The rules are simple, the logic is clear, and you will learn core game development concepts that apply to every game you build after this. In this first part of a 6-part series, we cover the fundamentals: grid system, snake movement, food spawning, collision detection, and the game loop.
Snake games work on a grid. Each cell can be empty, part of the snake, or food. We define the grid size and cell size, then use them to calculate canvas dimensions.
const GRID_SIZE = 20; // 20x20 grid
const CELL_SIZE = 20; // each cell is 20px
const WIDTH = GRID_SIZE * CELL_SIZE; // 400px
const HEIGHT = GRID_SIZE * CELL_SIZE; // 400px
const canvas = document.getElementById('game');
canvas.width = WIDTH;
canvas.height = HEIGHT;
const ctx = canvas.getContext('2d');
The snake is an array of {x, y} objects. The first element is the head, the rest is the body. The snake starts with 3 segments.
let snake = [
{ x: 10, y: 10 }, // head
{ x: 9, y: 10 }, // body
{ x: 8, y: 10 }, // tail
];
let direction = { x: 1, y: 0 }; // moving right
let nextDirection = { x: 1, y: 0 };
We use nextDirection as a buffer. This prevents the snake from reversing into itself when the player presses keys quickly. The actual direction only updates at the start of each game tick.
Each tick, we calculate a new head position by adding the direction to the current head. Then we add the new head to the front of the array and remove the tail. This creates the illusion of movement.
function moveSnake() {
direction = { ...nextDirection };
const head = snake[0];
const newHead = {
x: head.x + direction.x,
y: head.y + direction.y
};
snake.unshift(newHead); // add new head
snake.pop(); // remove tail
}
pop() so it grows by one segment.
Listen for arrow keys and update nextDirection. The critical rule: the snake cannot reverse direction. If it is moving right, pressing left should do nothing.
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowUp':
if (direction.y === 0)
nextDirection = { x: 0, y: -1 };
break;
case 'ArrowDown':
if (direction.y === 0)
nextDirection = { x: 0, y: 1 };
break;
case 'ArrowLeft':
if (direction.x === 0)
nextDirection = { x: -1, y: 0 };
break;
case 'ArrowRight':
if (direction.x === 0)
nextDirection = { x: 1, y: 0 };
break;
}
});
The if (direction.y === 0) check ensures you can only change to up/down when currently moving horizontally, and vice versa. This is the simplest way to prevent 180-degree turns.
Food appears at a random grid position that is not occupied by the snake. We keep generating random positions until we find an empty cell.
let food = spawnFood();
function spawnFood() {
let pos;
do {
pos = {
x: Math.floor(Math.random() * GRID_SIZE),
y: Math.floor(Math.random() * GRID_SIZE)
};
} while (snake.some(s => s.x === pos.x && s.y === pos.y));
return pos;
}
function checkFood() {
const head = snake[0];
if (head.x === food.x && head.y === food.y) {
// Grow: add segment back (undo the pop)
snake.push({ ...snake[snake.length - 1] });
food = spawnFood();
score++;
}
}
Two types of collision end the game: hitting a wall and hitting yourself.
function checkCollision() {
const head = snake[0];
// Wall collision
if (head.x < 0 || head.x >= GRID_SIZE ||
head.y < 0 || head.y >= GRID_SIZE) {
return true;
}
// Self collision (skip head at index 0)
for (let i = 1; i < snake.length; i++) {
if (head.x === snake[i].x &&
head.y === snake[i].y) {
return true;
}
}
return false;
}
Wall collision checks if the head is outside the grid bounds. Self collision checks if the head overlaps any body segment. Both are simple coordinate comparisons.
Clear the canvas, draw the grid background, the snake, and the food. The snake head gets a slightly different color so the player can see which end is which.
function draw() {
// Background
ctx.fillStyle = '#0f0f23';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Grid lines (optional, subtle)
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
for (let i = 0; i <= GRID_SIZE; i++) {
ctx.beginPath();
ctx.moveTo(i * CELL_SIZE, 0);
ctx.lineTo(i * CELL_SIZE, HEIGHT);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * CELL_SIZE);
ctx.lineTo(WIDTH, i * CELL_SIZE);
ctx.stroke();
}
// Snake
snake.forEach((seg, i) => {
ctx.fillStyle = i === 0 ? '#4cff72' : '#2ad060';
ctx.fillRect(
seg.x * CELL_SIZE + 1,
seg.y * CELL_SIZE + 1,
CELL_SIZE - 2,
CELL_SIZE - 2
);
});
// Food
ctx.fillStyle = '#ff4040';
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();
}
The game loop runs at a fixed interval. Each tick: move the snake, check for food, check for collisions, then draw. We use setInterval for simplicity — the interval controls the game speed.
let score = 0;
let gameOver = false;
const SPEED = 150; // milliseconds per tick
function gameLoop() {
if (gameOver) return;
moveSnake();
checkFood();
if (checkCollision()) {
gameOver = true;
drawGameOver();
return;
}
draw();
drawScore();
}
function drawScore() {
ctx.fillStyle = '#fff';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Score: ' + score, 8, 18);
}
function drawGameOver() {
draw(); // draw final state
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
ctx.fillStyle = '#ff4040';
ctx.font = 'bold 24px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', WIDTH/2, HEIGHT/2 - 10);
ctx.fillStyle = '#fff';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.fillText('Score: ' + score, WIDTH/2, HEIGHT/2 + 20);
ctx.fillStyle = '#888';
ctx.font = '12px "Segoe UI", sans-serif';
ctx.fillText('Press R to restart', WIDTH/2, HEIGHT/2 + 45);
}
setInterval(gameLoop, SPEED);
Let the player restart by pressing R. Reset the snake, direction, score, and food.
document.addEventListener('keydown', (e) => {
if ((e.key === 'r' || e.key === 'R') && gameOver) {
snake = [
{ x: 10, y: 10 },
{ x: 9, y: 10 },
{ x: 8, y: 10 },
];
direction = { x: 1, y: 0 };
nextDirection = { x: 1, y: 0 };
score = 0;
gameOver = false;
food = spawnFood();
}
});
Here is how all the pieces fit together in the game loop:
nextDirection updatesThis gives you a fully playable Snake game. From here you can add:
Continue to Part 2: High Score where we add localStorage persistence, floating score animations, and a game over screen.