06-lld-interview-problems

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.

August 11, 2026
lldsnake-ladderboard-gameturn-managementgame-loopmapping

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

ActorDescription
PlayerTakes turns rolling the dice and moving their piece.
Game ManagerOwns the turn loop, applies board rules, checks the win condition.
Spectator / UIObserves move and win events without participating in turns.

Primary use cases

  1. Take a turn — current player rolls dice → piece advances → snake/ladder resolved (possibly chained) → turn passes to next player.
  2. Check win — after each move, if a piece lands exactly on the final cell, that player wins and the game ends.
  3. 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.
  4. 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

java
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

java
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

java
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

java
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

java
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

java
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

PatternWhere usedWhy
StrategyDice (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.
ObserverGameObserver notified on onMove/onWinDecouples "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() callsA minimal two-state machine (in-progress → over) rather than scattering winner != null checks.
Template-ish / single mapBoard.jumpMap unifying snakes and laddersNot 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:

java
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

java
@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's visited set 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

DecisionTrade-off
1D map/array board representationTrivial 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 laddersCollapses 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 loopCorrect 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 strategySlightly 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> alongside jumpMap, resolved the same way.
  • Networked multiplayerGameManager becomes a server-side session; GameObserver implementations push events over WebSocket instead of updating local UI.
  • Replay/undo — since MoveEvent already captures fromCell/toCell, persisting the event stream gives you a free replay log; true undo would need GameManager to snapshot player positions before each playTurn().

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 resolveJumps handles a chained snake-then-ladder (or ladder-then-snake) landing in a single pass.
  • What's the concrete testing benefit of making Dice an interface instead of calling Random directly inside GameManager?
  • 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 Board use a single jumpMap instead of separate snakeMap and ladderMap structures?
  • 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?