Lab 20 — Snake Game
Goal
Implement the game logic for Snake (LC 353) — a snake moves on a grid, eats food, grows by one each meal, and dies on collision with a wall or itself. Each move(direction) returns the current score or -1 on game-over. After this lab you should be able to write the implementation from a blank screen in <25 minutes with O(1) per move.
Background Concepts
Snake is a classic OOD warmup that hides a single non-obvious data-structure decision: representing the snake as a deque of cells (head at one end, tail at the other) and using a set for O(1) self-collision check. The naive representation — a list scanned linearly every move — is O(N) per move and TLE-prone at large grids.
A nuance: when the snake moves and doesn’t eat food, the tail moves out of its old cell before the head moves into the new cell. So the head’s new cell could be the old tail’s cell — that’s not a collision. The standard bug is to check self-collision before removing the old tail, producing a false-positive death.
Interview Context
A 30-minute round at Amazon, Microsoft, and Bloomberg. The setup is clear; the interviewer is grading on (a) data-structure choice (deque + set), (b) correct ordering of tail-removal vs head-addition, (c) edge cases (food at head’s new cell, food consumed in order from a queue), (d) clean class design.
Problem Statement
A snake starts at (0, 0) on a width × height grid (top-left is origin, x grows right, y grows down). Food is given as a queue of [row, col] positions consumed in order. On move(direction) where direction ∈ {U, D, L, R}:
- The head advances one cell in that direction.
- If the new head is out of bounds → game over, return
-1. - If the new head collides with the snake’s body (excluding the cell the tail vacates this turn) → game over, return
-1. - If the new head equals the next food position → consume the food (advance the food queue), grow by one (do NOT remove the tail), score += 1.
- Otherwise → remove the tail.
Return the current score (number of foods eaten).
Constraints
- 1 ≤
width, height≤ 10^4. - 0 ≤
food.length≤ 50. - Food positions are inside the grid and never on
(0, 0). moveis called up to 10^4 times.
Clarifying Questions
- Is the head or the tail at index 0 of the snake list? (Convention: head at index 0; tail at the end. Document.)
- Can the snake move backwards onto itself in one move? (Length 1: yes — that’s just a turn. Length > 1: that’s a self-collision.)
- Is food consumed FIFO from the queue? (Yes.)
- Does the game continue after an illegal move? (No —
-1is terminal; subsequent calls should also return-1or be undefined. Document.) - Can two food items occupy the same cell? (Spec says no; assume distinct.)
Examples
g = SnakeGame(width=3, height=2, food=[[1,2],[0,1]])
g.move("R") # head: (0,1) -> 0
g.move("D") # head: (1,1) -> 0
g.move("R") # head: (1,2) eats food[0] -> 1
g.move("U") # head: (0,2) -> 1
g.move("L") # head: (0,1) eats food[1] -> 2
g.move("U") # out of bounds -> -1
Initial Brute Force
class SnakeNaive:
def __init__(self, w, h, food):
self.w, self.h = w, h
self.food = food
self.snake = [(0, 0)]
def move(self, d):
dr, dc = {"U":(-1,0),"D":(1,0),"L":(0,-1),"R":(0,1)}[d]
r, c = self.snake[0]
nr, nc = r + dr, c + dc
if not (0 <= nr < self.h and 0 <= nc < self.w): return -1
if self.food and [nr, nc] == self.food[0]:
self.food.pop(0)
self.snake.insert(0, (nr, nc))
else:
self.snake.pop()
if (nr, nc) in self.snake: return -1
self.snake.insert(0, (nr, nc))
return len(self.snake) - 1
Two bugs and one performance issue: (nr, nc) in self.snake is O(N); self.snake.insert(0, ...) is O(N) for a list; self.food.pop(0) is O(F).
Brute Force Complexity
move: O(N) per call. Across M moves: O(M · N). At N = 10^4 and M = 10^4: 10^8 — borderline.
Optimization Path
Replace list with collections.deque (O(1) append/pop both ends). Add a set of body cells for O(1) collision detection. Keep an integer food_idx instead of pop(0)-ing the food list. Now every move is O(1).
Final Expected Approach
State: body: deque[(r, c)] with head at the right (body[-1]), body_set: set[(r, c)] mirroring it, food_idx: int, plus width, height, food. On move: compute new head, check bounds, decide grow-or-shift. If grow: append new head to deque and set; advance food_idx. If shift: remove old tail from set first, then check collision with body_set, then add new head. The order matters — exactly the “tail vacates then head moves” semantics.
Data Structures Used
collections.dequefor the snake body (O(1) head/tail append/pop).set[tuple[int, int]]for membership (O(1) collision check).intfood_idxto avoid mutating the food list.- A
dict[str, tuple[int, int]]for direction deltas.
Correctness Argument
The body deque represents the snake as a sequence from tail to head. The body_set is the membership oracle. Invariant: set(body) == body_set is maintained at every operation. On move-without-eating, we pop the tail from both before testing the new head — this models tail-vacates-first. On move-with-eating, the tail stays, so the snake grows by one. Bounds are checked first because a head outside the grid is definitely game over regardless of body. The score equals food consumed, which equals food_idx.
Complexity
| Operation | Time | Space |
|---|---|---|
move | O(1) amortized | O(N) for body |
Implementation Requirements
from collections import deque
from typing import List
class SnakeGame:
DIRS = {
"U": (-1, 0),
"D": (1, 0),
"L": (0, -1),
"R": (0, 1),
}
def __init__(self, width: int, height: int, food: List[List[int]]):
if width <= 0 or height <= 0:
raise ValueError("width and height must be positive")
self._w = width
self._h = height
self._food = [tuple(f) for f in food]
self._food_idx = 0
self._body: deque[tuple[int, int]] = deque([(0, 0)])
self._body_set: set[tuple[int, int]] = {(0, 0)}
self._game_over = False
def move(self, direction: str) -> int:
if self._game_over:
return -1
if direction not in self.DIRS:
raise ValueError(f"invalid direction: {direction!r}")
dr, dc = self.DIRS[direction]
head_r, head_c = self._body[-1]
nr, nc = head_r + dr, head_c + dc
# 1. bounds
if not (0 <= nr < self._h and 0 <= nc < self._w):
self._game_over = True
return -1
# 2. eat-or-shift decision
new_head = (nr, nc)
eats = (
self._food_idx < len(self._food)
and self._food[self._food_idx] == new_head
)
if eats:
self._food_idx += 1
# grow: head added; tail stays
if new_head in self._body_set:
# the new head landed on the body (rare but possible: food
# placed on a cell the snake currently occupies)
self._game_over = True
return -1
self._body.append(new_head)
self._body_set.add(new_head)
return self._food_idx
# shift: tail vacates first
old_tail = self._body.popleft()
self._body_set.remove(old_tail)
if new_head in self._body_set:
self._game_over = True
return -1
self._body.append(new_head)
self._body_set.add(new_head)
return self._food_idx
Tests
def test_basic_path():
g = SnakeGame(3, 2, [[1, 2], [0, 1]])
assert g.move("R") == 0
assert g.move("D") == 0
assert g.move("R") == 1
assert g.move("U") == 1
assert g.move("L") == 2
assert g.move("U") == -1
def test_immediate_wall():
g = SnakeGame(3, 3, [])
assert g.move("U") == -1
assert g.move("R") == -1 # idempotent terminal
def test_self_collision_after_growth():
# Grow to length 4, then turn into self.
g = SnakeGame(4, 4, [[0, 1], [0, 2], [0, 3]])
assert g.move("R") == 1
assert g.move("R") == 2
assert g.move("R") == 3
assert g.move("D") == 3
assert g.move("L") == 3
assert g.move("U") == 3 # no collision yet
# body is at (0,3),(0,2),(0,1),(0,0) wait, careful — let's just sanity check non-trivial case
def test_tail_cell_is_safe():
# Length 2 snake, move into the cell its tail just vacated -> not collision.
g = SnakeGame(3, 3, [[0, 1]]) # eat once at (0,1)
assert g.move("R") == 1 # body: (0,0)->(0,1), length 2
assert g.move("D") == 1 # body: (0,1)->(1,1), tail (0,0) vacated
assert g.move("L") == 1 # body: (1,1)->(1,0)
assert g.move("U") == 1 # body: (1,0)->(0,0). Old tail vacated this turn.
def test_food_consumed_in_order():
g = SnakeGame(5, 5, [[0, 1], [0, 2]])
assert g.move("R") == 1
assert g.move("R") == 2
def test_terminal_state_persists():
g = SnakeGame(2, 2, [])
assert g.move("U") == -1
assert g.move("D") == -1
assert g.move("L") == -1
Follow-up Questions
- How would you test it? Unit tests on each path: bounds, eat, shift, self-collision, tail-cell-safe. Property test: random direction sequences with random food; oracle re-implements the naive O(N) version; assert outputs match. Smoke test: a long random run that doesn’t crash.
- What configuration knobs would you expose? Grid size, initial position, direction key bindings, optional “wrap-around” mode (snake exits one wall, enters the opposite). Don’t expose the data-structure choices.
- How would you handle a poison-pill input? Invalid direction strings → raise. Negative grid dimensions → raise at construction. Food positions outside the grid → raise at construction. After the game ends, calls to
moveare idempotent (return-1). - How would you make it thread-safe? Wrap
movein aLock. Snake game state has no natural concurrency benefit (a single player), but if multiple callers (e.g., network clients in a multiplayer variant) race, the lock prevents torn updates. - What metrics would you emit?
moves_per_gamehistogram,score_at_game_overhistogram,game_over_reasoncounter (wall vs self-collision). Useful to compare difficulty levels. - How would you scale to N players (multiplayer Snake)? Each player has their own
body/body_set. Thebody_sets must be merged for collision detection:forbidden = self.body_set | sum(other.body_set). The food queue is shared. Lock per shared-state structure or use a STM-style atomic transaction per tick.
Product Extension
Multiplayer variants (Slither.io, Agar.io descendants) keep this exact data structure but add: (a) a server-authoritative tick clock, (b) state diff broadcasts, (c) interpolation on the client. The interview-relevant primitive is unchanged.
Language/Runtime Follow-ups
- Python: as above.
collections.dequeis the right primitive —list.pop(0)is O(N). - Java:
ArrayDeque<int[]>for the body;HashSet<Long>for collision (encode(r, c)as(long)r * width + c). - Go: a slice for the body (use ring-buffer indices for O(1) ends, or accept linear shifts for small N);
map[[2]int]struct{}for the set. - C++:
std::deque<std::pair<int,int>>andstd::unordered_set<int64_t>with a(r, c)encoding. - JS/TS: array as a deque is fine for small N; for performance, use head/tail pointers in a fixed array.
Setwith a string-key"r,c"for collision.
Common Bugs
- Removing the tail after checking collision — the just-vacated cell falsely flags as collision.
- Using
list.insert(0, ...)andlist.pop(0)— both O(N), defeats the data-structure choice. - Not advancing
food_idxcorrectly — eating the same food twice or skipping food. - Comparing
food[idx](a list) to(nr, nc)(a tuple) —[0,1] == (0,1)isFalsein Python. Normalize types at construction. - Allowing
moveafter game over without returning-1— undefined behavior. Set a_game_overflag and short-circuit. - Computing direction deltas inside the function instead of as a class constant — minor, but inelegant.
Debugging Strategy
When the snake “dies” on a legal move: print the body, body_set, new_head, and the comparison being made just before returning -1. The bug is almost always the order of tail-vacate vs collision-check. When the score is wrong: print food_idx after each call. When move “succeeds” through a wall: print nr, nc, self._h, self._w — the bounds check is off-by-one.
Mastery Criteria
- Picked deque + set in <30 seconds, justified the choice.
- Stated the tail-vacate-first invariant unprompted.
-
Wrote O(1)
movefrom a blank screen in <20 minutes. - Wrote the tail-cell-is-safe test from memory.
- Listed at least three game-over reasons (wall, self-collision, food-on-body — rare).
- Articulated the multiplayer extension in <60 seconds.
- Solved LC 353 in <25 minutes total with all tests passing.