The idea
What it is
“Design snakes and ladders.” There is no skill in this game. You roll, you move, you climb or you slide. A five-year-old plays it correctly. So what is there to design?
Exactly that. Because the rules are trivial, nothing hides behind them — the interviewer sees your modelling with no algorithm to distract from it. Three things get judged: how you represent the board, how you run the turn loop, and whether the randomness is something you can control in a test. Most candidates nail the first two and fail the third without noticing.
The whole system in three sentences
The board is an integer from 1 to 100 — the grid is only how it is drawn. A jump map turns any landing square into a destination, and snakes and ladders are the same map. A turn loop takes the next player from a queue, asks the dice for a number, moves, and stops when someone reaches 100.
The three things being graded
- Is the board a line? Modelling it as a 10×10 grid means converting coordinates on every move for no benefit. A player's position is one number.
- Are snakes and ladders one thing? They are both “landing here sends you there”. Two classes with identical fields and opposite comparisons is duplicate code with a costume on.
- Can you test it? If
Math.random()is called insideplayTurn(), you cannot write a single deterministic test of your own game. This is the one that separates candidates.
Mechanics
How it works
Step 1 · Clarify — 3 minutes
- How many players? — build a queue and it does not matter. Two, three, six: same code.
- Do you need an exact roll to land on 100? — this is a real house rule and it splits opinion. Ask, then make it a flag, because they will ask you to change it.
- Does a six give another turn? — another common house rule. Same answer: a rule, not a rewrite.
- Can two tokens share a square? — usually yes. If they say “no, you send the other player back to 1”, that is a genuinely different game and worth 30 seconds of scoping.
- Where do snakes and ladders come from? — configuration. A board with hardcoded jumps cannot be tested or varied.
The question that is really a design decision
“Should the game print the board?” If your Game class contains System.out.println, you cannot run it in a UI, a test, or a server. Keep the game silent and let the caller render — a two-second decision that keeps the whole design usable.
Step 2 · The board is a line
The physical board snakes back and forth — 1 to 10 left to right, then 11 to 20 right to left. That layout is a rendering detail. To the game, square 47 is just 47, and moving is position + roll.
Player has a row and a col, you have imported the drawing into the rules. One int position is the whole board.Step 3 · Snakes and ladders are the same thing
A snake takes you from 36 down to 6. A ladder takes you from 4 up to 14. Both are “if you land on from, go to to”. The only difference is the sign, and the sign is data.
jumps[4] = 14. One map, two behaviours, and the difference is arithmetic rather than inheritance.Should a jump chain?
Land on a ladder that ends on a snake's head — do you slide again? Real rules say no: one jump per turn. Say this out loud and make it a single if rather than a while. It is a two-word answer that shows you thought about the edge, and it is a genuinely ambiguous rule so the interviewer will not have a fixed answer either.
Step 4 · The dice — the part everybody gets wrong
Here is the line most candidates write, and it quietly ruins the design:
public void playTurn() {
int roll = 1 + new Random().nextInt(6); // <-- randomness welded into the game
...
}The game now cannot be tested. You cannot check “a player at 4 climbs to 14”, because you cannot make the dice show a 4. You cannot check “from 97 a roll of 5 forfeits the turn”. You cannot reproduce a reported bug. Every test you write is a coin toss.
interface Dice { int roll(); }
class RandomDice implements Dice {
private final Random random = new Random();
public int roll() { return 1 + random.nextInt(6); }
}
class LoadedDice implements Dice { // for tests, replays and demos
private final int[] values;
private int i = 0;
LoadedDice(int... values) { this.values = values; }
public int roll() { return values[i++ % values.length]; }
}
// now this is a real, deterministic test:
Game game = new Game(board, List.of(asha), new LoadedDice(4));
game.playTurn();
assertEquals(14, asha.position()); // 4 is a ladder to 14Try it in the prototype
Switch to 🎯 Loaded, press ↺ New game, roll three times, then reset and roll three times again. Identical game, both times. That reproducibility is the only reason the dice is an object — and it is worth saying exactly that when the interviewer asks why you bothered.
Step 5 · The turn loop
Do not put the loop inside Game
while (true) { playTurn(); } inside the game means it can only ever run as a console program. Expose playTurn() and let the caller loop — that is what makes the same class work in a main(), in a test, and behind a UI button. The prototype above calls exactly this method once per click.
House rules are configuration, not code
When flags become too many
Three or four booleans is fine and honest. Past that, bundle them into a GameRules object you pass in — one parameter instead of six, and named presets (GameRules.classic(), GameRules.quick()) become possible. Mention the threshold rather than pre-building it; see Pattern overuse & anti-patterns.
The class diagram
Dice, and if you only get one abstraction into this design, make it that one. Notation: Class diagrams.The follow-ups
- “Write a test that a player climbs a ladder.” → already free.
new LoadedDice(4), one turn, assert position 14. If you welded inMath.random(), you are now rewriting the design under time pressure. - “Simulate 10,000 games and report the average number of turns.” → the reason
playTurn()does not print and does not loop. The caller loops; the game stays silent. This follow-up is common and it punishes a chattyGameclass. - “Rolling a six gives another turn.” → do not re-queue the player when
roll == 6. One line, because the queue is the turn order. - “Landing on an occupied square sends the other player back to 1.” → the only follow-up that genuinely changes the model: the game now needs to look up who is on a square, so positions become a two-way relationship.
- “Make it playable over a network.” →
playTurn()is already a request handler. Add validation that the caller is the current player, and make it atomic — same shape as the check-and-take in Parking Lot. - “Log every move for replay.” → keep a move history, exactly as in Tic-Tac-Toe. Replay a game by feeding the recorded rolls into a
LoadedDice.
How this round is lost
Math.random()inside the turn. The single most common miss, and the one that makes every follow-up harder.- Separate
SnakeandLadderclasses. Two identical shapes and two loops where one map would do. - A 2D board. Row/column conversions on every move, in service of a drawing the game never needs.
while (true)andprintlninsideGame. Now it cannot be tested, simulated, or put behind a UI.- Forgetting the overshoot rule entirely. A player sitting on 99 rolls a 4 and lands on 103 — and your array index throws.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A real three-player game. Press 🎲 Roll and the token walks square by square — because the board is a line, not a grid — then a 🐍 or a 🪜 may move it again in a second beat. Every jump is the same one-line lookup; the explain panel shows it as jumps[4] = 14. Now the part that matters: switch the dice to 🎯 Loaded, press ↺ New game, and roll. Do it again. The same game happens every time — because the dice is an object you can swap, not a call to Math.random() buried inside the turn. Then flip exact landing off and watch a house rule change with no code change.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Watch a move happen in two beats
Press 🎲 Roll. The token walks one square at a time to the target — that is position + roll on a line. Then, if it landed on a 🐍 or 🪜, a second beat fires and the explain line shows the actual lookup, jumps[4] = 14. Two beats, because a move and a jump are two different things.
Prove the game is deterministic
Switch to 🎯 Loaded, press ↺ New game, and roll three times — note where everyone lands. Reset and do it again. Identical. Now switch back to 🎲 Random and repeat: never the same twice. That difference is the entire argument for making the dice an object.
Test a specific rule by hand
With the loaded dice, the first roll is always a 4 — and square 4 is a ladder to 14. That is exactly the unit test in the Java sample: new LoadedDice(4), one turn, assert 14. You just ran it manually; in code it is three lines and takes a millisecond.
Flip a house rule
Play until someone is in the 90s, then toggle between ✓ Required and ✗ Overshoot wins. Under the strict rule a player on 97 needs exactly a 3 and forfeits on anything higher; under the loose rule any roll of 3 or more finishes it. Same code, one boolean.
Let it run
Press ⏩ Play 10 turns and just watch the turn order rotate: 🔴 → 🔵 → 🟢 → 🔴. That rotation is one queue with poll() and offer(). Adding a fourth player would not change a single line of the turn logic.
Build it from memory
Blank file, in this order: Dice interface with RandomDice and LoadedDice first (it is the design decision, so write it first) → Board with the jump map → Player with one int → Game.playTurn() → a main() that loops until someone wins. Then write the ladder test. If the test needs more than three lines, your dice is not injected properly.
In practice
When to use it — and what trips people up
Injecting randomness — the transferable lesson
Everything in this problem is easy except one habit, and that habit is worth more than the game. Anything non-deterministic that your logic depends on should arrive from outside, behind a tiny interface you can swap.
- The clock.
Instant.now()inside a method is the same bug asMath.random()inside a turn. Pass the time in — exactly what ATM and Parking Lot both do withexitAt. - Random values — dice, shuffles, sampling, jitter, IDs. Inject a source, or at minimum inject the seed.
- UUIDs and IDs. A generator interface makes assertions on created objects possible.
- The network and the filesystem. Same principle at a bigger scale: a
BankServiceinterface with aFakeBankis why ATM can demo a rollback.
The cheap version, when you have no time
Even without an interface, taking a seeded Random in the constructor makes the whole game reproducible: new Game(board, players, new Random(42)). It is one parameter and it recovers most of the benefit. Worth knowing as the fallback — but the interface is better and costs four lines.
Where this design stops
- Multiplayer over a network.
playTurn()becomes a request handler and needs to verify who is calling and be atomic. The design supports it; it just does not do it yet. - Games with actual choices. Ludo lets you pick which token to move, so a turn takes a parameter and
Playerbecomes a strategy. That is the natural next step up from this problem. - Interaction between tokens. The moment landing on an occupied square does something, positions stop being independent and the board needs to answer “who is on square 47?”
If you only remember one thing
Never call the random number generator, or the clock, from inside your logic. Take them as collaborators. It is four lines here and it is the difference between a system you can test and one you can only run.
What it gives you
- The dice behind an interface makes the entire game deterministic on demand — tests, replays and bug reproduction all become trivial.
- One jump map instead of Snake and Ladder classes makes lookups O(1) and removes a whole duplicated code path.
- Position as a single integer means no coordinate maths, and the board layout stays a rendering concern.
- playTurn() returning a result instead of looping and printing lets the same class run in a test, a UI, or a 10,000-game simulation.
- House rules as booleans mean the common variants cost nothing, and the board itself is passed in rather than hardcoded.
Common mistakes
- The design assumes a turn has no choices; games where a player picks a move (Ludo, backgammon) need playTurn to take a parameter and Player to become a strategy.
- Positions are independent, so any rule about tokens interacting requires a new square-to-player lookup the board does not currently have.
- GameRules as loose booleans stops scaling past three or four flags before it wants to become a real object with presets.
- One jump per turn is hardcoded as a design choice — chaining jumps is a genuinely ambiguous rule that this model quietly decides for you.
- There is no move history, so replay and undo need to be added rather than falling out of the design as they do in tic-tac-toe.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
// ---------- the design decision: randomness is a collaborator, not a call ----------
interface Dice {
int roll();
}
class RandomDice implements Dice {
private final Random random;
private final int faces;
RandomDice() { this(6, new Random()); }
RandomDice(int faces, Random r) { this.faces = faces; this.random = r; }
public int roll() { return 1 + random.nextInt(faces); }
}
/** Fixed sequence — for tests, replays and reproducible demos. */
class LoadedDice implements Dice {
private final int[] values;
private int i = 0;
LoadedDice(int... values) { this.values = values; }
public int roll() { return values[i++ % values.length]; }
}
class Player {
private final String name;
private int position = 0; // 0 == not yet on the board
Player(String name) { this.name = name; }
String name() { return name; }
int position() { return position; }
void moveTo(int p) { this.position = p; }
}
/** A snake and a ladder are the same thing: land on "from", go to "to". */
class Board {
private final int lastSquare;
private final Map<Integer, Integer> jumps;
Board(int lastSquare, Map<Integer, Integer> jumps) {
this.lastSquare = lastSquare;
this.jumps = Map.copyOf(jumps);
jumps.forEach((from, to) -> {
if (from < 1 || from > lastSquare || to < 1 || to > lastSquare)
throw new IllegalArgumentException("jump off the board: " + from + "->" + to);
if (from.equals(to)) throw new IllegalArgumentException("jump to itself: " + from);
});
}
int lastSquare() { return lastSquare; }
/** One lookup. to < from is a snake, to > from is a ladder — the difference is arithmetic. */
int destinationFrom(int square) { return jumps.getOrDefault(square, square); }
static Board classic() {
Map<Integer, Integer> j = new HashMap<>();
j.put(1, 38); j.put(4, 14); j.put(9, 31); j.put(21, 42);
j.put(28, 84); j.put(51, 67); j.put(71, 91); j.put(80, 100); // ladders
j.put(17, 7); j.put(36, 6); j.put(49, 11); j.put(54, 34);
j.put(62, 18); j.put(87, 24); j.put(95, 56); j.put(98, 78); // snakes
return new Board(100, j);
}
}
/** House rules are data, not subclasses. */
record GameRules(boolean exactLanding, boolean extraTurnOnSix) {
static GameRules classic() { return new GameRules(true, true); }
}
record TurnResult(Player player, int roll, int from, int to, boolean jumped, boolean won, boolean forfeited) {}
class Game {
private final Board board;
private final Deque<Player> turns = new ArrayDeque<>();
private final Dice dice;
private final GameRules rules;
private Player winner;
Game(Board board, List<Player> players, Dice dice, GameRules rules) {
if (players.size() < 2) throw new IllegalArgumentException("need at least two players");
this.board = board;
this.dice = dice;
this.rules = rules;
this.turns.addAll(players);
}
boolean isOver() { return winner != null; }
Player winner() { return winner; }
Player current() { return turns.peekFirst(); }
/** ONE turn. No loop, no printing — so this works in a test, a UI or a simulation. */
TurnResult playTurn() {
if (isOver()) throw new IllegalStateException("game is over");
Player player = turns.removeFirst();
int roll = dice.roll();
int from = player.position();
int target = from + roll;
// overshoot: forfeit, or clamp, depending on the house rule
if (target > board.lastSquare()) {
if (rules.exactLanding()) {
turns.addLast(player);
return new TurnResult(player, roll, from, from, false, false, true);
}
target = board.lastSquare();
}
int after = board.destinationFrom(target); // one jump per turn, never a chain
player.moveTo(after);
if (after == board.lastSquare()) {
winner = player;
return new TurnResult(player, roll, from, after, after != target, true, false);
}
// rolling a six keeps you at the front of the queue
if (rules.extraTurnOnSix() && roll == 6) turns.addFirst(player);
else turns.addLast(player);
return new TurnResult(player, roll, from, after, after != target, false, false);
}
}
public class Main {
public static void main(String[] args) {
List<Player> players = List.of(new Player("Asha"), new Player("Ravi"), new Player("Meera"));
// ---- deterministic: the same game every run ----
Game game = new Game(Board.classic(), players, new LoadedDice(4, 6, 2, 5, 3, 1), GameRules.classic());
while (!game.isOver()) {
TurnResult t = game.playTurn();
System.out.printf("%-6s rolls %d : %d -> %d%s%s%n",
t.player().name(), t.roll(), t.from(), t.to(),
t.jumped() ? " (jump)" : "", t.forfeited() ? " (forfeit)" : "");
}
System.out.println("winner: " + game.winner().name());
// ---- the test the injected dice makes possible ----
Player solo = new Player("Test");
Game t = new Game(Board.classic(), List.of(solo, new Player("Other")),
new LoadedDice(4), GameRules.classic());
t.playTurn();
System.out.println("rolled 4 from 0, ladder at 4 -> position " + solo.position() + " (expected 14)");
}
}References & further reading
6 sources- Articlegithub.com
awesome-low-level-design — Snake and Ladder
A second take on the same problem — compare how it models the jumps.
- Articlemartinfowler.com
Test Double — Martin Fowler
The vocabulary for LoadedDice: stub, fake, mock, spy. Useful for naming exactly what you built.
- Articlemartinfowler.com
Mocking the clock — Fowler on Clock Wrapper
The same argument as the dice, applied to time. Short, and it generalises the lesson.
- Bookgameprogrammingpatterns.com
Game Programming Patterns — Game Loop
Free online. Why the loop belongs outside the game object, which is the reason playTurn() does not loop.
- Docsdocs.oracle.com
java.util.Random — seeding for reproducibility
The cheap alternative to an interface: pass a seeded Random and the whole game replays identically.
- Articleen.wikipedia.org
Snakes and Ladders — the history and the maths
Includes the Markov-chain analysis of expected game length — good context if the interviewer asks you to simulate.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
Why should the dice be an interface rather than a call to the random number generator inside playTurn()?
question 02 / 08
How should snakes and ladders be represented?
question 03 / 08
A player on 97 rolls a 5 under the “exact landing required” rule. What happens?
question 04 / 08
Why store a player's position as a single integer rather than a row and a column?
question 05 / 08
Why should playTurn() play exactly one turn instead of the game containing while (true)?
question 06 / 08
How is turn order best managed for three or more players?
question 07 / 08
A ladder ends on a square that is a snake's head. What should happen, and how should you answer?
question 08 / 08
You have five minutes left and no Dice interface yet. What is the best fallback?
0/8 answered