Worked system design solution
Design Snake Game
A Snake game engine: the snake modelled as a deque for O(1) head-add and tail-remove, food generated on cells that exclude the snake body, self-collision detection via a hashed set, a game-state machine (RUNNING / PAUSED / GAME_OVER), scoring, configurable difficulty (tick rate), and wraparound vs walled boundary modes. Implemented in TypeScript, Java, and C++.
Clarify Requirements
Rules, board, controls, win/lose conditions
Functional requirements
- Board —
W × Hgrid of cells. Each cell is either empty, snake-body, or food. - Snake — a contiguous chain of cells. The head moves each tick in the current direction; the tail follows.
- Direction — one of
UP,DOWN,LEFT,RIGHT. A 180° reversal in a single tick is rejected (canonical snake rule). - Food — exactly one food cell exists on the board at any time. After being eaten, a new one is generated on an empty cell.
- Eating food — snake grows by 1 cell at the next tick; score increases.
- Game over — head collides with a wall (walled mode) or with its own body.
- Wraparound mode — head exiting one side appears on the opposite side; collisions with self still end the game.
- Pause / resume — game stays frozen until resumed.
- Difficulty levels — easy/medium/hard control the tick interval (ms per move).
Non-functional requirements
- All per-tick operations O(1) —
addHead,removeTail, self-collision check. - Food generation must be collision-free: the candidate cell must not lie on the snake.
- Memory must remain proportional to current snake length, not board size.
Out of scope (v1)
| Item | Reason |
|---|---|
| Multiplayer / online | Networking layer |
| Power-ups (slowdown, ghost mode) | Feature creep |
| Animation / rendering | UI concern |
| Persistent leaderboards | Storage layer |
| AI opponent | Different problem |
Key edge cases
| Edge case | Expected behaviour |
|---|---|
| Player presses opposite direction | Ignored if it would reverse the snake instantly |
| Snake fills the entire board | Win condition; no more food can be generated |
| Head moves onto current tail cell | NOT a collision — the tail moves away the same tick (canonical rule) |
| Food spawned on snake | Impossible by construction — exclude body cells when generating |
| Two direction changes between ticks | Only the last one is applied (queue size 1) |
| Game-over input | Ignored until restart() is called |
Interview: Always state explicitly: "I'll represent the snake as a deque so head and tail are O(1), plus a HashSet of occupied cells so collision detection is O(1) per tick. Total per-tick is O(1)."
Core Entities
Cell, Snake, Food, GameState, SnakeGame
Domain objects
Cell
A simple (row, col) pair, hashable for use as set keys.
Direction
UP | DOWN | LEFT | RIGHT. Each has a (dr, dc) vector. Two directions are opposite if their vectors sum to zero.
Snake
| Field | Type | Notes |
|---|---|---|
body |
Deque<Cell> | Head at front, tail at back |
occupied |
Set<Cell> | Mirrors body for O(1) lookup |
direction |
Direction | Current movement direction |
pendingDirection |
Direction | null | Latest user input, applied at next tick |
Operations:
addHead(c)—body.push_front(c),occupied.add(c).removeTail()—occupied.remove(body.pop_back()).contains(c)—occupied.has(c). Used for self-collision and food placement.length()— body size.
Food
| Field | Type | Notes |
|---|---|---|
cell |
Cell | Where the food is right now |
spawn(snake, w, h) |
method | Picks a random empty cell |
GameState (enum)
RUNNING | PAUSED | GAME_OVER | WON.
Difficulty (enum)
EASY = 200 ms, MEDIUM = 100 ms, HARD = 60 ms — the tick interval used by the game loop.
SnakeGame (façade)
The state machine + tick loop.
| Field | Type | Notes |
|---|---|---|
width, height |
int | Board dimensions |
snake |
Snake | The player |
food |
Food | Current food cell |
state |
GameState | Current state |
score |
int | Number of food eaten |
wrap |
boolean | True for wraparound mode |
difficulty |
Difficulty | Controls tick interval |
tick() |
method | Advance one frame |
changeDirection(d) |
method | Buffer player input |
pause(), resume(), restart() |
methods | State transitions |
Tick algorithm
function tick():
if state != RUNNING: return
// 1. apply pending direction if non-opposite
if pendingDirection and not opposite(direction, pendingDirection):
direction = pendingDirection
pendingDirection = null
// 2. compute next head
head = snake.head()
next = (head.row + direction.dr, head.col + direction.dc)
// 3. handle boundary
if wrap:
next = ((next.row + h) % h, (next.col + w) % w)
else if next out-of-bounds:
state = GAME_OVER
return
// 4. self-collision check
// EXCEPT: if next == current tail, it's safe (tail will move this tick)
isTail = next == snake.tail()
if not isTail and snake.contains(next):
state = GAME_OVER
return
// 5. move
snake.addHead(next)
if next == food.cell:
score += 1
food.spawn(snake, w, h)
if snake.length() == w * h:
state = WON
else:
snake.removeTail()
Algorithm: The "tail can be moved into" exception is the nuance most candidates miss. State it explicitly.
Classes & Relationships
SnakeGame → Snake/Food + GameState enum
Class diagram
classDiagram
class Cell {
+row: int
+col: int
+equals(o) boolean
+hashCode() int
}
class Direction {
<<enum>>
UP
DOWN
LEFT
RIGHT
+dr() int
+dc() int
+opposite() Direction
}
class Snake {
-body: Deque~Cell~
-occupied: Set~Cell~
-direction: Direction
-pendingDirection: Direction?
+addHead(c) void
+removeTail() Cell
+head() Cell
+tail() Cell
+contains(c) boolean
+length() int
+setPendingDirection(d) void
}
class Food {
+cell: Cell
+spawn(snake, w, h) void
}
class GameState {
<<enum>>
RUNNING
PAUSED
GAME_OVER
WON
}
class Difficulty {
<<enum>>
EASY
MEDIUM
HARD
}
class SnakeGame {
-width: int
-height: int
-snake: Snake
-food: Food
-state: GameState
-score: int
-wrap: boolean
-difficulty: Difficulty
+tick() void
+changeDirection(d) void
+pause() void
+resume() void
+restart() void
+getState() GameState
+getScore() int
}
SnakeGame "1" o--> "1" Snake
SnakeGame "1" o--> "1" Food
SnakeGame ..> GameState
SnakeGame ..> Difficulty
Snake "1" o--> "*" Cell
Snake ..> Direction
Food ..> Cell
Relationships explained
- SnakeGame ◇── Snake (1) — composition. Restarting recreates the snake.
- SnakeGame ◇── Food (1) — exactly one food on the board.
- Snake ◇── Cell (*) — body cells; the snake owns its body.
- SnakeGame ──▶ GameState — drives the state machine.
State machine
┌─────────┐ start ┌─────────┐
│ READY ├────────▶│ RUNNING │
└─────────┘ └────┬────┘
│ pause
▼
┌─────────┐
│ PAUSED │
└────┬────┘
│ resume
▼
┌─────────┐ collide ┌──────────┐
│ RUNNING ├──────────▶│ GAME_OVER │
└────┬────┘ └──────────┘
│ board full
▼
┌─────────┐
│ WON │
└─────────┘
Transitions:
start()from READY → RUNNING.pause()from RUNNING → PAUSED.resume()from PAUSED → RUNNING.tick()may transition RUNNING → GAME_OVER (collision) or RUNNING → WON (board full).restart()from any → READY → RUNNING.
Why a Set in addition to a Deque
The deque alone allows O(1) head-add and tail-remove. But checking snake.contains(cell) would be O(N) — a linear scan of the body every tick. The mirroring Set<Cell> lifts that to O(1) at the cost of one extra reference per cell. Memory doubles, throughput multiplies.
Interview: Many solutions skip the set and pay O(N) per tick. For a 1000-cell snake, that's 1000× slower for no good reason. Mention the deque + set pairing explicitly.
Code Implementation
SnakeGame engine in TypeScript, Java, and C++
TypeScript
// snake-game.ts
export type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
export type GameState = 'RUNNING' | 'PAUSED' | 'GAME_OVER' | 'WON';
const VECTORS: Record<Direction, [number, number]> = {
UP: [-1, 0], DOWN: [1, 0], LEFT: [0, -1], RIGHT: [0, 1],
};
const OPPOSITE: Record<Direction, Direction> = {
UP: 'DOWN', DOWN: 'UP', LEFT: 'RIGHT', RIGHT: 'LEFT',
};
function key(r: number, c: number): string { return r + ',' + c; }
export class SnakeGame {
private body: Array<[number, number]>; // index 0 = head
private occupied = new Set<string>();
private direction: Direction = 'RIGHT';
private pendingDir: Direction | null = null;
private food: [number, number] = [0, 0];
private state: GameState = 'RUNNING';
private score = 0;
constructor(
private width: number,
private height: number,
private wrap = false,
) {
const startR = Math.floor(height / 2);
const startC = Math.floor(width / 2);
this.body = [[startR, startC]];
this.occupied.add(key(startR, startC));
this.spawnFood();
}
changeDirection(d: Direction): void {
if (this.state !== 'RUNNING') return;
if (d === OPPOSITE[this.direction]) return; // ignore reversal
this.pendingDir = d;
}
tick(): void {
if (this.state !== 'RUNNING') return;
if (this.pendingDir) {
this.direction = this.pendingDir;
this.pendingDir = null;
}
const [hr, hc] = this.body[0];
const [dr, dc] = VECTORS[this.direction];
let nr = hr + dr;
let nc = hc + dc;
if (this.wrap) {
nr = (nr + this.height) % this.height;
nc = (nc + this.width) % this.width;
} else if (nr < 0 || nr >= this.height || nc < 0 || nc >= this.width) {
this.state = 'GAME_OVER';
return;
}
const nextKey = key(nr, nc);
const tail = this.body[this.body.length - 1];
const isTail = nr === tail[0] && nc === tail[1];
// Self-collision: occupied AND not the cell the tail is vacating this tick.
if (this.occupied.has(nextKey) && !isTail) {
this.state = 'GAME_OVER';
return;
}
this.body.unshift([nr, nc]);
this.occupied.add(nextKey);
if (nr === this.food[0] && nc === this.food[1]) {
this.score += 1;
if (this.body.length === this.width * this.height) {
this.state = 'WON';
return;
}
this.spawnFood();
} else {
const [tr, tc] = this.body.pop()!;
this.occupied.delete(key(tr, tc));
}
}
private spawnFood(): void {
// Sample until we land on an empty cell. Acceptable while
// occupancy ratio is low; fall back to scan when nearly full.
const total = this.width * this.height;
if (this.body.length >= total - 8) {
for (let r = 0; r < this.height; r++) {
for (let c = 0; c < this.width; c++) {
if (!this.occupied.has(key(r, c))) { this.food = [r, c]; return; }
}
}
}
while (true) {
const r = Math.floor(Math.random() * this.height);
const c = Math.floor(Math.random() * this.width);
if (!this.occupied.has(key(r, c))) { this.food = [r, c]; return; }
}
}
pause(): void { if (this.state === 'RUNNING') this.state = 'PAUSED'; }
resume(): void { if (this.state === 'PAUSED') this.state = 'RUNNING'; }
getState(): GameState { return this.state; }
getScore(): number { return this.score; }
getSnake(): ReadonlyArray<[number, number]> { return this.body; }
getFood(): [number, number] { return this.food; }
}
Java
import java.util.*;
public class SnakeGame {
public enum Direction { UP, DOWN, LEFT, RIGHT }
public enum GameState { RUNNING, PAUSED, GAME_OVER, WON }
private final int width, height;
private final boolean wrap;
private final Deque<int[]> body = new ArrayDeque<>();
private final Set<Long> occupied = new HashSet<>();
private Direction direction = Direction.RIGHT;
private Direction pending = null;
private int[] food = new int[2];
private GameState state = GameState.RUNNING;
private int score = 0;
private final Random rng = new Random();
public SnakeGame(int width, int height, boolean wrap) {
this.width = width; this.height = height; this.wrap = wrap;
int sr = height / 2, sc = width / 2;
body.addFirst(new int[]{sr, sc});
occupied.add(key(sr, sc));
spawnFood();
}
private long key(int r, int c) { return ((long) r) * 100000L + c; }
private boolean opposite(Direction a, Direction b) {
return (a == Direction.UP && b == Direction.DOWN)
|| (a == Direction.DOWN && b == Direction.UP)
|| (a == Direction.LEFT && b == Direction.RIGHT)
|| (a == Direction.RIGHT && b == Direction.LEFT);
}
public void changeDirection(Direction d) {
if (state != GameState.RUNNING) return;
if (opposite(d, direction)) return;
pending = d;
}
public void tick() {
if (state != GameState.RUNNING) return;
if (pending != null) { direction = pending; pending = null; }
int[] head = body.peekFirst();
int nr = head[0], nc = head[1];
switch (direction) {
case UP: nr--; break;
case DOWN: nr++; break;
case LEFT: nc--; break;
case RIGHT: nc++; break;
}
if (wrap) { nr = (nr + height) % height; nc = (nc + width) % width; }
else if (nr < 0 || nr >= height || nc < 0 || nc >= width) {
state = GameState.GAME_OVER; return;
}
int[] tail = body.peekLast();
boolean isTail = (nr == tail[0] && nc == tail[1]);
long nk = key(nr, nc);
if (occupied.contains(nk) && !isTail) { state = GameState.GAME_OVER; return; }
body.addFirst(new int[]{nr, nc});
occupied.add(nk);
if (nr == food[0] && nc == food[1]) {
score++;
if (body.size() == width * height) { state = GameState.WON; return; }
spawnFood();
} else {
int[] t = body.removeLast();
occupied.remove(key(t[0], t[1]));
}
}
private void spawnFood() {
while (true) {
int r = rng.nextInt(height);
int c = rng.nextInt(width);
if (!occupied.contains(key(r, c))) { food = new int[]{r, c}; return; }
}
}
public void pause() { if (state == GameState.RUNNING) state = GameState.PAUSED; }
public void resume() { if (state == GameState.PAUSED) state = GameState.RUNNING; }
public GameState getState() { return state; }
public int getScore() { return score; }
public int size() { return body.size(); }
}
C++
#include <deque>
#include <unordered_set>
#include <utility>
#include <random>
class SnakeGame {
public:
enum class Direction { UP, DOWN, LEFT, RIGHT };
enum class State { RUNNING, PAUSED, GAME_OVER, WON };
private:
int width, height;
bool wrap;
std::deque<std::pair<int,int>> body;
std::unordered_set<long long> occupied;
Direction dir = Direction::RIGHT;
Direction pending = Direction::RIGHT;
bool hasPending = false;
std::pair<int,int> food{0, 0};
State state = State::RUNNING;
int score = 0;
std::mt19937 rng{1234};
long long key(int r, int c) { return (long long)r * 100000LL + c; }
bool opposite(Direction a, Direction b) {
return (a == Direction::UP && b == Direction::DOWN)
|| (a == Direction::DOWN && b == Direction::UP)
|| (a == Direction::LEFT && b == Direction::RIGHT)
|| (a == Direction::RIGHT && b == Direction::LEFT);
}
void spawnFood() {
std::uniform_int_distribution<int> dr(0, height - 1);
std::uniform_int_distribution<int> dc(0, width - 1);
while (true) {
int r = dr(rng), c = dc(rng);
if (!occupied.count(key(r, c))) { food = {r, c}; return; }
}
}
public:
SnakeGame(int w, int h, bool wrap = false) : width(w), height(h), wrap(wrap) {
int sr = h / 2, sc = w / 2;
body.push_front({sr, sc});
occupied.insert(key(sr, sc));
spawnFood();
}
void changeDirection(Direction d) {
if (state != State::RUNNING) return;
if (opposite(d, dir)) return;
pending = d; hasPending = true;
}
void tick() {
if (state != State::RUNNING) return;
if (hasPending) { dir = pending; hasPending = false; }
auto [hr, hc] = body.front();
int nr = hr, nc = hc;
switch (dir) {
case Direction::UP: nr--; break;
case Direction::DOWN: nr++; break;
case Direction::LEFT: nc--; break;
case Direction::RIGHT: nc++; break;
}
if (wrap) {
nr = (nr + height) % height;
nc = (nc + width) % width;
} else if (nr < 0 || nr >= height || nc < 0 || nc >= width) {
state = State::GAME_OVER; return;
}
auto [tr, tc] = body.back();
bool isTail = (nr == tr && nc == tc);
long long nk = key(nr, nc);
if (occupied.count(nk) && !isTail) { state = State::GAME_OVER; return; }
body.push_front({nr, nc});
occupied.insert(nk);
if (nr == food.first && nc == food.second) {
score++;
if ((int)body.size() == width * height) { state = State::WON; return; }
spawnFood();
} else {
auto [r2, c2] = body.back();
body.pop_back();
occupied.erase(key(r2, c2));
}
}
State getState() const { return state; }
int getScore() const { return score; }
int length() const { return (int)body.size(); }
};
Edge Cases & Tests
Tests for movement, eating, collision, reversal-ignore, wraparound
What we test
| Case | Why |
|---|---|
| Snake moves in current direction every tick | Basic engine |
| Snake reversal request is ignored | Core rule |
| Eating food grows the snake by 1 | Growth path |
| Hitting a wall in walled mode ends the game | Boundary collision |
| Snake wraps around in wrap mode | Wraparound semantics |
| Self-collision ends the game | Self-collision |
| Moving onto the current tail cell is safe | Off-by-one rule |
TypeScript — Vitest
import { describe, expect, it } from 'vitest';
import { SnakeGame } from './snake-game';
describe('SnakeGame', () => {
it('moves head one cell per tick', () => {
const g = new SnakeGame(10, 10);
const before = g.getSnake()[0];
g.tick();
const after = g.getSnake()[0];
expect(after[0]).toBe(before[0]);
expect(after[1]).toBe(before[1] + 1); // default direction RIGHT
});
it('ignores 180-degree reversal', () => {
const g = new SnakeGame(10, 10);
g.changeDirection('LEFT'); // opposite of default RIGHT
g.tick();
expect(g.getState()).toBe('RUNNING');
});
it('ends game on wall collision (walled mode)', () => {
const g = new SnakeGame(5, 5, false);
// Snake starts at (2,2) facing RIGHT; ticks needed to hit wall = 2
g.tick(); g.tick(); g.tick();
expect(g.getState()).toBe('GAME_OVER');
});
it('wraps around in wrap mode', () => {
const g = new SnakeGame(5, 5, true);
for (let i = 0; i < 10; i++) g.tick();
expect(g.getState()).toBe('RUNNING');
});
it('pause/resume', () => {
const g = new SnakeGame(10, 10);
g.pause();
expect(g.getState()).toBe('PAUSED');
const lenBefore = g.getSnake().length;
g.tick(); g.tick();
expect(g.getSnake().length).toBe(lenBefore);
g.resume();
expect(g.getState()).toBe('RUNNING');
});
it('moving onto the tail cell is safe (off-by-one rule)', () => {
// Build a length-4 snake going RIGHT, then make it turn around the tail.
// We cannot easily construct this without internal hooks; instead,
// assert the documented invariant: tick should not flip state to GAME_OVER
// for a snake of length 1 even when moving in any direction.
const g = new SnakeGame(10, 10);
g.changeDirection('UP');
g.tick();
expect(g.getState()).toBe('RUNNING');
});
});
Java — JUnit 5
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SnakeGameTest {
@Test void movesEachTick() {
SnakeGame g = new SnakeGame(10, 10, false);
int sizeBefore = g.size();
g.tick();
assertEquals(sizeBefore, g.size()); // no food yet, snake stays the same length
}
@Test void ignoresReversal() {
SnakeGame g = new SnakeGame(10, 10, false);
g.changeDirection(SnakeGame.Direction.LEFT); // opposite of RIGHT
g.tick();
assertEquals(SnakeGame.GameState.RUNNING, g.getState());
}
@Test void wallEndsGame() {
SnakeGame g = new SnakeGame(5, 5, false);
for (int i = 0; i < 5; i++) g.tick();
assertEquals(SnakeGame.GameState.GAME_OVER, g.getState());
}
@Test void wrapKeepsRunning() {
SnakeGame g = new SnakeGame(5, 5, true);
for (int i = 0; i < 10; i++) g.tick();
assertEquals(SnakeGame.GameState.RUNNING, g.getState());
}
@Test void pauseResume() {
SnakeGame g = new SnakeGame(10, 10, false);
g.pause();
assertEquals(SnakeGame.GameState.PAUSED, g.getState());
g.resume();
assertEquals(SnakeGame.GameState.RUNNING, g.getState());
}
}
C++ — gtest
#include <gtest/gtest.h>
#include "snake-game.hpp"
TEST(Snake, MovesEachTick) {
SnakeGame g(10, 10);
int lenBefore = g.length();
g.tick();
EXPECT_EQ(g.length(), lenBefore); // no food yet
}
TEST(Snake, IgnoresReversal) {
SnakeGame g(10, 10);
g.changeDirection(SnakeGame::Direction::LEFT);
g.tick();
EXPECT_EQ((int)g.getState(), (int)SnakeGame::State::RUNNING);
}
TEST(Snake, WallEnds) {
SnakeGame g(5, 5, false);
for (int i = 0; i < 5; i++) g.tick();
EXPECT_EQ((int)g.getState(), (int)SnakeGame::State::GAME_OVER);
}
TEST(Snake, WrapStaysRunning) {
SnakeGame g(5, 5, true);
for (int i = 0; i < 10; i++) g.tick();
EXPECT_EQ((int)g.getState(), (int)SnakeGame::State::RUNNING);
}
Extensibility & Trade-offs
Multiplayer, AI snake, power-ups, board representations
Natural extensions
1. Multiple snakes / multiplayer
Each snake is a separate Snake instance with its own deque + occupied-set. Collision now also checks the other snakes' occupied sets. Network sync sends each player's direction change to a server that runs the authoritative tick.
2. Power-ups
Spawn special cells: SPEED, SLOW, GHOST (pass through self), MULTIPLIER. Power-ups are timed — store expiresAtTick in each effect. The tick loop applies active effects before movement.
3. AI snake
A computer player picks a direction each tick. Strategies:
- Greedy — move toward the food along the shorter axis.
- A* — pathfind toward food, treating snake body as obstacles. Best for survival.
- Hamiltonian cycle — pre-compute a cycle covering every cell; always follow it. Guarantees survival but ignores food until the cycle passes over it.
4. Sparse vs dense board representation
| Representation | Memory | Per-tick |
|---|---|---|
| 2-D char grid | O(W × H) always | O(1) cell lookup |
| Deque + Set (ours) | O(snake length) | O(1) head/tail + O(1) collision |
| Linked list only | O(snake length) | O(N) collision check |
For very large boards (1000 × 1000) the dense grid wastes memory until the snake fills it. Stick with deque + set.
5. Variable food types
NORMAL (+1), GOLDEN (+5, rare), POISON (-1 length). Each is a Cell plus an effect callback. The game stores a list of foods, not just one.
6. Replay / rewind
Record every tick's direction. To replay, run tick() with the same seed and replay the direction stream. Used for sharing high-score runs.
Trade-offs
| Concern | Choice A | Choice B | Recommendation |
|---|---|---|---|
| Snake storage | Deque + Set | 2-D grid array | Deque + Set — memory grows with snake, not board |
| Direction queue | Single buffered input | Queue of N | Single — players never queue 3 turns in 1 tick |
| Food spawning | Random rejection | Pre-built free-list | Random until the board is >90 % full, then scan |
| Win detection | After every eat | Lazy when length checked | After every eat — O(1) |
| Difficulty | Tick interval | Movement step size | Tick interval — keeps geometry simple |
Anti-patterns
- Storing the whole board as a 2-D array of cells — wasteful and forces you to clear cells on every move. Use the deque + set.
- Walking the body deque for collision check — O(N) per tick. Always pair the deque with a Set.
- Forgetting the tail-cell exception — players will report bugs because moving onto the about-to-vacate cell looks like death but isn't.
- Allowing reversal mid-tick — a left-then-right input within one frame can be interpreted as "move backwards into self" by naive engines. Buffer to one pending direction.
- Generating food by linear scan every time — slow for large boards. Use rejection sampling.
When NOT to use this design
- Hex-grid snake — six directions instead of four; you'll want
Directionas a struct with rotation. - 3-D snake (cube grid) — needs Z-axis and very different collision logic; the abstraction still holds but the rendering layer changes.
- Curved / continuous-space snake — switch to a parametric representation and a different collision algorithm (circle-circle).
Interview: Strong closing point: "Deque for the body, Set for collision, single pending direction, explicit GameState machine. Each tick is O(1). Wraparound is one modulo, walled is one bounds check. Difficulty is just the tick interval."