Design a snake & ladder game — full class-level solution. — Cracked Java
// Low-Level Design (LLD / OOD) · Design a Snake & Ladder Game
MidSystem Design

Design a snake & ladder game — full class-level solution.

1. Functional requirements

  • A square board of N cells (typically 100), cell 1 to cell N.
  • Snakes (head → lower tail) and ladders (bottom → higher top) placed on the board.
  • Two or more players take turns in a fixed order.
  • On a turn, a player rolls a die, advances, and — if landing on a snake head or ladder bottom — is teleported to the other end.
  • A player wins by reaching the final cell; configurable exact-roll-to-win vs overshoot.
  • The game reports the winner and the turn sequence.

2. Non-functional requirements

  • Extensibility — swapping the die (fair, loaded, multi-die) must not touch the game loop (Open/Closed).
  • Testability — a deterministic/seeded die makes the engine unit-testable.
  • Single responsibility — board owns jumps, dice owns randomness, game owns the turn loop; no class reaches into another's rules.
  • Lightweight — no concurrency or persistence required; resist over-engineering.

3. Core entities

EntityResponsibility
BoardSized grid; maps a cell to its jump destination (snake or ladder).
JumpUniform start → end move; a Snake jumps down, a Ladder jumps up.
DiceStrategy producing a roll value (fair, loaded, multi-die).
PlayerA participant with a name and current position.
GameEngine: turn order, the loop, applying jumps, declaring a winner.
MoveObserverOptional sink notified on each position change (logger/UI).

4. Class diagram

Snake and ladder class model

5. Key interfaces and classes

interface Dice {                       // Strategy: pluggable randomness
    int roll();
}

final class FairDice implements Dice {
    private final Random rng;
    private final int faces;
    FairDice(int faces, long seed) { this.faces = faces; this.rng = new Random(seed); }
    public int roll() { return 1 + rng.nextInt(faces); }   // seeded -> testable
}

final class LoadedDice implements Dice {                   // biased toward high rolls
    private final Random rng = new Random();
    private final int faces;
    LoadedDice(int faces) { this.faces = faces; }
    public int roll() { return Math.max(rng.nextInt(faces) + 1, rng.nextInt(faces) + 1); }
}

final class Jump {                     // a snake or a ladder, modeled uniformly
    final int start, end;
    Jump(int start, int end) { this.start = start; this.end = end; }
}

interface MoveObserver {               // Observer: logger / UI hook
    void onMove(Player p, int from, int to);
}

final class Player {
    final String name;
    private int position = 0;          // 0 = off-board start
    Player(String name) { this.name = name; }
    int position() { return position; }
    void moveTo(int p) { this.position = p; }
}
final class Board {
    private final int size;
    private final Map<Integer, Integer> jumps = new HashMap<>();  // start cell -> end cell

    Board(int size, List<Jump> jumpList) {
        this.size = size;
        for (Jump j : jumpList) jumps.put(j.start, j.end);        // snakes and ladders, uniformly
    }
    int size() { return size; }
    // Returns the cell to settle on after applying any jump that starts here.
    int destination(int cell) { return jumps.getOrDefault(cell, cell); }
}
final class Game {
    private final Board board;
    private final Dice dice;
    private final Queue<Player> players;                  // round-robin turn order
    private final List<MoveObserver> observers = new ArrayList<>();

    Game(Board board, Dice dice, List<Player> players) {
        this.board = board;
        this.dice = dice;
        this.players = new LinkedList<>(players);
    }

    void addObserver(MoveObserver o) { observers.add(o); }

    Player play() {
        while (true) {
            Player p = players.poll();                    // take turn
            int roll = dice.roll();
            int target = p.position() + roll;
            if (target <= board.size()) {                 // overshoot stays put (exact-win rule)
                int from = p.position();
                int landed = board.destination(target);   // apply snake/ladder
                p.moveTo(landed);
                observers.forEach(o -> o.onMove(p, from, landed));
                if (landed == board.size()) return p;     // winner
            }
            players.offer(p);                             // back of the queue
        }
    }
}

6. Design patterns used

  • StrategyDice hides the roll algorithm so a fair (seeded) die, a loaded die, or a multi-die sum can be swapped in without touching Game. It also makes the engine deterministically testable.
  • ObserverMoveObserver lets a logger, replay recorder, or UI react to each move without the Game knowing about them.
  • Factory — a BoardFactory (e.g. standardBoard(), fromConfig(...)) centralizes board construction so snake/ladder placement is defined in one place rather than littering the caller.

7. Trade-offs and alternatives

  • Uniform jumps vs separate Snake/Ladder classes. Storing both as start → end entries in one map collapses the logic to a single lookup. Separate Snake/Ladder subclasses read nicely but add no behavior — a textbook case where inheritance earns nothing. Prefer the map.
  • Exact-roll-to-win vs overshoot. Shown rule: an overshoot forfeits the move (stay put). Alternatives include "bounce back" from the final cell; clarify which the interviewer wants — getting this rule right signals attention to requirements.
  • Restraint over patterns. State or Command could be layered on, but for a board this simple they are over-engineering. Naming the seams (Dice, Observer) and declining to add more is the senior move.
  • Turn order structure. A Queue gives clean round-robin; a list with an index works too but is easier to get wrong on removal.

8. Common follow-up questions

  • Multiple players — already handled by the round-robin queue; scale is just more entries.
  • Custom / configurable board — drive snake/ladder placement from config via a BoardFactory; validate no two jumps share a start cell and no jump lands on the final cell.
  • Weighted / loaded dice — a LoadedDice Strategy; the engine is unchanged, which is the point of the abstraction.
  • Replay — record each onMove event via an observer; the seeded FairDice makes a full game reproducible.
  • Multiple dice / movement variants — a MultiDice summing several rolls slots in behind the same Dice interface.

9. What interviewers are really probing

Mark your status