Skip to content

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.

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.

Snake has three collision types to check after the snake moves:

CollisionWhat it means
Head hits wallGame over
Head hits snake bodyGame over
Head hits foodGrow + spawn new food + increase score
examples/module-4/collision.ts
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' };
}

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 + direction
2. checkCollision(newHead, ...)
3. If collision:
- wall/self → game over
- food → grow (don't remove tail), spawn new food
4. If no collision:
- move: add newHead to front of snake, remove tail

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)];
}

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.

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.

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.