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.
tickadvances one frame,changeDirectionprevents 180° reversals, andgetAutoDirectionimplements 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
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,
}
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:
- Commit
pendingDirectiontodirection - Compute new head position
- Check wall collision → game over
- Check self collision (against all segments except tail, since the tail will move away)
- 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 };
}
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 };
}
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
}
}
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 } };
}
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.
- 📦 Repo: https://github.com/sen-ltd/snake
- 🌐 Live: https://sen.ltd/portfolio/snake/
- 🏢 Company: https://sen.ltd/

Top comments (0)