Skip to content
poozee

Browser Gaming

How to Code Snake in JavaScript: A Step-by-Step Tutorial

Code Snake in JavaScript with one HTML file and a canvas: food that never lands on the snake, no reverse-into-yourself bug, a real win, and a bot that plays.

By Poozee Editors8 min read

To code Snake in JavaScript, keep the snake as a list of squares (head first), and on every step add a new head in the direction of travel and drop the tail, unless the snake just ate. Draw it on a <canvas>, read the arrow keys into a short queue of turns, and drive the steps from requestAnimationFrame with a time budget. The complete game below is one HTML file of 277 lines of plain JavaScript, HTML and CSS, with no libraries.

It is built from the same rules as Poozee's Snake: an 18x18 board, one apple at a time, and a win when the snake fills every square. At the end you'll add a bot that plays the game by itself and never loses.

Five bugs common in Snake tutorials

We read three popular Snake tutorials from the search results for this topic. Each one had at least two of these bugs, none of them ended the game when the board fills, and all three moved the snake with setTimeout or setInterval.

Bug The fix in this tutorial
Food can spawn under the snake Pick from a list of empty squares (step 3)
Two quick turns reverse the snake into itself, or the second key is ignored Queue turns, one per step (step 5)
Chasing your own tail counts as a crash The tail's square is free unless the snake eats (step 4)
No win: a full board crashes, freezes or keeps going End the game at 324 squares (step 4)
Steps on a timer: uneven speed, out of step with the screen Step on elapsed time in each animation frame (step 8)

Every code block on this page is run by an automated test. It plays hundreds of games with this code and with the engine behind Poozee's game, side by side, and checks that the snake, the apple and the score match after every single step.

Step 1: The page

Create a file called snake.html. The canvas is the board and the paragraph under it shows the score and what to press. The script goes at the end of the body, so the canvas exists by the time it runs.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Snake</title>
  <style>
    body {
      margin: 0;
      padding: 16px;
      background: #fdf6e3;
      color: #222;
      font-family: system-ui, sans-serif;
      text-align: center;
    }
    canvas {
      display: block;
      box-sizing: border-box;
      width: 100%;
      max-width: 436px;
      margin: 0 auto;
      border: 2px solid #222;
      touch-action: none;
    }
  </style>
</head>
<body>
  <canvas id="board"></canvas>
  <p id="message"></p>
  <script>
// Steps 2 to 9 go here, in order.
  </script>
</body>
</html>

touch-action: none stops a swipe on the board from scrolling the page on a phone.

Step 2: The game state

The board is 18 squares by 18, each 24 pixels wide, so the canvas is 432 pixels square. The snake takes one step every 105 milliseconds, the speed of Classic mode on Poozee.

const GRID = 18; // squares per side
const CELL = 24; // pixels per square
const STEP_MS = 105; // time between steps

const DIRS = {
  up: { x: 0, y: -1 },
  down: { x: 0, y: 1 },
  left: { x: -1, y: 0 },
  right: { x: 1, y: 0 },
};
const OPPOSITE = {
  up: "down",
  down: "up",
  left: "right",
  right: "left",
};

let snake; // squares, head first
let dir; // the way the snake is moving
let queue; // turns waiting for the next steps
let food;
let score;
let status; // ready, playing, paused, over or won
let autopilot; // true while the bot plays (step 9)

function newGame() {
  const mid = Math.floor(GRID / 2);
  snake = [
    { x: mid - 3, y: mid },
    { x: mid - 4, y: mid },
    { x: mid - 5, y: mid },
  ];
  dir = "right";
  queue = [];
  score = 0;
  status = "ready";
  autopilot = false;
  food = placeFood();
}

Squares are { x, y } objects with (0, 0) at the top left, and y grows downward, like canvas pixels. The snake starts 3 squares long on the 10th row, heading right.

Step 3: Food that never lands on the snake

The usual version picks a random square and hopes it is empty. When it isn't, the apple hides under the body, and a "try again until it's empty" loop never ends once the board is full. Instead, list the empty squares and pick one of them:

function placeFood() {
  const free = [];
  for (let y = 0; y < GRID; y++) {
    for (let x = 0; x < GRID; x++) {
      const taken = snake.some(
        (p) => p.x === x && p.y === y);
      if (!taken) free.push({ x, y });
    }
  }
  const i = Math.floor(Math.random() * free.length);
  return free[i];
}

Every empty square is equally likely, and the apple can never appear under the snake. Checking all 324 squares for each apple is far too little work to notice.

Step 4: Move one step, crash or win

This is the heart of the game. Work out the new head, then decide whether the tail moves:

function step() {
  if (queue.length > 0) dir = queue.shift();
  const v = DIRS[dir];
  const head = {
    x: snake[0].x + v.x,
    y: snake[0].y + v.y,
  };
  const eats = head.x === food.x && head.y === food.y;
  // The tail leaves its square on this step,
  // unless the snake eats (then it grows).
  const body = eats ? snake : snake.slice(0, -1);
  const hitsWall = head.x < 0 || head.y < 0 ||
    head.x >= GRID || head.y >= GRID;
  const hitsSelf = body.some(
    (p) => p.x === head.x && p.y === head.y);
  if (hitsWall || hitsSelf) {
    status = "over";
    return;
  }
  snake = [head, ...body];
  if (!eats) return;
  score += 1;
  if (snake.length === GRID * GRID) {
    status = "won";
  } else {
    food = placeFood();
  }
}

Three details here matter more than they look:

  1. The tail's square is free on this step. The tail moves at the same moment as the head, so the head may enter the square the tail is leaving, as it can on Poozee. If you test the new head against the whole old body, chasing your own tail kills you. With this rule, a 4-square snake can circle a 2x2 block forever: the test runs it for 1,000 steps. The one exception is a step that eats, when the tail stays put (that is how the snake grows), which is why eats is worked out before body.
  2. Check before you move. The new head is tested first, and the snake only changes if the step is legal. Code that moves first and checks on the next tick draws a frame with the head inside the wall or the body. Here, a crash leaves the snake exactly where it was, so the last frame shows what went wrong.
  3. There is a win. The snake starts at 3 squares and grows one per apple, so after 321 apples it covers all 324 squares. Then there is nowhere to put food, so the game must end there. In the step above, placeFood() only runs when an empty square exists.

Step 5: Turns: a queue, not a flag

Here is the classic bug. You're heading right and press Up, then Left, within one step. If each key is checked against dir and changes it on the spot, both pass: Up isn't the opposite of right, and Left isn't the opposite of up. The snake turns around into its own neck and dies. A common patch ignores every key after the first until the next step, which keeps the snake alive but throws away the second key.

The fix is to queue turns and check each new key against the last turn in the queue:

function turn(newDir) {
  const last = queue.length > 0
    ? queue[queue.length - 1] : dir;
  if (newDir === last) return;
  if (newDir === OPPOSITE[last]) return;
  if (queue.length >= 3) return;
  queue.push(newDir);
}

Now Up then Left becomes two moves on two steps, up and then left, a tight U-turn that players do on purpose. A third turn can wait too, but the queue stops at 3 so mashing keys doesn't leave the snake turning long after you let go. Poozee's game uses the same queue.

Step 6: Draw the board

Each frame paints the whole board: a light checkerboard so the squares are easy to count, the apple, then the snake with a darker head.

const canvas = document.getElementById("board");
const ctx = canvas.getContext("2d");
const message = document.getElementById("message");
canvas.width = GRID * CELL;
canvas.height = GRID * CELL;

const TEXT = {
  ready: "Press an arrow key or swipe to start.",
  playing: "Space pauses. B lets the bot play.",
  paused: "Paused. Press Space to go on.",
  over: "Game over. Press an arrow key to retry.",
  won: "You filled the board. You win!",
};

function square(p, color, inset) {
  const size = CELL - 2 * inset;
  ctx.fillStyle = color;
  ctx.fillRect(p.x * CELL + inset,
    p.y * CELL + inset, size, size);
}

function draw() {
  for (let y = 0; y < GRID; y++) {
    for (let x = 0; x < GRID; x++) {
      const light = (x + y) % 2 === 0;
      square({ x, y }, light ? "#cfe8d5" : "#c2dfc9", 0);
    }
  }
  if (status !== "won") square(food, "#e4572e", 3);
  snake.forEach((p, i) => {
    square(p, i === 0 ? "#1b3a8f" : "#3866d6", 1);
  });
  const text = `Score ${score}. ${TEXT[status]}`;
  if (message.textContent !== text) {
    message.textContent = text;
  }
}

The canvas's drawing size is set in code, and the CSS from step 1 scales it down to fit a phone.

Step 7: Keyboard and swipe

Arrow keys and W, A, S and D steer. The first of them you press starts a game, and Space pauses. preventDefault() stops the arrows and Space from scrolling the page while you play.

const KEYS = {
  arrowup: "up", w: "up",
  arrowdown: "down", s: "down",
  arrowleft: "left", a: "left",
  arrowright: "right", d: "right",
};

function press(newDir) {
  if (status === "paused") {
    status = "playing";
  } else if (status !== "playing") {
    newGame();
    status = "playing";
  }
  turn(newDir);
}

document.addEventListener("keydown", (e) => {
  if (e.ctrlKey || e.metaKey || e.altKey) return;
  const newDir = KEYS[e.key.toLowerCase()];
  if (newDir) {
    e.preventDefault();
    press(newDir);
  } else if (e.key === " ") {
    e.preventDefault();
    if (status === "playing") status = "paused";
    else if (status === "paused") status = "playing";
  }
});

let swipe = null;
canvas.addEventListener("pointerdown", (e) => {
  swipe = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener("pointerup", (e) => {
  if (!swipe) return;
  const dx = e.clientX - swipe.x;
  const dy = e.clientY - swipe.y;
  swipe = null;
  if (Math.max(Math.abs(dx), Math.abs(dy)) < 20) return;
  if (Math.abs(dx) > Math.abs(dy)) {
    press(dx > 0 ? "right" : "left");
  } else {
    press(dy > 0 ? "down" : "up");
  }
});

Lowercasing the key means WASD still works with Caps Lock on. On a phone, a swipe of at least 20 pixels steers, and a shorter touch counts as a tap and does nothing.

Step 8: The game loop

setInterval(step, 105) looks simpler, but a timer only promises to wait at least that long, so ticks can arrive late and unevenly. Browsers also slow timers down in background tabs, and drawing on a timer isn't in sync with the screen's refresh.

Instead, draw on every requestAnimationFrame and keep a running total of the real time that hasn't been turned into steps yet. Each time it reaches 105 ms, take a step and subtract 105 ms. Poozee's Snake runs the same loop.

let last = performance.now();
let lag = 0; // real time not yet turned into steps

function frame(now) {
  if (status === "playing") {
    // Cap the catch-up, so a stall can't fire
    // a burst of steps all at once.
    lag += Math.min(now - last, 250);
    while (lag >= STEP_MS && status === "playing") {
      // The bot from step 9 steers here.
      if (autopilot) queue = [loopDirection(snake[0])];
      step();
      lag -= STEP_MS;
    }
  } else {
    lag = 0;
  }
  last = now;
  draw();
  requestAnimationFrame(frame);
}

// Pause when the player switches tabs.
document.addEventListener("visibilitychange", () => {
  if (document.hidden && status === "playing") {
    status = "paused";
  }
});

newGame();
requestAnimationFrame(frame);

The speed no longer depends on the screen. Our test fed this loop 10 seconds of frames at 30, 60 and 144 frames per second, and at uneven gaps between 5 and 50 ms: the snake took exactly 95 steps every time, one per 105 ms. After a 5-second freeze, the cap allows at most 2 catch-up steps instead of a jump of 47 squares.

Save the file and open it: the game is complete and playable. One more step makes it play itself.

Step 9: Make it play itself

A snake that follows a fixed loop through every square of the board can never crash: its body always trails behind its head along the loop, and the square ahead is either empty or the tail's, which empties on the same step. The simplest loop is a lawnmower path. Run down the left column, then sweep the other columns row by row from the bottom up, turning at each wall. It needs an even number of rows, and 18 is even.

// The lawnmower loop: down the left column, then
// back and forth along the rows, bottom to top.
function rowsLoop() {
  const loop = [];
  for (let y = 0; y < GRID; y++) loop.push({ x: 0, y });
  for (let y = GRID - 1; y >= 0; y--) {
    for (let i = 1; i < GRID; i++) {
      const x = (GRID - y) % 2 === 1 ? i : GRID - i;
      loop.push({ x, y });
    }
  }
  return loop;
}

// NEXT[y][x]: the way from square (x, y) to the
// next square of the loop.
const LOOP = rowsLoop();
const NEXT = [];
for (let y = 0; y < GRID; y++) NEXT.push([]);
LOOP.forEach((p, i) => {
  const q = LOOP[(i + 1) % LOOP.length];
  NEXT[p.y][p.x] = Object.keys(DIRS).find((d) =>
    p.x + DIRS[d].x === q.x && p.y + DIRS[d].y === q.y);
});

function loopDirection(p) {
  return NEXT[p.y][p.x];
}

document.addEventListener("keydown", (e) => {
  if (e.ctrlKey || e.metaKey || e.altKey) return;
  if (e.key.toLowerCase() !== "b") return;
  newGame();
  autopilot = true;
  status = "playing";
});

Press B and the bot starts a new game. The starting snake already lies on the loop, heading the right way (row 10 runs left to right), so the bot can follow it from the first step. Any arrow key after a game ends hands control back to you.

How long does it take? Following any loop, the apple is on average half a lap of the empty squares away, which works out to exactly 26,001 steps per game on this board. In 10 test games of this exact code, the bot filled the board every time, in 25,861 steps on average (24,368 at the fastest, 27,601 at the slowest). That's about 45.5 minutes at 105 ms a step. To watch it finish, set STEP_MS to 1: the loop then runs 1,000 steps a second, and the board fills in about 26 seconds.

The bot is slow because it walks the whole loop even when the apple is right next to it. Our guide to how the perfect Snake AI works adds safe shortcuts that cut the time roughly in half, and how to win the snake game turns the same loop into a plan you can follow by hand.

The complete code

Here is the whole file: step 1's page with steps 2 to 9 pasted into the script, in order. Save it as snake.html and open it in a browser.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Snake</title>
  <style>
    body {
      margin: 0;
      padding: 16px;
      background: #fdf6e3;
      color: #222;
      font-family: system-ui, sans-serif;
      text-align: center;
    }
    canvas {
      display: block;
      box-sizing: border-box;
      width: 100%;
      max-width: 436px;
      margin: 0 auto;
      border: 2px solid #222;
      touch-action: none;
    }
  </style>
</head>
<body>
  <canvas id="board"></canvas>
  <p id="message"></p>
  <script>
const GRID = 18; // squares per side
const CELL = 24; // pixels per square
const STEP_MS = 105; // time between steps

const DIRS = {
  up: { x: 0, y: -1 },
  down: { x: 0, y: 1 },
  left: { x: -1, y: 0 },
  right: { x: 1, y: 0 },
};
const OPPOSITE = {
  up: "down",
  down: "up",
  left: "right",
  right: "left",
};

let snake; // squares, head first
let dir; // the way the snake is moving
let queue; // turns waiting for the next steps
let food;
let score;
let status; // ready, playing, paused, over or won
let autopilot; // true while the bot plays (step 9)

function newGame() {
  const mid = Math.floor(GRID / 2);
  snake = [
    { x: mid - 3, y: mid },
    { x: mid - 4, y: mid },
    { x: mid - 5, y: mid },
  ];
  dir = "right";
  queue = [];
  score = 0;
  status = "ready";
  autopilot = false;
  food = placeFood();
}

function placeFood() {
  const free = [];
  for (let y = 0; y < GRID; y++) {
    for (let x = 0; x < GRID; x++) {
      const taken = snake.some(
        (p) => p.x === x && p.y === y);
      if (!taken) free.push({ x, y });
    }
  }
  const i = Math.floor(Math.random() * free.length);
  return free[i];
}

function step() {
  if (queue.length > 0) dir = queue.shift();
  const v = DIRS[dir];
  const head = {
    x: snake[0].x + v.x,
    y: snake[0].y + v.y,
  };
  const eats = head.x === food.x && head.y === food.y;
  // The tail leaves its square on this step,
  // unless the snake eats (then it grows).
  const body = eats ? snake : snake.slice(0, -1);
  const hitsWall = head.x < 0 || head.y < 0 ||
    head.x >= GRID || head.y >= GRID;
  const hitsSelf = body.some(
    (p) => p.x === head.x && p.y === head.y);
  if (hitsWall || hitsSelf) {
    status = "over";
    return;
  }
  snake = [head, ...body];
  if (!eats) return;
  score += 1;
  if (snake.length === GRID * GRID) {
    status = "won";
  } else {
    food = placeFood();
  }
}

function turn(newDir) {
  const last = queue.length > 0
    ? queue[queue.length - 1] : dir;
  if (newDir === last) return;
  if (newDir === OPPOSITE[last]) return;
  if (queue.length >= 3) return;
  queue.push(newDir);
}

const canvas = document.getElementById("board");
const ctx = canvas.getContext("2d");
const message = document.getElementById("message");
canvas.width = GRID * CELL;
canvas.height = GRID * CELL;

const TEXT = {
  ready: "Press an arrow key or swipe to start.",
  playing: "Space pauses. B lets the bot play.",
  paused: "Paused. Press Space to go on.",
  over: "Game over. Press an arrow key to retry.",
  won: "You filled the board. You win!",
};

function square(p, color, inset) {
  const size = CELL - 2 * inset;
  ctx.fillStyle = color;
  ctx.fillRect(p.x * CELL + inset,
    p.y * CELL + inset, size, size);
}

function draw() {
  for (let y = 0; y < GRID; y++) {
    for (let x = 0; x < GRID; x++) {
      const light = (x + y) % 2 === 0;
      square({ x, y }, light ? "#cfe8d5" : "#c2dfc9", 0);
    }
  }
  if (status !== "won") square(food, "#e4572e", 3);
  snake.forEach((p, i) => {
    square(p, i === 0 ? "#1b3a8f" : "#3866d6", 1);
  });
  const text = `Score ${score}. ${TEXT[status]}`;
  if (message.textContent !== text) {
    message.textContent = text;
  }
}

const KEYS = {
  arrowup: "up", w: "up",
  arrowdown: "down", s: "down",
  arrowleft: "left", a: "left",
  arrowright: "right", d: "right",
};

function press(newDir) {
  if (status === "paused") {
    status = "playing";
  } else if (status !== "playing") {
    newGame();
    status = "playing";
  }
  turn(newDir);
}

document.addEventListener("keydown", (e) => {
  if (e.ctrlKey || e.metaKey || e.altKey) return;
  const newDir = KEYS[e.key.toLowerCase()];
  if (newDir) {
    e.preventDefault();
    press(newDir);
  } else if (e.key === " ") {
    e.preventDefault();
    if (status === "playing") status = "paused";
    else if (status === "paused") status = "playing";
  }
});

let swipe = null;
canvas.addEventListener("pointerdown", (e) => {
  swipe = { x: e.clientX, y: e.clientY };
});
canvas.addEventListener("pointerup", (e) => {
  if (!swipe) return;
  const dx = e.clientX - swipe.x;
  const dy = e.clientY - swipe.y;
  swipe = null;
  if (Math.max(Math.abs(dx), Math.abs(dy)) < 20) return;
  if (Math.abs(dx) > Math.abs(dy)) {
    press(dx > 0 ? "right" : "left");
  } else {
    press(dy > 0 ? "down" : "up");
  }
});

let last = performance.now();
let lag = 0; // real time not yet turned into steps

function frame(now) {
  if (status === "playing") {
    // Cap the catch-up, so a stall can't fire
    // a burst of steps all at once.
    lag += Math.min(now - last, 250);
    while (lag >= STEP_MS && status === "playing") {
      // The bot from step 9 steers here.
      if (autopilot) queue = [loopDirection(snake[0])];
      step();
      lag -= STEP_MS;
    }
  } else {
    lag = 0;
  }
  last = now;
  draw();
  requestAnimationFrame(frame);
}

// Pause when the player switches tabs.
document.addEventListener("visibilitychange", () => {
  if (document.hidden && status === "playing") {
    status = "paused";
  }
});

newGame();
requestAnimationFrame(frame);

// The lawnmower loop: down the left column, then
// back and forth along the rows, bottom to top.
function rowsLoop() {
  const loop = [];
  for (let y = 0; y < GRID; y++) loop.push({ x: 0, y });
  for (let y = GRID - 1; y >= 0; y--) {
    for (let i = 1; i < GRID; i++) {
      const x = (GRID - y) % 2 === 1 ? i : GRID - i;
      loop.push({ x, y });
    }
  }
  return loop;
}

// NEXT[y][x]: the way from square (x, y) to the
// next square of the loop.
const LOOP = rowsLoop();
const NEXT = [];
for (let y = 0; y < GRID; y++) NEXT.push([]);
LOOP.forEach((p, i) => {
  const q = LOOP[(i + 1) % LOOP.length];
  NEXT[p.y][p.x] = Object.keys(DIRS).find((d) =>
    p.x + DIRS[d].x === q.x && p.y + DIRS[d].y === q.y);
});

function loopDirection(p) {
  return NEXT[p.y][p.x];
}

document.addEventListener("keydown", (e) => {
  if (e.ctrlKey || e.metaKey || e.altKey) return;
  if (e.key.toLowerCase() !== "b") return;
  newGame();
  autopilot = true;
  status = "playing";
});
  </script>
</body>
</html>

Ideas for your next version

  • Speed levels. Poozee's game offers 150, 105 and 70 ms per step (Chill, Classic and Turbo). Make STEP_MS a let and add buttons.
  • A best score. Save it with localStorage.setItem when a game ends, and read it back on load.
  • Shortcuts for the bot. Let it skip ahead along the loop, but never past its own tail or the apple. The Hamiltonian cycle guide has that rule as code.

Pro tip: When something looks wrong, pause with Space and type snake, queue or food into the browser console. Top-level let variables in a classic script are visible there, so you can read the exact state, or even call step() by hand.

Keep playing

Compare your version with the real thing: a game of Snake on Poozee uses the same board, the same turn queue and the same win. Then try beating it yourself with our Snake high score tips, or follow the loop by hand with how to win the snake game.

Frequently asked questions

Do I need a library or framework to make Snake in JavaScript?

No. The whole game in this tutorial is one HTML file with a canvas and plain JavaScript. Save it, open it in any modern browser and it runs, with nothing to install.

Why does my snake die when I press two arrow keys quickly?

Your code checks each key against the direction the snake is moving now, so a fast Up then Left (while heading right) reverses it into its own neck before it takes a step. Check each key against the last turn you queued, and apply one queued turn per step.

Should I use setInterval or requestAnimationFrame for Snake?

Use requestAnimationFrame for drawing and count real elapsed time to decide when to step. A timer only promises a minimum delay, and browsers slow timers down in background tabs, so a game that steps on setInterval can run unevenly.

How do you win at Snake?

You win by filling every square. On an 18x18 board the snake starts 3 squares long, so it takes 321 apples. Your code needs a check for this, or the game has no ending except a crash.

How do I make the snake play by itself?

Give it a fixed loop that passes through every square once and steer it along that loop each step. It can never crash, and it always fills the board. Step 9 of this tutorial adds that bot in 36 lines of code.