4.3 Collision Detection
In a terminal game on a grid, collision detection is simple: two things collide if they occupy the same grid cell. This is called AABB (Axis-Aligned Bounding Box) detection — and when your world is discretized to a grid, it reduces to a single equality check.
Grid-Based Collision
Section titled “Grid-Based Collision”type Vec2 = { x: number; y: number };
function sameCell(a: Vec2, b: Vec2): boolean { return a.x === b.x && a.y === b.y;}That’s it. No square roots, no rectangles overlapping, no SAT (Separating Axis Theorem). Grid games get collision for free.
Collision Types in Snake
Section titled “Collision Types in Snake”Snake has three collision types to check after the snake moves:
| Collision | What it means |
|---|---|
| Head hits wall | Game over |
| Head hits snake body | Game over |
| Head hits food | Grow + spawn new food + increase score |
import { type Vec2 } from './types';
export type CollisionResult = | { type: 'none' } | { type: 'wall' } | { type: 'self' } | { type: 'food' };
export function checkCollision( head: Vec2, body: Vec2[], // all snake segments except the head food: Vec2, gridW: number, gridH: number,): CollisionResult { // Wall collision — head is outside the playfield if (head.x <= 0 || head.x >= gridW - 1 || head.y <= 0 || head.y >= gridH - 1) { return { type: 'wall' }; }
// Self collision — head occupies a body segment for (const segment of body) { if (head.x === segment.x && head.y === segment.y) { return { type: 'self' }; } }
// Food collision if (head.x === food.x && head.y === food.y) { return { type: 'food' }; }
return { type: 'none' };}Why Check After Moving
Section titled “Why Check After Moving”The collision check happens after computing the new head position, but before applying it to game state. This is the standard game loop order:
1. Compute newHead = head + direction2. checkCollision(newHead, ...)3. If collision: - wall/self → game over - food → grow (don't remove tail), spawn new food4. If no collision: - move: add newHead to front of snake, remove tailSpawning Food
Section titled “Spawning Food”Food must spawn at an empty cell. The naive approach — pick a random position and retry if occupied — works fine for most of the game. Only when the snake nearly fills the board does it become slow:
function spawnFood(snake: Vec2[], gridW: number, gridH: number): Vec2 { // Build set of occupied positions for fast lookup const occupied = new Set(snake.map(s => `${s.x},${s.y}`));
let pos: Vec2; do { pos = { x: 1 + Math.floor(Math.random() * (gridW - 2)), y: 1 + Math.floor(Math.random() * (gridH - 2)), }; } while (occupied.has(`${pos.x},${pos.y}`));
return pos;}For a proper implementation that handles a nearly-full board without infinite looping:
function spawnFoodSafe(snake: Vec2[], gridW: number, gridH: number): Vec2 | null { const occupied = new Set(snake.map(s => `${s.x},${s.y}`)); const empty: Vec2[] = [];
for (let y = 1; y < gridH - 1; y++) { for (let x = 1; x < gridW - 1; x++) { if (!occupied.has(`${x},${y}`)) empty.push({ x, y }); } }
if (empty.length === 0) return null; // board full, player wins return empty[Math.floor(Math.random() * empty.length)];}Snake Movement: The Dequeue Trick
Section titled “Snake Movement: The Dequeue Trick”A snake moves by adding a head and removing the tail — unless it just ate food, in which case the tail stays. The data structure is a dequeue (double-ended queue): fast push to front, fast pop from back.
JavaScript arrays support this natively:
type Snake = Vec2[]; // snake[0] = head, snake[last] = tail
function moveSnake(snake: Snake, direction: Vec2, grew: boolean): Snake { const newHead = { x: snake[0].x + direction.x, y: snake[0].y + direction.y, };
const newSnake = [newHead, ...snake];
if (!grew) { newSnake.pop(); // remove tail }
return newSnake;}[newHead, ...snake] is O(n) for a spread — for typical Snake game lengths (max ~100 segments on a small grid) this is negligible. For a 100,000-segment snake you would want a linked list.
Anti-Reverse
Section titled “Anti-Reverse”A classic Snake rule: you cannot reverse direction into yourself. Implement this as an input filter:
type Direction = { x: number; y: number };
function isOpposite(a: Direction, b: Direction): boolean { return a.x === -b.x && a.y === -b.y;}
// In your input handler:if (!isOpposite(newDirection, state.snake.direction)) { pendingDirection = newDirection;}Without this, pressing Left when moving Right would immediately collide the head with the second body segment.
Putting It Together
Section titled “Putting It Together”The full update cycle for Snake:
function update(state: GameState): GameState { if (state.status !== 'playing') return state;
// Apply queued direction (with anti-reverse guard) let dir = state.snake.direction; if (state.pendingDir && !isOpposite(state.pendingDir, dir)) { dir = state.pendingDir; }
const newHead = { x: state.snake.body[0].x + dir.x, y: state.snake.body[0].y + dir.y }; const collision = checkCollision(newHead, state.snake.body.slice(1), state.food, GRID_W, GRID_H);
switch (collision.type) { case 'wall': case 'self': return { ...state, status: 'game-over' };
case 'food': { const newBody = [newHead, ...state.snake.body]; // grow: no tail removal const newFood = spawnFood(newBody, GRID_W, GRID_H); return { ...state, snake: { body: newBody, direction: dir }, food: newFood, score: state.score + 10, pendingDir: null, }; }
case 'none': { const newBody = [newHead, ...state.snake.body.slice(0, -1)]; // move: remove tail return { ...state, snake: { body: newBody, direction: dir }, pendingDir: null, }; } }}This is the complete logic for the Snake game update. Clean, pure, testable — no side effects, just state in → state out.
The next lesson assembles all of this into the full game.