Snake & Ladder LLD: One Map, One Die, and a Game You Can Test
A low-level design walkthrough of snake and ladder: why snakes and ladders are one Map, how injecting the die makes the game testable, and the complete implementation with a clean turn loop.
"Design Snake & Ladder." Most candidates hear this and immediately write class Snake and class Ladder — two classes, two lists, twin code paths for sliding down and climbing up. Twenty minutes later they're knee-deep in duplication, and the interviewer hasn't even asked the real question yet: "how would you test a game built on dice rolls?"
Both traps have one-line answers, and they're the whole article: a snake and a ladder are the same thing, and randomness you don't inject is randomness you can't test. Let's build the game properly — it's shorter than the broken version.
This is queue stop #3 in the games series — after Tic-Tac-Toe (where the lesson was a tiny cast) and the Vending Machine (state machines). Today's lessons: spotting twins, and taming dice.
Let's start nowhere near a computer
Unfold the board from your childhood and look at it like a designer. What is a ladder, really? You land on square 2, and someone instantly moves your token to square 8. And a snake? You land on 9, and someone moves your token to 4.
Same event. Land here → get moved there. The artwork is different — one's painted as a ladder, one as a serpent — but the rule is identical, and code models rules, not artwork:
One Map<Integer, Integer> holds every snake and every ladder on the board. Whether an entry is a "snake" or a "ladder" is just whether it sends you backwards or forwards — a fact you can derive, not a type you must build.
Where these two lessons keep paying
- The twin trap is everywhere. Credits/debits, follows/blocks, upvotes/downvotes — interviewers love domains with two things that are secretly one thing with a sign flip.
- The dice problem is every external dependency. A die is just
System.currentTimeMillis()wearing dots — the parking lot injected aClockfor exactly the same reason we'll inject aDie. - Turn rotation — round-robin over a queue — is the same shape as schedulers and load balancers handing out work.
Step 1 — Functional requirements (sentences first)
What the game must do, as plain sentences — the functional requirements.
- Two or more players take turns rolling a die and moving forward by the roll.
- Landing at the bottom of a ladder lifts you to its top; landing on a snake's head drops you to its tail.
- A player must land exactly on the last square to win; overshooting means staying put.
- The first player to reach the last square wins, and the game ends.
(That third sentence is a house rule — some boards bounce you backwards instead. Saying "I'm choosing stay-put; bounce-back is a one-line change" is worth more than either rule.)
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well the engine behaves, not just what it does — and for a game built on dice they decide whether your code survives the interviewer's real questions:
- Correctness. A roll moves the token by exactly that many squares; landing on a jump mouth teleports you to its far end once, with no chaining; the win demands an exact landing on the final square, and an overshoot stays put. The rules are the spec, and the spec must hold on every edge.
- Performance. A turn is O(1). The jump is a direct map lookup — never a scan of a snake list and a ladder list looking for a match.
- Extensibility. Board size, the snake/ladder layout, the number of die faces, and the player count are all data and parameters, not numbers baked into the engine.
- Testability. A seeded or scripted die makes a whole game reproducible — given the rolls, every turn is deterministic, so any scenario (the climb, the bite, the overshoot, the photo-finish) is a repeatable test.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | jumps apply once on landing, the win-check runs after the jump, overshoot stays put — Step 6 |
| Performance | snakes and ladders are one Map<Integer,Integer>; a jump is one O(1) getOrDefault — Step 4 |
| Extensibility | board size, jump layout, die faces, and players all arrive through constructors — Steps 4, 5 |
| Testability | the die is an injected Die interface; a scripted die makes the whole game deterministic — Step 4 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns → classes (and the twins collapse)
The nouns: board, square, snake, ladder, player, die, game. After the twin insight and Tic-Tac-Toe's "don't invent nouns" lesson, the cast shrinks fast:
- Snake, Ladder → entries in the board's jump map. No classes.
- Square → an
int. Positions are numbers, not objects. - Player → a name in a turn queue plus a position in a map. No class needed yet.
- Board → yes: it owns the size, the jump map, and the validation (more below).
- Die → yes, and it's the most important interface in the design.
- Game → the conductor: turn order, positions, status.
Which structure — and why. The jumps live in a single Map<Integer, Integer> (square → destination), and that's the load-bearing choice, not a default. Two lists — a snake list and a ladder list — would force a search on every landing to ask "is this a mouth?", turning each turn into a scan; the map answers in one getOrDefault, which is the performance NFR earned directly. The same map is also why extensibility is free: a different board is a different map literal, no engine change. Turn order is an ArrayDeque<String> because rotation is exactly "take from the front, push to the back" — the round-robin queue — and positions are a Map<String, Integer> so a player is just a name and a number, never a class you didn't need.
Step 4 — The die is the design decision
Here's the question that separates candidates: your game depends on randomness — how will you ever write a test that asserts anything? A test that calls new Random() can't predict a single outcome.
The answer is one tiny interface:
public interface Die {
int roll();
}Production plugs in RandomDie. Tests plug in a loaded die — a die that rolls exactly the script you give it — and suddenly every scenario is reachable: the ladder climb, the snake bite, the overshoot, the photo-finish win. This is the Strategy seam again, but with a sharper motivation than "flexibility": determinism on demand.
Generalize the instinct: clocks, dice, UUIDs, network calls — anything you
don't control gets an interface and arrives through the constructor. The
parking lot injected Clock; we inject Die; production code injects the
world. Your tests script it.
Step 5 — The board validates at birth
Jump maps can encode nonsense: a ladder starting on the final square, a snake whose head and tail are the same cell, or the sneaky one — a chain, where a ladder drops you onto a snake's head. Does the snake then trigger? Different house rules disagree, which is exactly why your constructor should refuse the ambiguity:
if (from == to) {
throw new IllegalArgumentException("a jump must go somewhere: " + from);
}
if (from <= 1 || from >= lastSquare || to < 1 || to > lastSquare) {
throw new IllegalArgumentException("jump out of bounds: " + from + " → " + to);
}
if (jumps.containsKey(to)) {
throw new IllegalArgumentException("chained jumps: " + from + " → " + to + " → …");
}Fail at construction, not mid-game — a board that exists is a board that's legal. (If your interviewer wants chains, this is one while loop on landing; offer it as the variant.)
Step 6 — One turn, every rule in order
public TurnResult takeTurn() {
if (status != Status.IN_PROGRESS) {
throw new IllegalStateException("game over — " + winner + " already won");
}
String player = turnOrder.peekFirst();
int roll = die.roll();
int from = positions.get(player);
int target = from + roll;
int to = from; // overshoot? stay exactly where you were
if (target <= board.lastSquare()) {
to = board.destination(target); // jumps apply where you LAND
}
positions.put(player, to);
if (to == board.lastSquare()) {
status = Status.WON;
winner = player;
} else {
turnOrder.addLast(turnOrder.pollFirst()); // rotate to the next player
}
return new TurnResult(player, roll, from, to, status);
}Three details worth narrating out loud:
- Jumps apply on landing, once. The
destination()lookup happens after the move, never after another jump — the chain question was settled at construction. - The winner check runs after the jump — so a ladder whose top is the last square wins the game, which players consider the best possible ending and broken implementations consider a surprise.
TurnResultis a record, not console output. The game returns facts; whoever's driving (CLI, UI, test) decides how to show them. Games thatSystem.out.printlninside the engine can't be tested either.
Step 7 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
one jump Map<Integer,Integer> | a Snake list and a Ladder list | a landing is one O(1) lookup, not a scan of two lists, and zero duplication | performance |
| validate jumps in the constructor | check legality mid-turn | a board that exists is legal; bad layouts and chains die at birth, not at 2 a.m. | correctness |
Die as an injected interface | new Random() inside takeTurn | a scripted die makes every game reproducible and every scenario a test | testability |
| board size and players as params | a hard-coded 100-square, 2-player | a new board or player count is a constructor argument, not an edit | extensibility |
Growth — when the board gets bigger or the rules get weirder. Nothing here cares whether the board is 100 squares or 10,000: the map lookup is O(1) regardless, and a turn touches one player's position. The house rules that interviewers reach for — bounce-back on overshoot, roll-a-six-go-again, chained jumps — are each a few lines because the turn lives in one method and the jumps live in one map. You don't make the engine cleverer to grow it; you hand it different data.
The complete implementation
package dev.fiveyear.snakeladder;
import java.util.Random;
public interface Die {
int roll();
}
public final class RandomDie implements Die {
private final Random random = new Random();
@Override
public int roll() {
return random.nextInt(6) + 1;
}
}package dev.fiveyear.snakeladder;
import java.util.Map;
/** The squares and the jump map — snakes and ladders, unified. */
public final class Board {
private final int lastSquare;
private final Map<Integer, Integer> jumps;
public Board(int lastSquare, Map<Integer, Integer> jumps) {
if (lastSquare < 10) {
throw new IllegalArgumentException("board too small: " + lastSquare);
}
for (Map.Entry<Integer, Integer> jump : jumps.entrySet()) {
int from = jump.getKey();
int to = jump.getValue();
if (from == to) {
throw new IllegalArgumentException("a jump must go somewhere: " + from);
}
if (from <= 1 || from >= lastSquare || to < 1 || to > lastSquare) {
throw new IllegalArgumentException("jump out of bounds: " + from + " → " + to);
}
if (jumps.containsKey(to)) {
throw new IllegalArgumentException(
"chained jumps: " + from + " → " + to + " → " + jumps.get(to));
}
}
this.lastSquare = lastSquare;
this.jumps = Map.copyOf(jumps);
}
int lastSquare() {
return lastSquare;
}
/** Where you end up after landing on a square — itself, or a jump's far end. */
int destination(int square) {
return jumps.getOrDefault(square, square);
}
}package dev.fiveyear.snakeladder;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class SnakeAndLadder {
public enum Status { IN_PROGRESS, WON }
public record TurnResult(String player, int roll, int from, int to, Status status) {}
private final Board board;
private final Die die;
private final ArrayDeque<String> turnOrder = new ArrayDeque<>();
private final Map<String, Integer> positions = new HashMap<>();
private Status status = Status.IN_PROGRESS;
private String winner;
public SnakeAndLadder(Board board, Die die, List<String> players) {
if (players.size() < 2) {
throw new IllegalArgumentException("need at least 2 players");
}
this.board = board;
this.die = die;
for (String player : players) {
turnOrder.addLast(player);
positions.put(player, 0); // everyone starts off the board
}
}
public TurnResult takeTurn() {
if (status != Status.IN_PROGRESS) {
throw new IllegalStateException("game over — " + winner + " already won");
}
String player = turnOrder.peekFirst();
int roll = die.roll();
int from = positions.get(player);
int target = from + roll;
int to = from;
if (target <= board.lastSquare()) {
to = board.destination(target);
}
positions.put(player, to);
if (to == board.lastSquare()) {
status = Status.WON;
winner = player;
} else {
turnOrder.addLast(turnOrder.pollFirst());
}
return new TurnResult(player, roll, from, to, status);
}
public Status status() {
return status;
}
public String winner() {
return winner;
}
public int positionOf(String player) {
return positions.get(player);
}
}And a scripted game — possible only because the die is injected:
/** The test double from the diagram — a die that rolls your script. */
final class LoadedDie implements Die {
private final Queue<Integer> rolls;
LoadedDie(Integer... script) { this.rolls = new ArrayDeque<>(List.of(script)); }
public int roll() { return rolls.remove(); }
}
Board board = new Board(100, Map.of(4, 25, 27, 9, 96, 100));
Die loaded = new LoadedDie(4, 2, 6); // rolls exactly this script
SnakeAndLadder game = new SnakeAndLadder(board, loaded, List.of("asha", "dev"));
game.takeTurn(); // asha rolls 4 → lands on 4 → LADDER → 25
game.takeTurn(); // dev rolls 2 → 2
game.takeTurn(); // asha rolls 6 → 31. every assertion above is testable.The interview corner
Clarify before you code: Does winning require exact landing, or does overshoot win too? Fixed player order? Can a snake's tail sit on another ladder's base (chains)?
The follow-up ladder:
- "Overshoot bounces back."
position = 100 − (position + roll − 100)— one line, and the rule reads like the rulebook. - "Jumps can chain." While-loop the jump map — with a visited set so a snake-ladder cycle is rejected at board construction, not discovered at 2 a.m.
- "Rolling a six grants another turn." Don't advance the player index — the turn loop owns whose-turn-is-it, nothing else does.
- "Two dice? A weighted die?" The injected
Dieinterface pays again: sum two rolls, or swap in a weighted implementation; the game never knows. - "Replay a finished game." The scripted die plus recorded
TurnResults make replay free — this is why determinism was a design goal, not a test trick.
Mistakes that fail the round: twin Snake and Ladder classes for one map; new Random() born inside takeTurn; checking the win before applying the jump on the landing square.
Where to go from here
Pocket version: twins collapse into one map, randomness arrives through an interface, jumps validate at birth, and the engine returns facts instead of printing them.
- Add the house rules — roll-a-six-go-again, bounce-back overshoot, three sixes void your turn. Each is a few lines because the turn loop is one method.
- Next in the queue: 2048 — where one merge function plays all four directions, and the randomness lesson gets a second exam.
- The recipe, if you're landing here first: A Rookie's Guide to LLD.
Next family games night, when someone's token hits the big snake on 87, you'll see it for what it really is: a map entry with good art direction.