DEV Community

SEN LLC
SEN LLC

Posted on

Snake With a Greedy Auto-Play AI and Immutable Game State

Snake With a Greedy Auto-Play AI and Immutable Game State

The entire game engine is six pure functions operating on an immutable state object. tick advances one frame, changeDirection prevents 180° reversals, and getAutoDirection implements a greedy AI that picks the move closest to the food while avoiding walls and its own body.

Snake is one of those games that's deceptively simple to describe but has a few tricky details: how to handle the growth tick (do you pop the tail before or after adding the head?), preventing the player from reversing into themselves, and placing food that never overlaps the snake body.

🔗 Live demo: https://sen.ltd/portfolio/snake/
📦 GitHub: https://github.com/sen-ltd/snake

Screenshot

Features:

  • Canvas 2D rendering with 3 themes (Classic, Retro, Neon)
  • Arrow keys + WASD + touch/swipe
  • Auto-play AI mode (A key)
  • Speed increases every 5 food items
  • High score (localStorage)
  • Japanese / English UI
  • Zero dependencies, 34 tests

The state object

All game state lives in one plain object:

{
  snake: [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }],
  direction: 'right',
  pendingDirection: 'right',
  food: { x: 15, y: 7 },
  score: 0,
  speed: 8,
  gameOver: false,
  growing: false,
  gridWidth: 20,
  gridHeight: 20,
}
Enter fullscreen mode Exit fullscreen mode

Every function returns a new state. No mutation. This makes the game trivially testable — pass in a state, get back a state, assert on the result.

The tick function

Each frame, tick does five things in order:

  1. Commit pendingDirection to direction
  2. Compute new head position
  3. Check wall collision → game over
  4. Check self collision (against all segments except tail, since the tail will move away)
  5. Check food → grow or pop tail
const bodyToCheck = state.snake.slice(0, state.snake.length - 1);
if (bodyToCheck.some(seg => seg.x === newHead.x && seg.y === newHead.y)) {
  return { ...state, direction, gameOver: true };
}
Enter fullscreen mode Exit fullscreen mode

The self-collision check excludes the tail because on the next frame the tail moves forward. Without this exclusion, certain valid moves would be incorrectly flagged as collisions.

180° reversal prevention

If the snake is moving right and the player presses left, ignoring it is the expected behavior:

export function changeDirection(state, newDir) {
  const opposites = { up: 'down', down: 'up', left: 'right', right: 'left' };
  if (opposites[newDir] === state.direction) return state;
  return { ...state, pendingDirection: newDir };
}
Enter fullscreen mode Exit fullscreen mode

The pendingDirection vs direction split matters: the pending direction is committed at the start of tick, not immediately. This prevents a race condition where rapid key presses between frames could bypass the reversal check.

Greedy auto-play AI

The AI evaluates all valid directions, excludes reversals and lethal moves, then picks the one with the shortest Manhattan distance to food:

export function getAutoDirection(state) {
  for (const dir of dirs) {
    if (opposites[dir] === direction) continue;
    const next = moveHead(head, dir);
    if (next.x < 0 || next.x >= gridWidth || ...) continue;
    if (occupied.has(`${next.x},${next.y}`)) continue;
    const dist = Math.abs(next.x - food.x) + Math.abs(next.y - food.y);
    // pick lowest distance
  }
}
Enter fullscreen mode Exit fullscreen mode

It's intentionally simple — a greedy heuristic, not a pathfinding algorithm. It will eventually trap itself on longer games, which is part of the fun to watch. A Hamilton cycle solver would be more correct but far less interesting to observe.

Food placement

Food must not overlap any snake segment:

export function placeFood(state) {
  const occupied = new Set(state.snake.map(s => `${s.x},${s.y}`));
  let x, y;
  do {
    x = Math.floor(Math.random() * state.gridWidth);
    y = Math.floor(Math.random() * state.gridHeight);
  } while (occupied.has(`${x},${y}`));
  return { ...state, food: { x, y } };
}
Enter fullscreen mode Exit fullscreen mode

For a 20×20 grid with a typical snake length under 50, the rejection sampling loop terminates almost instantly. For very long snakes approaching grid capacity, the function detects the occupied.size >= total edge case and keeps the current position.

Tests

34 test cases covering all six exported functions.

Series

This is entry #36 in my 100+ public portfolio series.

Top comments (0)