Design the snake game¶
TL;DR¶
- You build a tick-driven engine: one
tick()moves the snake one cell, decides whether it died, and publishes a frame to whoever is rendering. - Three decisions carry the interview: the tail's cell is free the moment the tail leaves it (the rule most candidates get wrong), self-collision is O(1) because the snake keeps a deque and a set, and the input buffer validates each turn against the last queued direction, not the last applied one.
- Patterns that earn their place: State (four phase classes, unlike tic-tac-toe's enum), Strategy (food spawner, speed), Observer (renderer), Command (input). Template Method is deliberately not used.
Problem statement¶
"Design the snake game. On a W x H grid a snake moves one cell per tick in its current direction. Food appears on a free cell; eating it makes the snake one cell longer and scores a point. The game ends when the snake hits a wall, an obstacle or itself. The player can turn — but not by 180 degrees — and can pause and resume. The game speeds up as the score rises. Show me the classes, one tick in detail, and what happens when the player turns twice between two ticks."
Requirements¶
Functional
- A W x H grid with optional obstacles.
- A snake that moves one cell per tick in its current direction.
- Food on a free cell; eating it grows the snake by one and adds to the score.
- The game ends on a wall, an obstacle or the snake's own body; filling the grid ends it as a win.
- Direction changes that are neither a repeat nor a 180-degree reversal, buffered between ticks.
- Pause and resume; a tick while paused changes nothing.
- Speed scaling: the interval between ticks shortens as the score rises.
- A renderer that receives frames instead of polling the engine.
Non-functional and constraints
- One tick is O(1) in the snake's length: no scan of the body, no scan of the grid.
- Deterministic: the food generator is injected and seeded, and time comes from an injected
Clock. - Safe when input and the clock run on different threads.
- In-memory, single process, standard library only.
Out of scope: actual rendering, key bindings, high-score persistence, multiplayer.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Do the walls kill, or does the snake wrap around? | They kill. Wrap-around is a one-method change on Grid, noted under follow-ups. |
| Can the snake move into the cell its tail is leaving? | Yes, unless it is growing that tick. Ask this out loud; it is the rule the problem exists to test. |
| What happens to two key presses inside one tick? | Both are buffered, up to two, and each is validated against the previous queued direction. |
| Does the snake grow immediately on eating? | Yes: the head advances and the tail stays put for that tick. |
| Who drives the clock? | The caller. The engine exposes next_interval() and never sleeps, so tests are instant and a front end can use any loop. |
| What ends the game when the grid fills? | The spawner returns None, and the engine reports EndReason.FILLED. That is a win, not a crash. |
| Are obstacles static? | Yes, fixed at construction. Moving obstacles would be a second entity ticked before the snake. |
Core entities and relationships¶
- SnakeGame — the engine. It owns the world, the lock and the input buffer, and it exposes exactly three verbs:
tick,submitand the pause/resume pair. - GamePhase with
ReadyPhase,RunningPhase,PausedPhase,OverPhase— the State machine. Each phase answerstick,pauseandresumefor itself. - Grid — width, height and obstacles. It answers
contains,is_obstacleandfree_cells, and knows nothing about snakes. - Snake — a
dequefor order and asetfor membership. The set is the whole reason collision detection is O(1). - Point — a frozen
(x, y)cell, withygrowing downwards so thatUPis-1. - Direction — the four moves, each with a delta and an opposite.
reversesis one line and it is the guard the player feels. - Food — a frozen position plus its point value.
- FoodSpawner —
RandomFoodSpawner(seeded) andScriptedFoodSpawner(exact, for tests). - SpeedPolicy —
ConstantSpeedandAcceleratingSpeed. - TickResult and Frame — the immutable answers to "what happened" and "what does it look like".
- Renderer — the output port;
TextRendereris the in-memory implementation. - InputCommand with
Turn,Pause,Resume— one key press as an object.
Multiplicities: game 1 -> 1 grid, game 1 -> 1 snake, game 1 -> 0..1 food, game 1 -> 1 phase, game 1 -> 1 spawner, game 1 -> 1 speed policy, game 1 -> * renderers.
Class diagram¶
The world: one engine, one grid, one snake, and the two policies that vary.
classDiagram
class SnakeGame {
+grid Grid
+snake Snake
+state GameState
+score int
+tick() TickResult
+submit(command) bool
+change_direction(direction) bool
+pause() GameState
+resume() GameState
+advance() TickResult
+render() str
}
class Grid {
+width int
+height int
+obstacles frozenset
+contains(point) bool
+is_obstacle(point) bool
+free_cells(occupied) List~Point~
}
class Snake {
+head Point
+tail Point
+next_head(direction) Point
+would_collide(target, grow) bool
+move(target, grow) Point
+cells() tuple
}
class Point {
+x int
+y int
+step(direction) Point
}
class Food {
+position Point
+value int
}
class TickResult {
+tick int
+moved bool
+ate bool
+score int
+reason EndReason
}
class Frame {
+tick int
+score int
+board str
}
class FoodSpawner {
<<interface>>
+spawn(grid, occupied) Food
}
class RandomFoodSpawner
class ScriptedFoodSpawner
class SpeedPolicy {
<<interface>>
+interval(score, length) float
}
class AcceleratingSpeed
class Renderer {
<<interface>>
+on_frame(frame)
}
class TextRenderer {
+frames() tuple
+latest() Frame
}
SnakeGame "1" *-- "1" Grid
SnakeGame "1" *-- "1" Snake
SnakeGame "1" o-- "0..1" Food
SnakeGame --> FoodSpawner
SnakeGame --> SpeedPolicy
SnakeGame --> "many" Renderer : publishes
SnakeGame ..> TickResult : returns
SnakeGame ..> Frame : publishes
Snake "1" o-- "many" Point
FoodSpawner <|.. RandomFoodSpawner
FoodSpawner <|.. ScriptedFoodSpawner
SpeedPolicy <|.. AcceleratingSpeed
Renderer <|.. TextRenderer
The State machine and the input commands: the two places where behaviour is an object.
classDiagram
class GamePhase {
<<abstract>>
+state GameState
+tick(game) TickResult
+pause(game) GamePhase
+resume(game) GamePhase
}
class ReadyPhase
class RunningPhase
class PausedPhase
class OverPhase {
+reason EndReason
}
class SnakeGame {
+transition(phase)
}
class InputCommand {
<<interface>>
+apply(game) bool
}
class Turn {
+direction Direction
}
class Pause
class Resume
class Direction {
<<enumeration>>
+delta tuple
+opposite Direction
+reverses(other) bool
}
GamePhase <|-- ReadyPhase
GamePhase <|-- RunningPhase
GamePhase <|-- PausedPhase
GamePhase <|-- OverPhase
SnakeGame "1" *-- "1" GamePhase
InputCommand <|.. Turn
InputCommand <|.. Pause
InputCommand <|.. Resume
Turn --> Direction
InputCommand ..> SnakeGame : applies to
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| State | GamePhase with four subclasses |
A tick means three different things: start and advance, do nothing, or raise. Behaviour differs per state, which is exactly the test for State classes over an enum. Contrast with tic-tac-toe, where the status only labels the game and an enum is enough. |
| Strategy | FoodSpawner, SpeedPolicy |
"Make it harder" is a SpeedPolicy; "make food appear near the snake" is a FoodSpawner. Both are the difficulty knob interviewers reach for, and both keep the tick free of tuning constants. |
| Observer | Renderer and TextRenderer |
The engine publishes Frames and imports no display code. A curses front end, a websocket and the test renderer all implement one method. |
| Command | InputCommand with Turn, Pause, Resume |
A key press becomes an object, which is what makes input queueable, loggable and replayable. Recording the commands is a complete replay format. |
| Value objects | Point, Food, TickResult, Frame |
Frozen and comparable, so a test asserts game.food == Food(Point(6, 0)) rather than reading three fields. |
| Dependency injection | Clock, the seeded generator, both policies |
Nothing calls time.time() or the global random. The demo runs a full game in microseconds of wall time and 1.28 s of game time. |
What was deliberately not used: Template Method. The BoardGame base that tic-tac-toe and snake and ladder share is built around turns and players; this game has neither. Forcing it in would mean a one-player rotation and a choose_move that is really "read the input buffer" — ceremony that hides the tick. Say that out loud: knowing when not to reuse your own abstraction is the same judgement as knowing when to build it.
Key flows¶
One tick: buffered turn, wall, obstacle, self, move, eat, publish.
sequenceDiagram
autonumber
actor P as Player
participant G as SnakeGame
participant H as RunningPhase
participant S as Snake
participant D as Grid
participant F as RandomFoodSpawner
participant R as TextRenderer
P->>G: submit(Turn(UP))
G->>G: buffer it unless it repeats or reverses
P->>G: tick()
G->>H: tick(game)
H->>G: advance()
G->>S: next_head(direction)
S-->>G: target cell
G->>D: contains and is_obstacle
D-->>G: on the board, not blocked
G->>S: would_collide(target, grow)
S-->>G: False, the tail leaves this cell
G->>S: move(target, grow)
opt the snake ate
G->>F: spawn(grid, occupied)
F-->>G: Food or None
end
G-)R: on_frame, delivered outside the lock
G-->>P: TickResult
Game state. Four phases, and each one answers tick differently — which is the argument for State classes rather than an enum and a chain of ifs.
stateDiagram-v2
[*] --> READY
READY --> RUNNING : first tick
RUNNING --> RUNNING : tick moves the snake
RUNNING --> PAUSED : pause
PAUSED --> PAUSED : tick changes nothing
PAUSED --> RUNNING : resume
RUNNING --> OVER : wall, obstacle, self or grid filled
OVER --> [*]
Implementation¶
Write the vocabulary first, then the snake — the two methods on it are the whole problem — then the phases, then the engine.
Direction.reverses and the four end reasons are the rule vocabulary. Naming EndReason.FILLED early tells the interviewer you have thought about the win condition, which most candidates never reach:
class GameState(StrEnum):
READY = "ready"
RUNNING = "running"
PAUSED = "paused"
OVER = "over"
class EndReason(StrEnum):
WALL = "wall"
SELF = "self"
OBSTACLE = "obstacle"
FILLED = "filled" # no free cell left: the snake filled the grid and won
class Direction(StrEnum):
UP = "up"
DOWN = "down"
LEFT = "left"
RIGHT = "right"
@property
def delta(self) -> tuple[int, int]:
return DELTAS[self]
@property
def opposite(self) -> Direction:
return OPPOSITES[self]
def reverses(self, other: Direction) -> bool:
return self is other.opposite
class GameOverError(InvalidStateError):
"""The game has already ended; there is nothing left to tick."""
Geometry and the immutable results. y grows downwards, so UP is a delta of -1; say it once and you will not flip a sign later:
@dataclass(frozen=True, slots=True, order=True)
class Point:
"""A grid cell. ``y`` grows downwards, so ``UP`` is ``-1`` on ``y``."""
x: int
y: int
def step(self, direction: Direction) -> Point:
dx, dy = direction.delta
return Point(self.x + dx, self.y + dy)
def __str__(self) -> str:
return f"({self.x},{self.y})"
DELTAS: Final[Mapping[Direction, tuple[int, int]]] = MappingProxyType(
{
Direction.UP: (0, -1),
Direction.DOWN: (0, 1),
Direction.LEFT: (-1, 0),
Direction.RIGHT: (1, 0),
}
)
OPPOSITES: Final[Mapping[Direction, Direction]] = MappingProxyType(
{
Direction.UP: Direction.DOWN,
Direction.DOWN: Direction.UP,
Direction.LEFT: Direction.RIGHT,
Direction.RIGHT: Direction.LEFT,
}
)
@dataclass(frozen=True, slots=True)
class Food:
position: Point
value: int = 1
@dataclass(frozen=True, slots=True)
class TickResult:
"""What one tick did. Immutable, so it can be pushed to observers and stored."""
tick: int
state: GameState
moved: bool
ate: bool
score: int
reason: EndReason | None = None
@dataclass(frozen=True, slots=True)
class Frame:
"""One rendered picture plus the numbers a UI puts around it."""
tick: int
state: GameState
score: int
board: str
The grid answers three questions and knows nothing about snakes. free_cells returns row-major order so that a seeded spawner is reproducible run to run:
@dataclass(slots=True)
class Grid:
"""Bounds and obstacles. It knows geometry, never the snake."""
width: int
height: int
obstacles: frozenset[Point] = field(default_factory=frozenset)
def __post_init__(self) -> None:
if self.width < 3 or self.height < 3:
raise ValidationError(f"grid must be at least 3x3, got {self.width}x{self.height}")
outside = [p for p in self.obstacles if not self.contains(p)]
if outside:
raise ValidationError(f"obstacles outside the grid: {sorted(outside)}")
def contains(self, point: Point) -> bool:
return 0 <= point.x < self.width and 0 <= point.y < self.height
def is_obstacle(self, point: Point) -> bool:
return point in self.obstacles
def free_cells(self, occupied: Iterable[Point]) -> list[Point]:
"""Row-major order, so a seeded spawner is reproducible across runs."""
taken = set(occupied) | self.obstacles
return [
Point(x, y) for y in range(self.height) for x in range(self.width) if Point(x, y) not in taken
]
This is the class the problem is really about. Two containers, kept in step by a single mutating method, and the collision rule that costs candidates the interview:
class Snake:
"""A deque for order and a set for membership: O(1) push, pop and self-collision.
The set is what makes the collision test O(1) instead of O(length). Keeping the
two containers in step is the whole contract of this class, so every mutation
goes through ``move``.
"""
def __init__(self, body: Sequence[Point]) -> None:
if not body:
raise ValidationError("a snake needs at least one cell")
if len(set(body)) != len(body):
raise ValidationError("a snake cannot start on top of itself")
self._body: deque[Point] = deque(body)
self._cells: set[Point] = set(body)
@property
def head(self) -> Point:
return self._body[0]
@property
def tail(self) -> Point:
return self._body[-1]
def __len__(self) -> int:
return len(self._body)
def occupies(self, point: Point) -> bool:
return point in self._cells
def cells(self) -> tuple[Point, ...]:
return tuple(self._body)
def next_head(self, direction: Direction) -> Point:
return self.head.step(direction)
def would_collide(self, target: Point, grow: bool) -> bool:
"""Self-collision, with the rule everyone gets wrong.
The tail's cell is *free* this tick, because the tail leaves it in the same
move - unless the snake is growing, in which case the tail stays put.
"""
if target not in self._cells:
return False
return grow or target != self.tail
def move(self, target: Point, grow: bool) -> Point | None:
"""Push the new head, drop the tail unless growing. Returns the vacated cell."""
if self.would_collide(target, grow):
raise InvalidStateError(f"{target} is occupied by the snake")
vacated: Point | None = None
if not grow:
vacated = self._body.pop()
self._cells.discard(vacated)
self._body.appendleft(target)
self._cells.add(target)
return vacated
Both policies are one-method protocols. ScriptedFoodSpawner is what makes every test below exact instead of statistical:
class FoodSpawner(Protocol):
"""Returns the next piece of food, or None when the grid has no free cell left."""
def spawn(self, grid: Grid, occupied: Iterable[Point]) -> Food | None: ...
class RandomFoodSpawner:
"""Uniform over the free cells. The generator is injected, so a game replays exactly."""
def __init__(self, rng: random.Random, value: int = 1) -> None:
if value < 1:
raise ValidationError("food must be worth at least one point")
self._rng = rng
self._value = value
def spawn(self, grid: Grid, occupied: Iterable[Point]) -> Food | None:
free = grid.free_cells(occupied)
if not free:
return None
return Food(self._rng.choice(free), self._value)
class ScriptedFoodSpawner:
"""A queue of positions. This is what turns a snake game into a unit test."""
def __init__(self, positions: Iterable[Point], value: int = 1) -> None:
self._queue: deque[Point] = deque(positions)
self._value = value
def spawn(self, grid: Grid, occupied: Iterable[Point]) -> Food | None:
taken = set(occupied)
while self._queue:
position = self._queue.popleft()
if grid.contains(position) and position not in taken and not grid.is_obstacle(position):
return Food(position, self._value)
return None
class SpeedPolicy(Protocol):
"""Seconds between ticks. The engine asks; it never computes a difficulty itself."""
def interval(self, score: int, length: int) -> float: ...
class ConstantSpeed:
def __init__(self, seconds: float = 0.15) -> None:
if seconds <= 0:
raise ValidationError("a tick interval must be positive")
self._seconds = seconds
def interval(self, score: int, length: int) -> float:
return self._seconds
class AcceleratingSpeed:
"""Every point shortens the interval by ``factor``, down to a floor.
With the defaults a game starts at 200 ms per tick and reaches the 60 ms floor
after 12 points: 0.2 x 0.9^12 = 0.056, clamped to 0.06.
"""
def __init__(self, base: float = 0.2, factor: float = 0.9, floor: float = 0.06) -> None:
if not (0 < factor <= 1) or base <= 0 or floor <= 0 or floor > base:
raise ValidationError("need 0 < factor <= 1 and 0 < floor <= base")
self._base = base
self._factor = factor
self._floor = floor
def interval(self, score: int, length: int) -> float:
return max(self._floor, self._base * self._factor**score)
Now the State machine. Each phase is a handful of lines, and the engine never asks what state it is in:
class GamePhase(ABC):
"""State pattern: the phase decides what tick, pause and resume mean.
Here it earns its place, unlike the enum-plus-guards in tic-tac-toe: a paused
tick is a no-op, a running tick advances the world, and a finished tick is an
error. Three different behaviours behind one call, with no ``if state ==``
ladder anywhere in the engine.
"""
state: GameState
def tick(self, game: SnakeGame) -> TickResult:
raise InvalidStateError(f"cannot tick a game that is {self.state}")
def pause(self, game: SnakeGame) -> GamePhase:
raise InvalidStateError(f"cannot pause a game that is {self.state}")
def resume(self, game: SnakeGame) -> GamePhase:
raise InvalidStateError(f"cannot resume a game that is {self.state}")
class ReadyPhase(GamePhase):
state = GameState.READY
def tick(self, game: SnakeGame) -> TickResult:
game.transition(RunningPhase())
return game.phase.tick(game)
class RunningPhase(GamePhase):
state = GameState.RUNNING
def tick(self, game: SnakeGame) -> TickResult:
return game.advance()
def pause(self, game: SnakeGame) -> GamePhase:
return PausedPhase()
def resume(self, game: SnakeGame) -> GamePhase:
return self
class PausedPhase(GamePhase):
state = GameState.PAUSED
def tick(self, game: SnakeGame) -> TickResult:
return game.idle() # a tick while paused moves nothing and costs nothing
def pause(self, game: SnakeGame) -> GamePhase:
return self
def resume(self, game: SnakeGame) -> GamePhase:
return RunningPhase()
class OverPhase(GamePhase):
state = GameState.OVER
def __init__(self, reason: EndReason) -> None:
self.reason = reason
def tick(self, game: SnakeGame) -> TickResult:
raise GameOverError(f"the game ended: {self.reason}")
The engine. advance is the method to write on the whiteboard first: wall, obstacle, self, move, eat, respawn, in that order. change_direction is where the input-buffer subtlety lives:
class SnakeGame:
"""One tick moves the world forward by one cell. Everything else serves that method.
``_lock`` guards the snake, the food, the score, the phase and the input buffer.
It exists because input and the clock are different threads in every real front
end: a key press must not land halfway through a tick.
"""
MAX_BUFFERED_INPUTS = 2
def __init__(
self,
grid: Grid,
snake: Snake,
spawner: FoodSpawner,
direction: Direction = Direction.RIGHT,
*,
speed: SpeedPolicy | None = None,
clock: Clock | None = None,
) -> None:
for cell in snake.cells():
if not grid.contains(cell) or grid.is_obstacle(cell):
raise ValidationError(f"the snake starts on {cell}, which is not a free cell")
self.grid = grid
self.snake = snake
self._spawner = spawner
self._speed = speed or ConstantSpeed()
self._clock = clock or SystemClock()
self._direction = direction
self._inputs: deque[Direction] = deque()
self._phase: GamePhase = ReadyPhase()
self._food: Food | None = spawner.spawn(grid, snake.cells())
self._score = 0
self._tick = 0
self._started_at: float | None = None
self._ended_at: float | None = None
self._lock = threading.RLock()
self._observers: list[Renderer] = []
self._buffer: list[Frame] = []
# -- state, read-only to callers ----------------------------------------------------
@property
def phase(self) -> GamePhase:
return self._phase
@property
def state(self) -> GameState:
with self._lock:
return self._phase.state
@property
def tick_count(self) -> int:
with self._lock:
return self._tick
@property
def score(self) -> int:
with self._lock:
return self._score
@property
def food(self) -> Food | None:
with self._lock:
return self._food
@property
def direction(self) -> Direction:
with self._lock:
return self._direction
def next_interval(self) -> float:
with self._lock:
return self._speed.interval(self._score, len(self.snake))
def elapsed(self) -> float:
with self._lock:
if self._started_at is None:
return 0.0
return (self._ended_at or self._clock.now()) - self._started_at
# -- the public API ------------------------------------------------------------------
def tick(self) -> TickResult:
try:
with self._lock:
return self._phase.tick(self)
finally:
self.flush()
def submit(self, command: InputCommand) -> bool:
"""Commands are applied between ticks, never inside one - that is what the lock buys."""
return command.apply(self)
def change_direction(self, direction: Direction) -> bool:
"""Buffer a turn. Rejected if it repeats or reverses the last *queued* direction.
Validating against the queue rather than the applied direction is the fix for
the classic bug: pressing UP then LEFT inside one tick must not let LEFT be
checked against RIGHT, which was two turns ago.
"""
with self._lock:
if self._phase.state is GameState.OVER:
raise GameOverError("the game has ended")
previous = self._inputs[-1] if self._inputs else self._direction
if direction is previous or direction.reverses(previous):
return False
if len(self._inputs) >= self.MAX_BUFFERED_INPUTS:
return False
self._inputs.append(direction)
return True
def pause(self) -> GameState:
try:
with self._lock:
self.transition(self._phase.pause(self))
self._publish()
return self._phase.state
finally:
self.flush()
def resume(self) -> GameState:
try:
with self._lock:
self.transition(self._phase.resume(self))
self._publish()
return self._phase.state
finally:
self.flush()
def transition(self, phase: GamePhase) -> None:
self._phase = phase
# -- what a tick actually does --------------------------------------------------------
def advance(self) -> TickResult:
"""Wall, then obstacle, then self; move; then eat and respawn."""
self._tick += 1
if self._started_at is None:
self._started_at = self._clock.now()
direction = self._inputs.popleft() if self._inputs else self._direction
self._direction = direction
target = self.snake.next_head(direction)
if not self.grid.contains(target):
return self._end(EndReason.WALL)
if self.grid.is_obstacle(target):
return self._end(EndReason.OBSTACLE)
food = self._food
grow = food is not None and target == food.position
if self.snake.would_collide(target, grow):
return self._end(EndReason.SELF)
self.snake.move(target, grow)
if food is None or not grow:
return self._result(moved=True, ate=False)
self._score += food.value
self._food = self._spawner.spawn(self.grid, self.snake.cells())
if self._food is None: # the snake filled the grid: there is nowhere left to eat
return self._end(EndReason.FILLED, moved=True, ate=True)
return self._result(moved=True, ate=True)
def idle(self) -> TickResult:
return TickResult(self._tick, self._phase.state, moved=False, ate=False, score=self._score)
def render(self) -> str:
with self._lock:
cells: dict[Point, str] = dict.fromkeys(self.snake.cells(), "o")
cells[self.snake.head] = "O"
for obstacle in self.grid.obstacles:
cells[obstacle] = "#"
if self._food is not None:
cells[self._food.position] = "*"
return "\n".join(
"".join(cells.get(Point(x, y), ".") for x in range(self.grid.width))
for y in range(self.grid.height)
)
# -- observers -------------------------------------------------------------------------
def subscribe(self, renderer: Renderer) -> None:
with self._lock:
self._observers.append(renderer)
def flush(self) -> None:
"""Publish with no lock held: a slow front end never delays the next tick."""
with self._lock:
frames, self._buffer = self._buffer, []
observers = tuple(self._observers)
for frame in frames:
for observer in observers:
observer.on_frame(frame)
def _publish(self) -> None:
self._buffer.append(Frame(self._tick, self._phase.state, self._score, self.render()))
def _result(self, *, moved: bool, ate: bool, reason: EndReason | None = None) -> TickResult:
self._publish()
return TickResult(self._tick, self._phase.state, moved, ate, self._score, reason)
def _end(self, reason: EndReason, *, moved: bool = False, ate: bool = False) -> TickResult:
self._ended_at = self._clock.now()
self.transition(OverPhase(reason))
return self._result(moved=moved, ate=ate, reason=reason)
Input as objects, and the renderer port. Together they are twenty lines and they buy you the whole "how would you add replay?" answer:
class InputCommand(Protocol):
"""One key press as an object: queueable, loggable, replayable, testable."""
def apply(self, game: SnakeGame) -> bool: ...
@dataclass(frozen=True, slots=True)
class Turn:
direction: Direction
def apply(self, game: SnakeGame) -> bool:
return game.change_direction(self.direction)
@dataclass(frozen=True, slots=True)
class Pause:
def apply(self, game: SnakeGame) -> bool:
return game.pause() is GameState.PAUSED
@dataclass(frozen=True, slots=True)
class Resume:
def apply(self, game: SnakeGame) -> bool:
return game.resume() is GameState.RUNNING
class Renderer(Protocol):
"""The output port. A curses front end and a websocket both implement this one method."""
def on_frame(self, frame: Frame) -> None: ...
class TextRenderer:
"""Observer: keeps every frame the engine published. Never reaches back into the game."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._frames: list[Frame] = []
def on_frame(self, frame: Frame) -> None:
with self._lock:
self._frames.append(frame)
def frames(self) -> tuple[Frame, ...]:
with self._lock:
return tuple(self._frames)
def latest(self) -> Frame | None:
with self._lock:
return self._frames[-1] if self._frames else None
Running python -m lld.snake_game.demo plays a short game: eat, turn three times so the head lands on the cell the tail is leaving, get a reversal refused, pause, then hit a wall.
--- 10 x 5 grid, scripted food, a snake of 3 heading right ---
..........
........#.
.ooO.*..#.
..........
..........
tick 2: ate at (5,2), score 1, length 4, interval 0.20s to 0.18s
tick 5: moved into the cell the tail was vacating, which is legal
*.........
....oo..#.
....Oo..#.
..........
..........
UP submitted after DOWN: accepted=False (a 180-degree reversal is ignored)
paused: tick still 5, moved=False, state=paused
tick 8: over by wall, score 1, length 4, 10 frames in 1.28s of game time
Concurrency and edge cases¶
Which lock protects what. One RLock on SnakeGame guards the snake, the food, the score, the phase and the input buffer. It exists because in every real front end input and the clock are different threads: a key press must not land halfway through a tick, after the head has been computed but before the body has moved. The critical section is a few dictionary and deque operations, on the order of the 17 ns an uncontended mutex costs, so a coarse lock is the right trade here — the alternative, a lock-free single-threaded loop with a thread-safe input queue, is the other legitimate answer and worth naming.
Notifying outside the lock. Frames are buffered during the tick and delivered by flush after the lock is released, so a renderer writing to a socket cannot delay the next tick.
The tail rule, stated precisely. When the head moves to a cell, that cell is occupied unless it is the tail and the snake is not growing this tick. Growing keeps the tail in place, so eating a fruit that sits on your own tail cell would be fatal — which is why food never spawns on the snake in the first place. A snake of length 2 turning back on itself is the same cell, but it is refused by the reversal guard rather than the collision test: two independent rules, both needed.
Input buffering. Each turn is validated against the last queued direction, not the last applied one. Press UP then LEFT inside one tick and both are legal in sequence; validate both against the applied RIGHT and you would accept UP then DOWN, and the snake would eat itself on the next tick. The buffer holds at most two turns, so mashing keys cannot queue a minute of moves.
Edge cases handled: a wall, an obstacle and the body each ending the game with their own EndReason; the grid filling up, which ends it as a win; a paused tick that changes nothing and does not advance the counter; pausing a game that has not started, and pausing or ticking one that has ended; a snake starting on an obstacle or on top of itself; a grid smaller than 3 x 3; obstacles outside the grid; a spawner with nowhere left to put food; and a repeated direction, which is refused rather than queued.
Common mistake
Writing self-collision as if target in snake.body and stopping there. It kills the snake whenever it follows its own tail, which is the most common legal move in the game, and it is a linear scan on top. Keep the body in a set beside the deque, and make the test target in cells and (grow or target != tail). Say the second half out loud before you write it.
Extensibility and follow-ups¶
- Wrap-around walls: one method on
Grid—normalise(point)returning the point modulo the dimensions — called beforecontains. The engine does not change, and the wall end reason simply stops occurring. - Multiplayer: a second
Snakeand a per-snake direction and buffer.advancecomputes both targets before moving either, so a head-on collision is symmetric rather than decided by iteration order. That ordering question is the whole difficulty, and it is worth saying so. - A BFS bot: a
MoveChooserstrategy that runs breadth-first search from the head to the food over free cells, with a fallback that follows the tail when no path exists. It plugs in wherechange_directionis called today, so the engine is untouched. - Replay: record the seed and the
InputCommandsequence with the tick each was applied on. That is a complete replay, because everything else in the engine is deterministic — which is the payoff for injecting the generator and the clock. - Moving obstacles or a shrinking arena: a
Hazardcollection ticked before the snake; the collision checks already ask the grid, so only the grid gains state. - Score multipliers and special food:
Food.valueis already there; aGoldenFoodwith a spawn probability is aFoodSpawnerdecorator, not a subclass of the snake.
Interview tip
Draw one tick as a numbered list before you write any class: next head, wall, obstacle, self, move, eat, respawn, publish. Then implement it top to bottom. Candidates who start with the Snake class usually forget that the checks have an order, and discover halfway through that they have to decide whether the head moves before or after the collision test.
Tests¶
tests/test_snake_game.py has 12 cases. The two to walk through are the tail rule — pinned directly on Snake and again through a real tick — and the input buffer, which is the only place where the correct behaviour is genuinely counter-intuitive.
def test_the_tail_cell_is_free_only_while_the_snake_is_not_growing() -> None:
snake = Snake([Point(2, 2), Point(2, 1), Point(3, 1), Point(3, 2)])
assert snake.would_collide(snake.tail, grow=False) is False # the tail leaves this tick
assert snake.would_collide(snake.tail, grow=True) is True # growing keeps the tail put
assert snake.would_collide(Point(2, 1), grow=False) is True # a mid-body cell is always fatal
def test_moving_into_the_cell_the_tail_is_vacating_is_legal() -> None:
game = SnakeGame(
Grid(8, 5),
Snake([Point(2, 2), Point(2, 1), Point(3, 1), Point(3, 2)]),
ScriptedFoodSpawner([Point(7, 4)]),
Direction.DOWN,
)
game.submit(Turn(Direction.RIGHT)) # head (2,2) turns onto the tail cell (3,2)
result = game.tick()
assert result.moved and game.state is GameState.RUNNING
assert game.snake.head == Point(3, 2) and len(game.snake) == 4
def test_reversals_repeats_and_a_full_buffer_are_all_refused() -> None:
game = simple_game(Direction.RIGHT)
assert game.submit(Turn(Direction.LEFT)) is False # reverses the applied direction
assert game.submit(Turn(Direction.RIGHT)) is False # no-op
assert game.submit(Turn(Direction.UP)) is True
assert game.submit(Turn(Direction.DOWN)) is False # reverses the *queued* UP, not RIGHT
assert game.submit(Turn(Direction.LEFT)) is True
assert game.submit(Turn(Direction.UP)) is False # buffer holds at most two turns
assert game.tick().moved and game.direction is Direction.UP
assert game.tick().moved and game.direction is Direction.LEFT
The concurrency test runs one ticker thread against four key-press threads and asserts two invariants that would break if a turn landed mid-tick: the body never contains a duplicate cell, and no two consecutively applied directions are opposites.
def test_input_from_other_threads_never_lands_mid_tick() -> None:
game = SnakeGame(
Grid(20, 20),
Snake([Point(10, 10), Point(9, 10), Point(8, 10)]),
RandomFoodSpawner(random.Random(5)),
Direction.RIGHT,
)
applied: list[Direction] = []
keys = list(Direction)
def ticker() -> None:
for _ in range(80):
try:
game.tick()
except GameOverError:
return
applied.append(game.direction) # only this thread appends
def presser(i: int) -> None:
for _ in range(80):
try:
game.submit(Turn(keys[i % 4]))
except GameOverError:
return
with ThreadPoolExecutor(max_workers=5) as pool:
for future in [pool.submit(ticker), *(pool.submit(presser, i) for i in range(4))]:
future.result()
cells = game.snake.cells()
assert len(set(cells)) == len(cells) # the deque and the membership set never diverged
assert len(applied) > 1
assert all(a is not b.opposite for a, b in zip(applied, applied[1:], strict=False))
The rest cover: a scripted game that eats, grows and publishes exactly two frames with the expected board row; the three collision reasons as a parametrized table; filling a 3 x 3 grid as a win; the pause, resume and game-over transitions including the errors each phase raises; grid, snake and placement validation; and the speed curve, with 0.2 x 0.9^12 = 0.056 clamped to the 0.06 floor. Run them with uv run pytest code/lld/snake_game -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0–5 | Clarify | Walls kill or wrap? Tail-chasing legal? Two key presses in one tick? Obstacles? Out of scope: rendering, persistence, multiplayer. |
| 5–10 | One tick as a list | Next head, wall, obstacle, self, move, eat, respawn, publish. Say the order is the design. |
| 10–16 | Entities | Grid, Snake, Point, Direction, Food, spawner, speed policy, renderer. Point out deque plus set and why. |
| 16–24 | The snake | Write would_collide and move. State the tail rule before the code, then show that grow is the only thing that changes it. |
| 24–32 | Engine and states | advance top to bottom, then the four phases. Justify State classes here after using an enum in tic-tac-toe. |
| 32–38 | Input and concurrency | The buffer, validated against the last queued direction. Name the lock and the mid-tick race it prevents. |
| 38–45 | Extensions | Wrap-around, multiplayer head-on collisions, a BFS bot, replay from seed plus commands. |
Related¶
- Design tic-tac-toe (an extensible board game) — the turn-based sibling, and why its status is an enum rather than State classes
- State — the four phases and when classes beat an enum
- Observer — the renderer port
- Command — input as objects, and the replay it enables
- Strategy — the spawner and the speed policy
- Design snake and ladder — the other snake problem, and a completely different shape