JavaScript Game Tutorial

How to Make a Snake Game in JavaScript (HTML5 Canvas Tutorial)

Snake is the perfect first browser game: one grid, one array and one loop. This tutorial rebuilds the Supagames Snake from scratch and explains every decision, so you can extend it with your own rules afterwards.

BeginnerJavaScriptHTML5 Canvas15 min read

Production context

This guide studies Snake, a published Supagames game. Repository source: games/lvl01/01-snake.html.

Play a few rounds first. The finished game is under 200 lines of plain JavaScript with no libraries, and every snippet below comes from that file, so you can open the page source and compare as you go.

You only need a text editor and a browser. Create an index.html with a canvas element, add a script tag, and build the game one system at a time: grid, snake, loop, input, food, collisions and drawing.

1. Set Up the Canvas and the Grid

Snake does not move in pixels, it moves in tiles. Pick a tile size, divide the canvas by it and keep every game position in grid coordinates. A 400 by 400 canvas with 20 pixel tiles gives a 20 by 20 board, which is small enough to read and large enough to grow a long snake.

Keeping logic in grid units means collision checks are simple integer comparisons. You only convert to pixels when drawing, by multiplying a grid coordinate by the tile size.

<canvas id="gameCanvas" width="400" height="400"></canvas>
<script>
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");

const gridSize = 20;                       // pixels per tile
const tileCount = canvas.width / gridSize; // 20 x 20 board

let snake = [{ x: 10, y: 10 }];  // head is always snake[0]
let food = { x: 5, y: 5 };
let dx = 1, dy = 0;              // moving right
let score = 0;
let gameOver = false;
</script>

2. Move the Snake by Adding a Head and Removing the Tail

The snake is an array of segments with the head at index 0. Moving does not shift every segment. Instead, create a new head one tile ahead, put it at the front with unshift, and remove the last segment with pop. The body follows automatically because each segment keeps its old position.

Growing is the same move without the pop. When the new head lands on food, skip removing the tail and the snake becomes one segment longer. That single if statement is the whole growth system.

function update() {
  if (gameOver) return;
  const head = { x: snake[0].x + dx, y: snake[0].y + dy };

  // collisions are covered in a later step
  snake.unshift(head);

  if (head.x === food.x && head.y === food.y) {
    score += 10;
    scoreEl.textContent = score;
    spawnFood();          // ate: keep the tail, the snake grows
  } else {
    snake.pop();          // normal step: drop the tail
  }
  draw();
}

3. Run the Game on a Fixed Tick

Snake is a turn-based game that looks real-time. The board only changes once per tick, so the loop does not need to run at 60 frames per second. The Supagames version calls update every 120 milliseconds with setInterval, which is easy to read and perfectly fine for a grid game.

If you later want smooth animation or a speed that increases with the score, switch to requestAnimationFrame with an accumulator. The simulation still advances in fixed steps, but you control the step length every frame.

// Simple version used by the game:
let gameLoop = setInterval(update, 120);

// Accumulator version for variable speed:
let last = performance.now();
let acc = 0;
function frame(now) {
  acc += now - last;
  last = now;
  const stepMs = Math.max(60, 120 - Math.floor(score / 50) * 10);
  while (acc >= stepMs) {
    update();
    acc -= stepMs;
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

4. Queue Arrow Keys So the Snake Cannot Reverse Into Itself

The classic Snake bug: the snake moves right, the player presses Up and then Left inside the same tick. If you only check the current direction, both presses are accepted, the snake turns back into its own neck and dies instantly.

The fix is a small input queue. Each key press is compared with the last queued direction, not the current one, reversals and repeats are ignored, and update consumes at most one turn per tick. It also makes fast inputs feel responsive because a second turn is remembered for the next tick instead of being lost.

const inputQueue = [];

function queueTurn(nx, ny) {
  const last = inputQueue.length ? inputQueue[inputQueue.length - 1] : { x: dx, y: dy };
  if (nx === -last.x && ny === -last.y) return; // no 180 degree turns
  if (nx === last.x && ny === last.y) return;   // ignore repeats
  if (inputQueue.length < 2) inputQueue.push({ x: nx, y: ny });
}

document.addEventListener("keydown", (e) => {
  const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
  if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(key)) e.preventDefault();
  if (key === "ArrowUp" || key === "w") queueTurn(0, -1);
  if (key === "ArrowDown" || key === "s") queueTurn(0, 1);
  if (key === "ArrowLeft" || key === "a") queueTurn(-1, 0);
  if (key === "ArrowRight" || key === "d") queueTurn(1, 0);
});

// first line of update():
if (inputQueue.length) ({ x: dx, y: dy } = inputQueue.shift());

5. Spawn Food Only on Free Tiles

Food must never appear inside the snake. A common shortcut is to pick a random tile and try again if it is occupied, but that retry loop gets slower as the snake fills the board and never ends when the board is full.

A sturdier approach is to collect every free tile and pick one of them. With a 20 by 20 board this is only 400 checks, cheap enough to run on every meal, and an empty list tells you the player has filled the whole board.

function spawnFood() {
  const free = [];
  for (let x = 0; x < tileCount; x++) {
    for (let y = 0; y < tileCount; y++) {
      if (!snake.some((s) => s.x === x && s.y === y)) free.push({ x, y });
    }
  }
  if (free.length === 0) {
    gameOver = true;      // the snake filled the board: a perfect game
    return;
  }
  food = free[Math.floor(Math.random() * free.length)];
}

6. Detect Wall and Self Collisions

Check collisions on the new head before adding it to the array. A wall hit means the head left the 0 to tileCount range on either axis. A self hit means any existing segment already occupies the head tile.

There is one design choice here. Because the tail moves away on a normal step, some versions allow the head to enter the tile the tail is leaving. The Supagames version is stricter and treats it as a crash, which is easier to understand when you are learning. Either rule is fine, as long as it is consistent.

const head = { x: snake[0].x + dx, y: snake[0].y + dy };

const hitWall = head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount;
const hitSelf = snake.some((s) => s.x === head.x && s.y === head.y);

if (hitWall || hitSelf) {
  gameOver = true;
  draw();         // draw once more to show the game over screen
  return;
}

7. Draw Everything From the Game State

The draw function should never decide anything. It reads the snake, the food and the gameOver flag and paints them. Clearing the whole canvas each tick is simpler and fast enough, so you do not need to erase individual tiles.

Small touches make a big difference: rounded segments, a one pixel gap between tiles so the body reads as segments, and a glow on the food. They are pure drawing choices and do not touch the game logic.

function draw() {
  ctx.fillStyle = "#0f0f23";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  snake.forEach((seg, i) => {
    ctx.fillStyle = i === 0 ? "#ffd93d" : "#6bcb77";
    ctx.fillRect(seg.x * gridSize + 1, seg.y * gridSize + 1, gridSize - 2, gridSize - 2);
  });

  ctx.fillStyle = "#ff6b6b";
  ctx.beginPath();
  ctx.arc(food.x * gridSize + gridSize / 2, food.y * gridSize + gridSize / 2, 7, 0, Math.PI * 2);
  ctx.fill();

  if (gameOver) {
    ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#fff";
    ctx.font = "bold 36px sans-serif";
    ctx.textAlign = "center";
    ctx.fillText("GAME OVER", canvas.width / 2, canvas.height / 2);
  }
}

8. Add Swipe Controls for Phones

A keyboard-only Snake is unplayable on a phone. Swipe controls take a dozen lines: remember where the touch started, compare it with where it ended, and turn along the longer axis. A minimum distance filters out accidental taps.

Feed swipes into the same queueTurn function as the keyboard, so both inputs share the reversal protection. Setting touch-action to none on the canvas stops the page from scrolling while the player swipes.

canvas.style.touchAction = "none";
let touchStart = null;

canvas.addEventListener("touchstart", (e) => {
  const t = e.changedTouches[0];
  touchStart = { x: t.clientX, y: t.clientY };
}, { passive: true });

canvas.addEventListener("touchend", (e) => {
  if (!touchStart) return;
  const t = e.changedTouches[0];
  const moveX = t.clientX - touchStart.x;
  const moveY = t.clientY - touchStart.y;
  touchStart = null;
  if (gameOver) return init();
  if (Math.max(Math.abs(moveX), Math.abs(moveY)) < 24) return; // a tap, not a swipe
  if (Math.abs(moveX) > Math.abs(moveY)) queueTurn(Math.sign(moveX), 0);
  else queueTurn(0, Math.sign(moveY));
});

9. Build checklist

  • Store positions in grid units and convert to pixels only when drawing.
  • Move by adding a head and removing the tail; skip the removal to grow.
  • Queue direction changes so two quick keys cannot reverse the snake.
  • Spawn food from a list of free tiles instead of retrying random positions.
  • Check walls and self collisions on the new head before inserting it.
  • Share one input function between keyboard and swipe controls.

10. FAQ

How long does it take to make Snake in JavaScript?

A playable version is roughly 100 to 150 lines and takes an hour or two if you follow this guide. Polishing input, mobile controls, speed curves and visuals is where the extra time goes.

Do I need a game engine or library to build Snake?

No. The Canvas 2D API, a keyboard listener and a timer are enough. Snake is a good project for learning game loops precisely because it needs no framework.

Should I use setInterval or requestAnimationFrame?

setInterval is fine for a fixed-speed grid game. Use requestAnimationFrame with an accumulator when you want the speed to change during play or want to animate between tiles smoothly.

How do I make the snake faster as the score grows?

Shorten the step length as the score rises, for example 120 milliseconds minus 10 for every 50 points with a lower limit of 60. With setInterval you need to clear and restart the timer; the accumulator loop picks up the new value automatically.

Previous: Flying Bird tutorial Next: How to make 2048