Low-level design · Problem P05 of 15 · Warm-up problems
Snake and ladder
A gentle problem whose real lesson is testability: once the dice is an interface, a game full of randomness becomes a deterministic test.
1. The prompt
The prompt"Design snake and ladder for several players. The board has snakes and ladders; players take turns rolling a die; the first to reach the last square exactly wins."
2. Clarify the requirements
Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.
| Ask | Assume (unless told otherwise) |
|---|---|
| Overshoot the last square? | You stay where you are (a common house rule; confirm it). |
| Roll a 6: extra turn? | Not by default; a follow-up. |
| Can a jump land on another jump? | No: validate when building the board. |
Must do
- Any number of players, taking turns in order
- Snakes move you down, ladders up
- You must land exactly on the last square to win
Must be
- Board configurable (size, jumps)
- Dice swappable (e.g. two dice, a loaded die for tests)
- Validate the board
Out of scope
- Graphics
- Saving a game
- Online play
3. The classes
| Class | Its one job |
|---|---|
| Game | Turn order, positions, and the win rule |
| Board | Size and jumps; one method: where does this square send you? |
| Dice | An interface; RandomDice for play, ScriptedDice for tests |
4. How it works
5. Patterns and why
| Pattern | Where | Why it's worth it here |
|---|---|---|
| Strategy / dependency injection | Dice passed into Game | Swap a random die for a scripted one in tests, or two dice in a variant |
| Data over classes | Snakes and ladders are just a map of jumps | No Snake and Ladder classes needed: they behave identically |
| Queue | Players in a deque; rotate after each turn | Turn order without index arithmetic |
Memory hook: "A jump is a jump"Snakes and ladders are one map from square to square; the dice is an interface so tests can script it.
6. The code
The demo uses ScriptedDice, so every run prints the same game. The real game passes RandomDice.
from __future__ import annotations
import random
from abc import ABC, abstractmethod
from collections import deque
class Dice(ABC):
@abstractmethod
def roll(self) -> int: ...
class RandomDice(Dice):
def __init__(self, faces: int = 6, seed: int | None = None) -> None:
self.faces, self.rng = faces, random.Random(seed)
def roll(self) -> int:
return self.rng.randint(1, self.faces)
class ScriptedDice(Dice):
"""Rolls a fixed sequence: the game becomes testable without touching its code."""
def __init__(self, rolls: list[int]) -> None:
self.rolls = deque(rolls)
def roll(self) -> int:
return self.rolls.popleft()
class Board:
def __init__(self, size: int, jumps: dict[int, int]) -> None:
for start, end in jumps.items():
if not (0 < start < size and 0 < end <= size) or start == end:
raise ValueError(f"bad jump {start}->{end}")
self.size, self.jumps = size, jumps
def destination(self, square: int) -> int:
return self.jumps.get(square, square)
class Game:
def __init__(self, board: Board, players: list[str], dice: Dice) -> None:
self.board, self.dice = board, dice
self.queue = deque(players)
self.position = {p: 0 for p in players}
self.winner: str | None = None
def take_turn(self) -> str:
player = self.queue[0]
roll = self.dice.roll()
start = self.position[player]
target = start + roll
if target > self.board.size: # must land exactly on the last square
note = f"{player} rolls {roll}: needs {self.board.size - start}, stays on {start}"
else:
end = self.board.destination(target)
kind = "" if end == target else (" ladder up to" if end > target else " snake down to")
note = f"{player} rolls {roll}: {start} -> {target}" + (f",{kind} {end}" if kind else "")
self.position[player] = end
if end == self.board.size:
self.winner = player
return note + f". {player} wins"
self.queue.rotate(-1)
return note
def play(self, max_turns: int = 1000) -> None:
for _ in range(max_turns):
print(self.take_turn())
if self.winner:
return
if __name__ == "__main__":
board = Board(30, {3: 22, 5: 8, 11: 26, 20: 29, 17: 4, 19: 7, 21: 9, 27: 1})
Game(board, ["Ana", "Ben"], ScriptedDice([2, 3, 6, 2, 6, 4, 3, 1, 5, 2, 2, 1])).play()
interface Dice { roll(): number; }
class RandomDice implements Dice {
constructor(private faces = 6) {}
roll(): number { return 1 + Math.floor(Math.random() * this.faces); }
}
/** Rolls a fixed sequence: the game becomes testable without touching its code. */
class ScriptedDice implements Dice {
constructor(private rolls: number[]) {}
roll(): number {
const r = this.rolls.shift();
if (r === undefined) throw new Error("out of rolls");
return r;
}
}
class Board {
constructor(readonly size: number, private jumps: Map<number, number>) {
for (const [s, e] of jumps) if (!(s > 0 && s < size && e > 0 && e <= size) || s === e) throw new Error(`bad jump ${s}->${e}`);
}
destination(square: number): number { return this.jumps.get(square) ?? square; }
}
class Game {
private queue: string[];
private position = new Map<string, number>();
winner: string | null = null;
constructor(private board: Board, players: string[], private dice: Dice) {
this.queue = [...players];
for (const p of players) this.position.set(p, 0);
}
takeTurn(): string {
const player = this.queue[0];
const roll = this.dice.roll();
const start = this.position.get(player)!;
const target = start + roll;
let note: string;
if (target > this.board.size) { // must land exactly on the last square
note = `${player} rolls ${roll}: needs ${this.board.size - start}, stays on ${start}`;
} else {
const end = this.board.destination(target);
const kind = end === target ? "" : end > target ? " ladder up to" : " snake down to";
note = `${player} rolls ${roll}: ${start} -> ${target}` + (kind ? `,${kind} ${end}` : "");
this.position.set(player, end);
if (end === this.board.size) {
this.winner = player;
return `${note}. ${player} wins`;
}
}
this.queue.push(this.queue.shift()!);
return note;
}
play(maxTurns = 1000): void {
for (let i = 0; i < maxTurns && !this.winner; i++) console.log(this.takeTurn());
}
}
const board = new Board(30, new Map([[3, 22], [5, 8], [11, 26], [20, 29], [17, 4], [19, 7], [21, 9], [27, 1]]));
new Game(board, ["Ana", "Ben"], new ScriptedDice([2, 3, 6, 2, 6, 4, 3, 1, 5, 2, 2, 1])).play();
void RandomDice; // the real game would pass new RandomDice()import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
interface Dice {
int roll();
}
class RandomDice implements Dice {
private final Random rng = new Random();
private final int faces;
RandomDice(int faces) {
this.faces = faces;
}
public int roll() {
return 1 + rng.nextInt(faces);
}
}
/** Rolls a fixed sequence: the game becomes testable without touching its code. */
class ScriptedDice implements Dice {
private final Deque<Integer> rolls;
ScriptedDice(List<Integer> rolls) {
this.rolls = new ArrayDeque<>(rolls);
}
public int roll() {
return rolls.removeFirst();
}
}
class Board {
final int size;
private final Map<Integer, Integer> jumps;
Board(int size, Map<Integer, Integer> jumps) {
jumps.forEach((s, e) -> {
if (!(s > 0 && s < size && e > 0 && e <= size) || s.equals(e))
throw new IllegalArgumentException("bad jump " + s + "->" + e);
});
this.size = size;
this.jumps = jumps;
}
int destination(int square) {
return jumps.getOrDefault(square, square);
}
}
class Game {
private final Board board;
private final Dice dice;
private final Deque<String> queue;
private final Map<String, Integer> position = new HashMap<>();
String winner;
Game(Board board, List<String> players, Dice dice) {
this.board = board;
this.dice = dice;
this.queue = new ArrayDeque<>(players);
players.forEach(p -> position.put(p, 0));
}
String takeTurn() {
String player = queue.peekFirst();
int roll = dice.roll();
int start = position.get(player), target = start + roll;
String note;
if (target > board.size) { // must land exactly on the last square
note = player + " rolls " + roll + ": needs " + (board.size - start) + ", stays on " + start;
} else {
int end = board.destination(target);
String kind = end == target ? "" : end > target ? " ladder up to" : " snake down to";
note = player + " rolls " + roll + ": " + start + " -> " + target + (kind.isEmpty() ? "" : "," + kind + " " + end);
position.put(player, end);
if (end == board.size) {
winner = player;
return note + ". " + player + " wins";
}
}
queue.addLast(queue.removeFirst());
return note;
}
void play(int maxTurns) {
for (int i = 0; i < maxTurns && winner == null; i++) System.out.println(takeTurn());
}
}
public class Main {
public static void main(String[] args) {
Map<Integer, Integer> jumps = new HashMap<>(Map.of(3, 22, 5, 8, 11, 26, 20, 29, 17, 4, 19, 7, 21, 9, 27, 1));
Board board = new Board(30, jumps);
new Game(board, List.of("Ana", "Ben"), new ScriptedDice(List.of(2, 3, 6, 2, 6, 4, 3, 1, 5, 2, 2, 1))).play(1000);
Dice real = new RandomDice(6); // the real game would pass this instead
if (real.roll() < 1) throw new AssertionError();
}
}Output (the same in all three languages)
Ana rolls 2: 0 -> 2 Ben rolls 3: 0 -> 3, ladder up to 22 Ana rolls 6: 2 -> 8 Ben rolls 2: 22 -> 24 Ana rolls 6: 8 -> 14 Ben rolls 4: 24 -> 28 Ana rolls 3: 14 -> 17, snake down to 4 Ben rolls 1: 28 -> 29 Ana rolls 5: 4 -> 9 Ben rolls 2: needs 1, stays on 29 Ana rolls 2: 9 -> 11, ladder up to 26 Ben rolls 1: 29 -> 30. Ben wins
7. Concurrency and edge cases
- Validate the board when it's built: jumps inside the board, no jump to itself, ideally no jump that starts on another jump's end.
- Guard against endless games with a maximum number of turns, especially in automated tests.
- Online play needs one authority per game (a server-side lock or actor) so two players can't move at once.
8. Follow-up questions
What interviewers ask once the core works, and a short answer for each.
- Extra turn on a six?
- After the move, don't rotate the queue if the roll was 6; cap it at three sixes in a row.
- Two dice?
- A CompositeDice that sums two Dice: no change to Game.
- How do you test the win rule?
- ScriptedDice with the exact rolls that reach the end, then assert the winner.
9. Common mistakes
Separate Snake and Ladder classesThey differ only in direction. One jump map is simpler.
Random numbers inside GameUntestable. Inject the dice.
Index arithmetic for turnsA queue that rotates is clearer and handles any number of players.
10. Check yourself
Why is Dice an interface?
Randomness injected from outside makes the game deterministic in tests.
How are snakes and ladders stored?
Both are a jump; direction is just which square is bigger.
Your turn
- Add the extra-turn rule (10 min). Roll a 6, play again, at most three times.
- Add two dice (5 min). A Dice that sums two others.