DEV Community

SkyWalker
SkyWalker

Posted on

Deterministic Snake Movement on a Wrapping JavaScript Grid

Grid-based snake movement is a good example of a system that should be deterministic even when rendering is smooth. If movement depends directly on animation-frame timing, a slow frame can skip cells, collision behavior changes across devices, and replay tests become unreliable.

The solution is to separate the fixed simulation step from visual interpolation.

Model positions as integer cells

Keep the authoritative snake body as integer coordinates.

const state = {
  width: 24,
  height: 18,
  direction: { x: 1, y: 0 },
  body: [
    { x: 8, y: 7 },
    { x: 7, y: 7 },
    { x: 6, y: 7 }
  ],
  food: { x: 15, y: 11 }
};
Enter fullscreen mode Exit fullscreen mode

Pixels belong only in the renderer. Collision checks, food placement, scoring, and replay data should all use cells.

Advance at a fixed rate

An accumulator lets the simulation run in exact ticks even when requestAnimationFrame is irregular.

const stepMs = 100;
let previous = performance.now();
let accumulator = 0;

function frame(now) {
  accumulator += Math.min(now - previous, 250);
  previous = now;

  while (accumulator >= stepMs) {
    update();
    accumulator -= stepMs;
  }

  render(accumulator / stepMs);
  requestAnimationFrame(frame);
}
Enter fullscreen mode Exit fullscreen mode

Capping the added time prevents a tab that was asleep for a minute from executing hundreds of updates at once.

Wrap coordinates without negative-modulo bugs

JavaScript's remainder operator can return a negative number. A reusable wrap helper avoids a special case on every edge.

function wrap(value, size) {
  return ((value % size) + size) % size;
}

function nextHead(head, direction, board) {
  return {
    x: wrap(head.x + direction.x, board.width),
    y: wrap(head.y + direction.y, board.height)
  };
}
Enter fullscreen mode Exit fullscreen mode

For a hard-wall variant, replace wrapping with a bounds check. Keeping this policy in one function makes game modes easy to compare.

Queue turns between ticks

Fast keyboard input can contain two turns before the next simulation step. Store a small queue, but reject an immediate reversal that would send the head into the second segment.

const turnQueue = [];

function isOpposite(a, b) {
  return a.x + b.x === 0 && a.y + b.y === 0;
}

function queueTurn(nextDirection) {
  const last = turnQueue.at(-1) ?? state.direction;
  if (!isOpposite(last, nextDirection) && turnQueue.length < 2) {
    turnQueue.push(nextDirection);
  }
}

function update() {
  state.direction = turnQueue.shift() ?? state.direction;
  const head = nextHead(state.body[0], state.direction, state);

  const ate = head.x === state.food.x && head.y === state.food.y;
  const bodyToCheck = ate ? state.body : state.body.slice(0, -1);
  const hitSelf = bodyToCheck.some(p => p.x === head.x && p.y === head.y);

  if (hitSelf) return endGame();

  state.body.unshift(head);
  if (!ate) state.body.pop();
  else state.food = placeFood(state);
}
Enter fullscreen mode Exit fullscreen mode

Notice that moving into the current tail cell is legal when the snake is not growing, because the tail leaves on the same tick.

For comparing different browser snake layouts and control styles, this collection of snake games provides several third-party examples in one place.

Deterministic food placement

Build the list of free cells and choose from it using a seedable random generator. That makes replays and failing tests reproducible. It also avoids an unbounded “keep guessing until a cell is free” loop when the board is nearly full.

Useful tests include crossing every edge, two queued turns, reversal attempts, eating on the next tick, entering the previous tail position, filling the final free cell, and pausing with a partially filled accumulator.

With cell-based state and fixed ticks, rendering can be as smooth as desired without changing the rules. The same recorded input sequence will produce the same board on a fast laptop, a slow phone, or a headless test runner.

Top comments (0)