Design Snake & Ladder
Low-level design for Snake & Ladder: board representation, snake/ladder mapping, turn management with the Observer pattern, testable dice via injection, and the overshoot edge case everyone forgets.
Design Snake & Ladder
Snake & Ladder is a deceptively good LLD warm-up: the domain is trivial (everyone knows the rules), so the interview signal comes entirely from design hygiene — how you represent the board, how you make dice rolls testable, how you decouple "a player moved" from "who needs to react to that," and whether you correctly handle the one genuinely tricky rule: what happens when a roll would overshoot the last cell.
1. Requirements
Functional requirements
- Board is N×N (classically 10×10 = 100 cells), numbered 1 to N².
- 2+ players take turns rolling a single six-sided die.
- A player's piece moves forward by the roll amount from its current position.
- If the piece lands on a snake's head, it moves down to the snake's tail. If it lands on a ladder's base, it moves up to the ladder's top.
- Chained snakes/ladders are possible (landing on a ladder top that is itself a snake head) and must resolve fully before the turn ends.
- First player to land exactly on cell N² wins.
- Overshoot rule: if a roll would take the piece past N², the configured behavior applies (bounce back, or simply don't move — stay in place and forfeit the roll).
Non-functional requirements
- Deterministic testability: the dice must be injectable/mockable so game logic can be unit-tested without relying on real randomness.
- Extensible board size and snake/ladder count without changing
GameManager. - Decoupled notifications: UI updates, move logging, and win announcements should not be hardcoded into the core game loop.
- Support N players, not just 2 — the turn loop must generalize.
The "sounds trivial" trap in this problem is treating it as a script instead of a system. The interview signal is in whether GameManager stays ignorant of how a roll is generated (inject Dice) and who reacts to a move (Observer) — not in whether you can compute newPosition = currentPosition + roll.
2. Actors & Use Cases
| Actor | Description |
|---|---|
| Player | Takes turns rolling the dice and moving their piece. |
| Game Manager | Owns the turn loop, applies board rules, checks the win condition. |
| Spectator / UI | Observes move and win events without participating in turns. |
Primary use cases
- Take a turn — current player rolls dice → piece advances → snake/ladder resolved (possibly chained) → turn passes to next player.
- Check win — after each move, if a piece lands exactly on the final cell, that player wins and the game ends.
- Handle overshoot — a roll that would exceed the final cell is either ignored (piece doesn't move) or bounces back by the excess, per configured rule.
- Observe game events — a UI or logger subscribes to "player moved," "snake bite," "ladder climb," and "player won" events.
3. Class Diagram
4. Core Class Design
Board — a flat 1D map, not a 2D grid
class Board {
private final int size; // total cells, e.g. 100 for a 10x10 board
// A single HashMap encodes BOTH snakes and ladders: cell -> destination cell.
// Snake entries map head -> tail (destination < source).
// Ladder entries map bottom -> top (destination > source).
private final Map<Integer, Integer> jumpMap;
Board(int size, List<Snake> snakes, List<Ladder> ladders) {
this.size = size;
this.jumpMap = new HashMap<>();
for (Snake s : snakes) jumpMap.put(s.getHead(), s.getTail());
for (Ladder l : ladders) jumpMap.put(l.getBottom(), l.getTop());
validateNoOverlaps(snakes, ladders);
}
int getFinalCell() { return size; }
/** Resolves chained jumps: a ladder top that is itself a snake head, etc. */
int resolveJumps(int cell) {
Set<Integer> visited = new HashSet<>(); // guards against a misconfigured infinite loop
while (jumpMap.containsKey(cell)) {
if (!visited.add(cell)) {
throw new IllegalStateException("Cyclic snake/ladder configuration at cell " + cell);
}
cell = jumpMap.get(cell);
}
return cell;
}
private void validateNoOverlaps(List<Snake> snakes, List<Ladder> ladders) {
// A cell cannot be both a snake head AND a ladder bottom — ambiguous rule.
// A snake head cannot equal cell 1 (start) or the final cell (win cell).
// Left as an exercise in a real implementation; called out here for interview credit.
}
}Board representation: 1D array/map beats 2D grid. A 10×10 visual board displays as 2D with a boustrophedon (snake-path) numbering, but the game logic only ever needs "current linear position + roll = new linear position." Modeling Board as a 2D grid forces you to convert row/col ↔️ linear position on every single move for zero logic benefit — do that conversion only in the rendering layer, never in GameManager.
Snake and Ladder
final class Snake {
private final int head; // higher cell number
private final int tail; // lower cell number
Snake(int head, int tail) {
if (tail >= head) throw new IllegalArgumentException("Snake tail must be below its head");
this.head = head;
this.tail = tail;
}
int getHead() { return head; }
int getTail() { return tail; }
}
final class Ladder {
private final int bottom; // lower cell number
private final int top; // higher cell number
Ladder(int bottom, int top) {
if (top <= bottom) throw new IllegalArgumentException("Ladder top must be above its bottom");
this.bottom = bottom;
this.top = top;
}
int getBottom() { return bottom; }
int getTop() { return top; }
}Dice — interface first, for testability
interface Dice {
int roll();
}
class RandomDice implements Dice {
private final int faces;
private final Random random = new Random();
RandomDice(int faces) { this.faces = faces; }
public int roll() { return random.nextInt(faces) + 1; }
}
/** Test double: scripted rolls, so game-logic tests are 100% deterministic. */
class FixedSequenceDice implements Dice {
private final Queue<Integer> scriptedRolls;
FixedSequenceDice(List<Integer> rolls) {
this.scriptedRolls = new LinkedList<>(rolls);
}
public int roll() {
if (scriptedRolls.isEmpty()) throw new IllegalStateException("Dice script exhausted");
return scriptedRolls.poll();
}
}Player
class Player {
private final String id;
private final String name;
private int position = 0; // 0 = not yet on the board; cell 1 is the first square
Player(String id, String name) {
this.id = id;
this.name = name;
}
int getPosition() { return position; }
void setPosition(int position) { this.position = position; }
String getName() { return name; }
String getId() { return id; }
}Overshoot handling — strategy, not a hardcoded if
interface OvershootRule {
/** Returns the resolved position after applying a roll that may exceed the final cell. */
int apply(int currentPosition, int roll, int finalCell);
}
/** Classic rule: a roll that overshoots is simply wasted — piece doesn't move. */
class StayInPlaceRule implements OvershootRule {
public int apply(int currentPosition, int roll, int finalCell) {
int target = currentPosition + roll;
return target > finalCell ? currentPosition : target;
}
}
/** Variant rule: overshoot bounces back by the excess amount. */
class BounceBackRule implements OvershootRule {
public int apply(int currentPosition, int roll, int finalCell) {
int target = currentPosition + roll;
if (target <= finalCell) return target;
int excess = target - finalCell;
return finalCell - excess;
}
}GameManager — the turn loop and Observer hub
interface GameObserver {
void onMove(MoveEvent event);
void onWin(WinEvent event);
}
record MoveEvent(Player player, int fromCell, int toCell, boolean snakeBite, boolean ladderClimb) {}
record WinEvent(Player winner) {}
class GameManager {
private final Board board;
private final List<Player> players;
private final Dice dice;
private final OvershootRule overshootRule;
private final List<GameObserver> observers = new ArrayList<>();
private int currentPlayerIndex = 0;
private Player winner = null;
GameManager(Board board, List<Player> players, Dice dice, OvershootRule overshootRule) {
if (players.size() < 2) throw new IllegalArgumentException("Need at least 2 players");
this.board = board;
this.players = players;
this.dice = dice;
this.overshootRule = overshootRule;
}
void subscribe(GameObserver observer) { observers.add(observer); }
void playTurn() {
if (isGameOver()) throw new IllegalStateException("Game already over");
Player current = players.get(currentPlayerIndex);
int roll = dice.roll();
int rawTarget = overshootRule.apply(current.getPosition(), roll, board.getFinalCell());
int resolved = board.resolveJumps(rawTarget);
boolean snakeBite = resolved < rawTarget;
boolean ladderClimb = resolved > rawTarget;
int from = current.getPosition();
current.setPosition(resolved);
notifyMove(new MoveEvent(current, from, resolved, snakeBite, ladderClimb));
if (resolved == board.getFinalCell()) {
winner = current;
notifyWin(new WinEvent(current));
return;
}
currentPlayerIndex = (currentPlayerIndex + 1) % players.size();
}
boolean isGameOver() { return winner != null; }
Player getWinner() { return winner; }
private void notifyMove(MoveEvent e) { observers.forEach(o -> o.onMove(e)); }
private void notifyWin(WinEvent e) { observers.forEach(o -> o.onWin(e)); }
}5. Design Patterns Applied
| Pattern | Where used | Why |
|---|---|---|
| Strategy | Dice (RandomDice vs. FixedSequenceDice), OvershootRule (StayInPlaceRule vs. BounceBackRule) | Randomness source and overshoot behavior both vary independently of the turn loop — injecting them keeps GameManager testable and rule-set configurable. |
| Observer | GameObserver notified on onMove/onWin | Decouples "the game state changed" from "the UI redraws, a logger writes, an analytics event fires" — GameManager never imports a UI class. |
| State (implicit) | GameManager.isGameOver() gating further playTurn() calls | A minimal two-state machine (in-progress → over) rather than scattering winner != null checks. |
| Template-ish / single map | Board.jumpMap unifying snakes and ladders | Not a GoF pattern by name, but worth calling out: treating "snake" and "ladder" as the same underlying concept (a directed jump from one cell to another) collapses two branches of logic into one resolveJumps call. |
6. Key Algorithms, Concurrency & Edge Cases
Snake/ladder mapping as one HashMap
The single biggest design decision in this problem: don't model snake-check and ladder-check as two separate lookups with two separate if branches. Both are "landing on cell X redirects you to cell Y" — represent both as entries in one Map<Integer, Integer> and resolve with a single loop:
int resolveJumps(int cell) {
Set<Integer> visited = new HashSet<>();
while (jumpMap.containsKey(cell)) {
if (!visited.add(cell)) throw new IllegalStateException("Cyclic jump at cell " + cell);
cell = jumpMap.get(cell);
}
return cell;
}This loop is the chained-snake/ladder handling for free — landing on a ladder that leads to a cell that is itself a snake head resolves correctly with no special-casing, because the while just keeps following the map until it lands on a cell with no further jump.
The overshoot edge case
This is the rule candidates most often get wrong or forget entirely. On a 100-cell board, a player at cell 98 rolling a 5 would land on cell 103 — which doesn't exist. Two standard resolutions, both implemented above as swappable strategies:
- Stay in place (most common house rule): the roll is wasted; the player doesn't move at all.
- Bounce back: move to the final cell, then reflect the excess backward — 98 + 5 = 103, final cell is 100, excess is 3, so the piece lands on 97.
Whichever rule you pick, apply it before checking for a snake/ladder jump, and apply the jump resolution to the overshoot-adjusted position — not the raw roll target. Landing exactly on the final cell always wins outright; a bounce-back or stay-in-place result should still be checked against jumpMap since it can land you on a snake or ladder square too.
Testable randomness via dependency injection
@Test
void playerWinsOnExactRoll() {
Board board = new Board(100, List.of(), List.of());
Player p1 = new Player("1", "Alice");
Player p2 = new Player("2", "Bob");
Dice scripted = new FixedSequenceDice(List.of(6, 6, /* ... */ 4)); // deterministic path to 100
GameManager game = new GameManager(board, List.of(p1, p2), scripted, new StayInPlaceRule());
while (!game.isGameOver()) game.playTurn();
assertEquals("Alice", game.getWinner().getName());
}Because Dice is an interface, there is zero flakiness in tests — no Random seed juggling, no retries for "the dice happened to roll a 6." This is the concrete payoff of Strategy/DI shown in code rather than asserted in prose.
Other edge cases worth naming
- Cyclic or overlapping configuration: a snake head that is also a ladder bottom, or a jump chain that loops back on itself —
resolveJumps'svisitedset converts an infinite loop into a fast, diagnosable exception at construction/runtime rather than a hang. - Landing exactly on a snake head vs. passing over it: only the exact landing cell triggers a jump — passing through cell 17 while moving from 15 to 20 does nothing, since movement isn't simulated square-by-square, only the destination cell is evaluated.
- N players, not just 2:
currentPlayerIndex = (currentPlayerIndex + 1) % players.size()generalizes the turn rotation to any player count without special-casing 2-player games. - Player skips a turn (house rule: rolling three 6s in a row forfeits the turn): naturally extends by having
playTurn()track consecutive-6 count per player and short-circuit before applying the move — doesn't require restructuring the core loop.
7. Trade-offs & Extensions
| Decision | Trade-off |
|---|---|
| 1D map/array board representation | Trivial move math (position + roll), but the rendering layer must own the row/col ↔️ linear-index conversion — a reasonable and common split of concerns, not a shortcut. |
Single jumpMap for both snakes and ladders | Collapses two rule branches into one lookup; loses the ability to trivially answer "how many snakes are on this board" without also filtering by destination < source — an acceptable trade for simplicity. |
| Synchronous, single-threaded turn loop | Correct and simple for a local/turn-based game; a real-time multiplayer version (players on different devices) would need playTurn() gated by whose actual turn it is server-side, plus a message queue rather than a direct method call. |
| Overshoot behavior as an injected strategy | Slightly more ceremony than a single if statement, but makes the rule variant a one-line constructor change instead of a code edit — worth it since house rules genuinely vary. |
Natural extensions an interviewer may probe:
- N-player boards with player collision rules (landing on another player sends them back to start) — extend
GameManager.playTurn()to check occupied cells after resolving jumps. - Power-up cells (extra roll, skip opponent's turn) — model as another
Map<Integer, CellEffect>alongsidejumpMap, resolved the same way. - Networked multiplayer —
GameManagerbecomes a server-side session;GameObserverimplementations push events over WebSocket instead of updating local UI. - Replay/undo — since
MoveEventalready capturesfromCell/toCell, persisting the event stream gives you a free replay log; true undo would needGameManagerto snapshot player positions before eachplayTurn().
Interview Questions
- Why is a 1D array/map a better fit for board logic than a 2D grid, even though the board visually displays as 2D?
- Walk through how
resolveJumpshandles a chained snake-then-ladder (or ladder-then-snake) landing in a single pass. - What's the concrete testing benefit of making
Dicean interface instead of callingRandomdirectly insideGameManager? - Explain both standard resolutions for the overshoot rule (stay-in-place vs. bounce-back) and where in the turn sequence the check belongs.
- Why does
Boarduse a singlejumpMapinstead of separatesnakeMapandladderMapstructures? - How would you detect and reject a misconfigured board where a snake head is also a ladder bottom?
- How would you extend this design to support a "landing on an occupied cell sends that player back to start" house rule?