Design Snake and Ladder
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
Ncells (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.
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.
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.
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
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
playTurnwith 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.
BoardusesMap.copyOf, so thousands of concurrent games can share one board instance safely.
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
Jumpconcept; interviewers like seeing polymorphism replace anifchain. - Inject
Dice. Being able to say "I can unit test any scenario withFixedDice" 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; returnTurnResultobjects and let the caller print them.