Design Snake and Ladder

Easyobject-oriented design · design patterns · strategy pattern · game design · java⏱ 12 min readAsked at Amazon, Microsoft, Flipkart, Swiggy, PaytmUpdated

Design a configurable Snake and Ladder game with a board of jumps, pluggable dice, turn rotation, win rules and a deterministic, testable game loop.

Requirements

Snake and Ladder is a favourite machine-coding round because it is small enough to finish in 45 minutes yet exposes how you separate configuration, rules and randomness. Pin the rules first, since households play it differently.

Functional

  • A board of N cells (100 by default) numbered 1..N. Every player starts off the board at position 0.
  • The board has snakes (head to tail, moving down) and ladders (bottom to top, moving up).
  • Two or more players take turns in a fixed order, rolling one or more dice.
  • After a roll the token moves forward; if it lands on a snake head or ladder bottom it jumps to the other end.
  • Exact finish rule: a roll that would go past N is wasted and the player stays put.
  • The first player to reach exactly N wins. Optionally keep playing to rank everyone.
  • Optional house rules: rolling a six gives another turn; three sixes in a row forfeits the turn.

Non-functional

  • Board setup must be validated (no snake and ladder on the same cell, no jump starting at 1 or N, no cycles).
  • Randomness must be injectable so tests and replays are deterministic.
  • Rules (dice count, extra turns) should change without editing the game loop.

Out of scope (confirm)

  • UI rendering, online multiplayer, persistence.
Tip

Ask "what happens on an overshoot?" and "does a six give an extra turn?". These two rules change the code more than anything else, and asking them shows you think about edge cases before typing.

Core Entities

Entity Responsibility Key fields
Jump (abstract) A start cell and an end cell start, end
Snake, Ladder Concrete jumps; constructor enforces direction head > tail, bottom < top
Board Size plus a map start -> Jump; resolves the final cell after landing size, jumps
BoardBuilder Fluent setup with validation addSnake, addLadder, build
Dice Rolls a number interface
StandardDice, FixedDice Random dice with seed; scripted dice for tests count, faces
Player Name and current position name, position
TurnResult Immutable record of one move, useful for logs and replay player, roll, from, to, jump
RuleSet House rules: extra turn on six, max consecutive sixes flags
Game Turn queue, applying rules, detecting winners board, dice, players, winners

Snakes and ladders behave identically at runtime (land on start, go to end), so a single Jump hierarchy keeps Board free of if (snake) ... else if (ladder) branches. The subclasses exist only for validation and readable output.

Class Diagram

classDiagram
  class Jump {
    <<abstract>>
    -int start
    -int end
    +kind() String
  }
  class Snake
  class Ladder
  Jump <|-- Snake
  Jump <|-- Ladder

  class Board {
    -int size
    -Map jumps
    +finalPosition(int) int
    +jumpAt(int) Jump
  }
  class BoardBuilder {
    +addSnake(int, int) BoardBuilder
    +addLadder(int, int) BoardBuilder
    +build() Board
  }
  class Dice {
    <<interface>>
    +roll() int
  }
  class StandardDice
  class FixedDice
  Dice <|.. StandardDice
  Dice <|.. FixedDice

  class Player {
    -String name
    -int position
  }
  class RuleSet {
    -boolean extraTurnOnSix
    -int maxConsecutiveSixes
  }
  class TurnResult {
    -Player player
    -int roll
    -int from
    -int to
  }
  class Game {
    -Board board
    -Dice dice
    -Deque players
    -List winners
    +playTurn() TurnResult
    +playUntilWinner() Player
  }

  Board "1" *-- "many" Jump
  BoardBuilder ..> Board : builds
  Game --> Board
  Game --> Dice
  Game --> RuleSet
  Game "1" o-- "2..*" Player
  Game ..> TurnResult : creates

Design Patterns Used

Pattern Where Why it is justified
Strategy Dice interface Two dice, a loaded die for a "power-up" mode, or a scripted die in tests all plug into the same loop
Builder BoardBuilder Boards have many optional parts; the builder validates the whole configuration once before a Board exists, so Board can be immutable
Polymorphism over conditionals Jump subclasses Board treats every jump uniformly; adding a "portal" or "teleport" is a new subclass
Queue for turn order ArrayDeque<Player> Poll the current player, push them to the back; finished players simply are not re-added
Value objects TurnResult (record) Each turn is an immutable event you can print, assert in tests, or replay

Validation the builder enforces

  • Jump endpoints lie in [2, N-1]: a ladder from 1 is pointless, and a snake on N would make winning impossible.
  • At most one jump starts at any cell.
  • A jump must not end on the start of another jump. Chained jumps are legal in some variants, but forbidding them rules out cycles like a ladder 10 to 40 and a snake 40 to 10, which would loop forever.
Insight

Storing jumps in a Map<Integer, Jump> keyed by start cell makes landing resolution O(1). A 100-cell int[] next array is equally good and even simpler; both beat scanning a list of snakes and ladders on every move.

Java Implementation

The code compiles as a single Main.java (Java 17+). The demo first uses FixedDice with a scripted sequence to show every rule (ladder, snake, extra turn on six, overshoot, three sixes, win), then plays a full seeded random game.

java
import java.util.*;

abstract class Jump {
    private final int start, end;
    Jump(int start, int end) { this.start = start; this.end = end; }
    int start() { return start; }
    int end() { return end; }
    abstract String kind();
    @Override public String toString() { return kind() + " " + start + "->" + end; }
}

class Snake extends Jump {
    Snake(int head, int tail) {
        super(head, tail);
        if (tail >= head) throw new IllegalArgumentException("Snake must go down: " + head + "->" + tail);
    }
    String kind() { return "snake"; }
}

class Ladder extends Jump {
    Ladder(int bottom, int top) {
        super(bottom, top);
        if (top <= bottom) throw new IllegalArgumentException("Ladder must go up: " + bottom + "->" + top);
    }
    String kind() { return "ladder"; }
}

final class Board {
    private final int size;
    private final Map<Integer, Jump> jumps;

    Board(int size, Map<Integer, Jump> jumps) { this.size = size; this.jumps = Map.copyOf(jumps); }

    int size() { return size; }
    Jump jumpAt(int cell) { return jumps.get(cell); }
    int finalPosition(int cell) {
        Jump j = jumps.get(cell);
        return j == null ? cell : j.end();
    }
}

class BoardBuilder {
    private final int size;
    private final List<Jump> pending = new ArrayList<>();

    BoardBuilder(int size) {
        if (size < 10) throw new IllegalArgumentException("Board too small");
        this.size = size;
    }

    BoardBuilder addSnake(int head, int tail) { pending.add(new Snake(head, tail)); return this; }
    BoardBuilder addLadder(int bottom, int top) { pending.add(new Ladder(bottom, top)); return this; }

    Board build() {
        Map<Integer, Jump> byStart = new HashMap<>();
        for (Jump j : pending) {
            for (int cell : new int[]{j.start(), j.end()})
                if (cell < 2 || cell > size - 1) throw new IllegalArgumentException("Out of range: " + j);
            if (byStart.putIfAbsent(j.start(), j) != null)
                throw new IllegalArgumentException("Two jumps start at " + j.start());
        }
        for (Jump j : pending)
            if (byStart.containsKey(j.end()))
                throw new IllegalArgumentException(j + " ends on the start of " + byStart.get(j.end()));
        return new Board(size, byStart);
    }
}

interface Dice { int roll(); }

class StandardDice implements Dice {
    private final int count, faces;
    private final Random random;
    StandardDice(int count, int faces, long seed) { this.count = count; this.faces = faces; this.random = new Random(seed); }
    public int roll() {
        int sum = 0;
        for (int i = 0; i < count; i++) sum += 1 + random.nextInt(faces);
        return sum;
    }
}

class FixedDice implements Dice {
    private final Iterator<Integer> rolls;
    FixedDice(Integer... rolls) { this.rolls = Arrays.asList(rolls).iterator(); }
    public int roll() { return rolls.next(); }
}

class Player {
    private final String name;
    private int position;
    Player(String name) { this.name = name; }
    String name() { return name; }
    int position() { return position; }
    void moveTo(int cell) { position = cell; }
    @Override public String toString() { return name; }
}

record RuleSet(boolean extraTurnOnSix, int maxConsecutiveSixes) {
    static RuleSet classic() { return new RuleSet(true, 3); }
}

record TurnResult(Player player, int roll, int from, int to, Jump jump, String note) {
    @Override public String toString() {
        String s = player + " rolled " + roll + ": " + from + " -> " + to;
        if (jump != null) s += " (" + jump + ")";
        return note.isEmpty() ? s : s + " [" + note + "]";
    }
}

class Game {
    private final Board board;
    private final Dice dice;
    private final RuleSet rules;
    private final Deque<Player> queue = new ArrayDeque<>();
    private final List<Player> winners = new ArrayList<>();
    private int sixesInRow;
    private int startOfStreak;

    Game(Board board, Dice dice, RuleSet rules, List<Player> players) {
        if (players.size() < 2) throw new IllegalArgumentException("Need at least two players");
        this.board = board;
        this.dice = dice;
        this.rules = rules;
        queue.addAll(players);
    }

    boolean isOver() { return queue.size() < 2 || !winners.isEmpty(); }
    List<Player> winners() { return List.copyOf(winners); }

    TurnResult playTurn() {
        if (isOver()) throw new IllegalStateException("Game is over");
        Player p = queue.peekFirst();
        int roll = dice.roll();
        int from = p.position();
        if (sixesInRow == 0) startOfStreak = from;

        if (roll == 6 && rules.extraTurnOnSix() && ++sixesInRow == rules.maxConsecutiveSixes()) {
            p.moveTo(startOfStreak);   // the whole streak is cancelled
            endTurn(p);
            return new TurnResult(p, roll, from, startOfStreak, null, "third six, back to " + startOfStreak);
        }

        int target = from + roll;
        String note = "";
        Jump jump = null;
        if (target > board.size()) {
            target = from;
            note = "overshoot, stays";
        } else {
            jump = board.jumpAt(target);
            target = board.finalPosition(target);
        }
        p.moveTo(target);

        if (target == board.size()) {
            winners.add(p);
            queue.pollFirst();
            sixesInRow = 0;
            note = "WINS";
        } else if (roll == 6 && rules.extraTurnOnSix()) {
            note = note.isEmpty() ? "extra turn" : note + ", extra turn";
        } else {
            endTurn(p);
        }
        return new TurnResult(p, roll, from, target, jump, note);
    }

    private void endTurn(Player p) {
        sixesInRow = 0;
        queue.pollFirst();
        queue.addLast(p);
    }

    Player playUntilWinner(int maxTurns) {
        for (int i = 0; i < maxTurns && !isOver(); i++) playTurn();
        return winners.isEmpty() ? null : winners.get(0);
    }
}

public class Main {
    static Board classicBoard() {
        return new BoardBuilder(100)
                .addLadder(4, 14).addLadder(9, 31).addLadder(21, 42).addLadder(28, 84)
                .addLadder(51, 67).addLadder(72, 91).addLadder(80, 99)
                .addSnake(17, 7).addSnake(54, 34).addSnake(62, 19).addSnake(64, 60)
                .addSnake(87, 36).addSnake(93, 73).addSnake(95, 75).addSnake(98, 79)
                .build();
    }

    public static void main(String[] args) {
        Board small = new BoardBuilder(30)
                .addLadder(3, 12).addLadder(8, 22).addSnake(18, 5).addSnake(27, 10).build();
        Player ann = new Player("Ann"), ben = new Player("Ben");
        Game scripted = new Game(small, new FixedDice(3, 6, 2, 6, 6, 6, 5, 4, 6, 4, 6, 4, 5, 6, 4),
                RuleSet.classic(), List.of(ann, ben));
        while (!scripted.isOver()) System.out.println(scripted.playTurn());
        System.out.println("Winner: " + scripted.winners());

        try {
            new BoardBuilder(100).addLadder(10, 40).addSnake(40, 10).build();
        } catch (IllegalArgumentException e) {
            System.out.println("Invalid board: " + e.getMessage());
        }

        Board board = classicBoard();
        List<Player> four = List.of(new Player("P1"), new Player("P2"), new Player("P3"), new Player("P4"));
        Game random = new Game(board, new StandardDice(1, 6, 2026), RuleSet.classic(), four);
        Player winner = random.playUntilWinner(10_000);
        System.out.print("Seeded game winner: " + winner + "; final positions:");
        for (Player p : four) System.out.print(" " + p + "=" + p.position());
        System.out.println();
    }
}

Demo output

text
Ann rolled 3: 0 -> 12 (ladder 3->12)
Ben rolled 6: 0 -> 6 [extra turn]
Ben rolled 2: 6 -> 22 (ladder 8->22)
Ann rolled 6: 12 -> 5 (snake 18->5) [extra turn]
Ann rolled 6: 5 -> 11 [extra turn]
Ann rolled 6: 11 -> 12 [third six, back to 12]
Ben rolled 5: 22 -> 10 (snake 27->10)
Ann rolled 4: 12 -> 16
Ben rolled 6: 10 -> 16 [extra turn]
Ben rolled 4: 16 -> 20
Ann rolled 6: 16 -> 22 [extra turn]
Ann rolled 4: 22 -> 26
Ben rolled 5: 20 -> 25
Ann rolled 6: 26 -> 26 [overshoot, stays, extra turn]
Ann rolled 4: 26 -> 30 [WINS]
Winner: [Ann]
Invalid board: ladder 10->40 ends on the start of snake 40->10
Seeded game winner: P4; final positions: P1=63 P2=99 P3=34 P4=100

Walkthrough of the scripted game (a 30-cell board keeps it short):

  • Ann rolls 3 and climbs the ladder 3 to 12. Ben's 6 grants an extra turn; his follow-up 2 lands on the ladder 8 to 22 and ends the turn.
  • Ann then rolls three sixes in a row. The third six cancels the whole streak, so she returns to 12, where the streak began, even though the first six had sent her down a snake.
  • At 26, Ann's 6 would pass 30, so it is wasted (she still earns the extra turn); the exact 4 then wins.
  • The invalid board is rejected because the ladder ends on the snake's head, which would form a loop.
  • The seeded four-player game on the classic 100-cell board is reproducible: the same seed always yields the same winner.

Concurrency Considerations

A local Snake and Ladder game is turn-based and single-threaded, so the honest answer is that the core needs no locks. Say so, then explain what changes when it runs on a server.

  • One writer per game. Route every action for a game ID to a single thread or actor (or guard playTurn with a per-game lock). Turn order then never interleaves.
  • Validate the actor. A server must reject "roll" requests from anyone other than queue.peekFirst(), otherwise a fast client could roll on someone else's turn.
  • Idempotent requests. Mobile clients retry. Include a turn number in each request and ignore stale or duplicate ones, so one tap never moves a token twice.
  • Server-side randomness. Dice must be rolled on the server with a secure source (SecureRandom), never trusted from the client.
  • Immutable board. Board uses Map.copyOf, so thousands of concurrent games can share one board instance safely.
Warning

Do not share one java.util.Random across many game threads in a server. It is thread-safe but contended; use ThreadLocalRandom or one generator per game, and keep a per-game seed if you need replays.

Extensions

Requirement Change
Rank every player, not just the winner Keep calling playTurn until one player remains; winners already records the order (relax isOver)
Two dice or custom dice Construct StandardDice(2, 6, seed) or add a new Dice implementation
Must roll a six to start A RuleSet flag checked when position is 0
Special cells (bonus roll, skip turn, portal) New Jump-like Cell effects through a CellEffect interface
Undo or replay Store the TurnResult list; replay by feeding the rolls into FixedDice
Spectators and UI Observer: publish each TurnResult to listeners or a message channel
Random board generation A BoardGenerator that keeps calling the builder until validation passes

Interview Tips

  • Clarify the overshoot and extra-turn rules before coding. Write them as named flags in RuleSet.
  • Model snakes and ladders as one Jump concept; interviewers like seeing polymorphism replace an if chain.
  • Inject Dice. Being able to say "I can unit test any scenario with FixedDice" is a strong signal.
  • Validate configuration up front in a builder rather than discovering a broken board mid-game.
  • Use a deque for turn rotation; it handles players finishing and leaving without index arithmetic.
  • Keep the game loop free of System.out; return TurnResult objects and let the caller print them.

References

My notes saved in this browser