Build Snake in vanilla JavaScript on an HTML Canvas — no framework, no dependencies, no build tooling. Just one index.html file, one snake.js file, and your browser.
You'll touch the requestAnimationFrame game loop, keyboard input handling, tile-based collision detection, and the DOM. By the end you'll have a working game you can share — and you'll understand how every 2-D game loop works, even in bigger engines.
We're not racing. Each step is one idea. If a hint helps, take it — there's no penalty.
Before you start
Python 3 usually ships on macOS + Linux. Check with python3 --version. To serve files, you'll run python3 -m http.server 8000 from your project folder, then visit http://localhost:8000 in your browser.
The walkthrough
Create index.html and snake.js
In a fresh folder, create two files: index.html and snake.js.
index.html should have the standard HTML boilerplate: a <canvas> element (any size you want, but let's say 400×400 pixels) and a <script> tag that loads snake.js.
<!DOCTYPE html> <html> <head> <title>Snake</title> </head> <body> <canvas id="canvas" width="400" height="400" style="border: 1px solid black;"></canvas> <script src="snake.js"></script> </body> </html>
Get the canvas context and set its size
In snake.js, write code that:
- Gets the canvas element from the HTML
- Gets its 2-D drawing context
- Logs the canvas width to the console so you know it loaded
Run the HTML file in your browser (start the local server if needed). Open the browser console (right-click → Inspect → Console tab) and check that the width logged correctly.
400
Draw a 20×20 tile grid on the canvas
The canvas is 400×400 pixels, and we want to think of it as a 20×20 grid of tiles. That means each tile is 20×20 pixels.
Write a function that draws the grid: loop from 0 to 19 on both axes, and for each tile, draw a small rectangle outline. Use ctx.strokeRect() to draw just the outline (not filled).
When you run it, you should see a grid of dark lines.
(grid visible on canvas)
Represent the snake as an array of tile coordinates
The snake is a line of body segments. We'll store it as an array of objects, where each object has an x and y tile position.
At the top of your snake.js file, create an array representing the starting snake. The snake should start at the center of the grid (around tile [10, 10]) and be 3 tiles long, stretching upward.
For example: [{x: 10, y: 10}, {x: 10, y: 9}, {x: 10, y: 8}] — the first item is the head, the last is the tail.
Then log the array to the console so you can see it.
[object Object]
Draw the snake on the grid
Write a function drawSnake(ctx, snake) that loops through the snake array and draws a filled rectangle for each body segment.
Make sure you clear the canvas first (with ctx.clearRect() or ctx.fillStyle + a full-screen fill) so the old grid doesn't stay there. Redraw the grid, then draw the snake on top.
(grid with a 3-tile snake in the center, visible on canvas)
Set up a game loop with requestAnimationFrame
The snake needs to move continuously. We'll use requestAnimationFrame — a browser API that runs a function right before the screen refreshes (roughly 60 times per second).
But the classic Snake game moves slower — about 10 times per second. We'll use an accumulator pattern: track elapsed time since the last move, and only move the snake when enough time has passed.
Write a loop function that:
- Calls itself with
requestAnimationFrame - Tracks time with
Date.now()orperformance.now() - Moves the snake only when >= 100 milliseconds have passed since the last move (that's roughly 10 FPS)
- Clears, redraws grid + snake, repeats
For now, make the snake move in one direction (e.g., always upward). You'll control the direction with keys in the next step.
(snake smoothly moves upward on the canvas, slowly)
Why use `requestAnimationFrame` instead of `setInterval`?
Listen for arrow keys and change the snake's direction
Add a variable to track the snake's current direction: let direction = { x: 0, y: -1 } (moving up — x stays the same, y decreases).
Listen for keydown events with window.addEventListener("keydown", handler). When the user presses an arrow key, update the direction — but guard against reversing: the snake can't instantly turn 180 degrees into itself.
Update the move logic to use the direction: {x: head.x + direction.x, y: head.y + direction.y} instead of hard-coding upward.
(snake responds to arrow keys, doesn't reverse into itself)
When moving, add a head but only drop the tail if we're not eating
Right now, the snake's length never changes because we add a head and drop a tail every move.
Add a variable let justAte = false. In your move code:
- Always add a new head
- Only drop the tail if
justAteis false - If
justAteis true, set it back to false (you grow once per food eaten)
For now, justAte stays false, so the snake doesn't grow yet. We'll set it to true in the next step when food is eaten.
(snake still moves, length doesn't change)
Create a food object at a random tile (never on the snake)
Add a variable let food = null at the top. Write a function spawnFood() that:
- Picks a random tile from 0–19 on both axes
- Checks if that tile is occupied by the snake
- If yes, pick again (or loop until you find an empty tile)
- If no, set
food = {x: ..., y: ...}and return
Call spawnFood() once at the start to place the first food. Then draw the food on the canvas (use a different color, like red).
(red square appears at a random location, never overlapping the snake)
Detect collision with food, grow, and respawn
After the snake moves (you've added the new head), check if the head position equals the food position. If it does:
- Set
justAte = true(so the next move doesn't drop the tail) - Call
spawnFood()to place new food
(snake eats the red food, grows by one segment, new food appears)
End the game if the snake hits a wall
Add a variable let gameOver = false. After the snake moves, check if the head is outside the 0–19 range on either axis. If it is, set gameOver = true and stop moving.
In your draw code, skip the move logic if gameOver is true — just draw the current state and the game loop keeps running, frozen.
(snake stops at the edge, game freezes)
End the game if the snake hits itself
After the snake moves, check if the head (position 0 in the snake array) is at the same position as any other body segment (positions 1 through the end).
If it is, set gameOver = true.
(snake coils into itself, game freezes)
Display the score and high score
Add let score = 0. Every time the snake eats food (when justAte is true), increment the score.
Draw the score above the canvas using HTML: add a <div id="score"></div> to your HTML (above the canvas), then update its text content in your game loop: document.getElementById("score").textContent = `Score: ${score}`;.
Also track a high score in localStorage: after setting the score, check if it's higher than the stored high score. If so, update it.
Score: 3
<div id="score" style="font-family: monospace; font-size: 20px; margin-bottom: 10px;"></div> <canvas id="canvas" width="400" height="400" style="border: 1px solid black;"></canvas>
Add a Game Over overlay and restart on spacebar
When gameOver is true, draw a semi-transparent overlay on top of the canvas and display "Game Over" + the final score. Add an instruction: "Press SPACE to restart."
Listen for the spacebar. When pressed (and the game is over), reset all state: snake back to the starting array, score back to 0, direction back to up, gameOver back to false, and spawn new food. The game runs again.
Game Over screen appears, spacebar restarts
You did it
Run the game. Play it a few times. Intentionally hit a wall, eat some food, hit yourself. Press SPACE and play again. Watch the high score stick across page reloads.
You wrote:
- An HTML page with a canvas and a script
- A 2-D game loop using
requestAnimationFramewith an accumulator for 10 FPS movement - Tile-based collision detection (wall + self)
- Keyboard input handling with direction guards
- Food spawning + eating + growth logic (the head-add/tail-drop trick)
- Score tracking + localStorage persistence
- A Game Over overlay + restart mechanic
Every one of those is a core game dev pattern. You'll see them again in bigger games, different engines, different languages.
What does the accumulator pattern in the game loop do?