Snake and ladder game
The classic machine-coding round: build a working snake and ladder game with a board, dice, players and a game loop, and keep the rules easy to change.
Build a snake and ladder game that runs in the console. Take the board size, snakes, ladders and players as input, play until someone wins, and print each move. Expect follow-up rule changes.
Questions to ask first
- What is the board size? Assume 100 cells by default, configurable.
- How many dice and players? Assume one six-sided dice and two to six players, both configurable.
- What if a roll goes past the last cell? Assume the player stays where they are. Other rules may be asked later.
- Does the game stop at the first winner? Assume yes. 'Play until one player is left' is an extension.
- Can a snake's tail land on a ladder's foot? Assume no chains: we reject such boards when they are built.
- Is the input from the console or a file? Assume a simple input in `main`; the core classes do not read input themselves.
Requirements
- Create a board of N cells with snakes (head above tail) and ladders (foot below top).
- Reject invalid boards: a jump starting on the last cell, two jumps on one cell, or a chain of jumps.
- Add players who all start at cell 0, outside the board.
- Players take turns: roll the dice, move, follow a snake or ladder, and print the move.
- The first player to land exactly on the last cell wins, and the game stops.
- Make dice and move rules replaceable without editing the game loop.
Out of scope
- A graphical board or a network game.
- Saving and loading a game.
- Computer players with strategy (there is no choice to make in this game).
Entities
- Game: board, players (turn queue), dice, rule, status -> play(), takeTurn()
- Board: size, jumps by start cell -> resolve(cell), lastCell()
- Jump (abstract): start, end; Snake and Ladder extend it and check direction
- Player: name, position -> moveTo()
- Dice (interface) -> roll(); StandardDice, ScriptedDice for tests
- MoveRule (interface) -> newPosition(from, roll, board); ExactFinishRule, BounceBackRule
- GameStatus: NOT_STARTED, IN_PROGRESS, FINISHED
Classes in the model design
- Game (class)
- Board (class)
- Jump (abstract)
- Snake (class)
- Ladder (class)
- Player (class)
- Dice (interface)
- StandardDice (class)
- ScriptedDice (class)
- MoveRule (interface)
- ExactFinishRule (class)
- BounceBackRule (class)
- GameStatus (enum)
The main use case, step by step
- `main` builds a `Board` of 100 cells with a snake from 97 to 78 and a ladder from 4 to 25. The board constructor checks every jump and throws if the board is invalid.
- `main` creates a `Game` with the board, two players, a `StandardDice` and an `ExactFinishRule`, then calls `play()`.
- `play` sets the status to IN_PROGRESS and calls `takeTurn` until the status is FINISHED. Each turn takes the next player from the front of the queue.
- Asha is at 1 and the dice shows 3. `ExactFinishRule.newPosition` computes 4, which is on the board, and asks `board.resolve(4)`. The ladder sends her to 25.
- The game moves Asha to 25, prints 'Asha rolled 3 and moved from 1 to 25', and puts her at the back of the queue.
- Later Ravi is at 98 and rolls 4. The target 102 is past 100, so the rule keeps him at 98. When a player lands exactly on 100, the game records the winner and stops.
Patterns used
- Strategy: MoveRule decides where a roll takes a player; Dice decides what a roll is.. Interviewers often say 'now a roll past 100 bounces back' or 'now use two dice'. With strategies, each change is a new class passed to Game. The game loop stays the same.
- Inheritance for a shared shape: Snake and Ladder extend the abstract class Jump, which stores start and end.. Both move a player from one cell to another, so the board treats them the same way. Each subclass only checks its own direction in the constructor. That is a true 'is a' relation.
Key code
Dice, rules and the board
public interface Dice { int roll(); }
public final class StandardDice implements Dice {
private final int count;
private final Random random;
public StandardDice(int count, Random random) { this.count = count; this.random = random; }
@Override public int roll() {
int sum = 0;
for (int i = 0; i < count; i++) sum += random.nextInt(6) + 1;
return sum;
}
}
public interface MoveRule { int newPosition(int from, int roll, Board board); }
public final class ExactFinishRule implements MoveRule {
@Override public int newPosition(int from, int roll, Board board) {
int target = from + roll;
return target > board.lastCell() ? from : board.resolve(target);
}
}
public final class Board {
private final int size;
private final Map<Integer, Jump> jumps = new HashMap<>();
public Board(int size, List<Jump> all) {
this.size = size;
Set<Integer> ends = new HashSet<>();
for (Jump j : all) {
if (j.start() < 1 || j.start() >= size || j.end() < 1 || j.end() > size) throw new IllegalArgumentException("Jump off the board: " + j);
if (jumps.putIfAbsent(j.start(), j) != null) throw new IllegalArgumentException("Two jumps start at " + j.start());
ends.add(j.end());
}
for (int end : ends) if (jumps.containsKey(end)) throw new IllegalArgumentException("Chain of jumps at " + end);
}
public int resolve(int cell) {
Jump j = jumps.get(cell);
return j == null ? cell : j.end();
}
public int lastCell() { return size; }
}
The game loop
public enum GameStatus { NOT_STARTED, IN_PROGRESS, FINISHED }
public final class Game {
private final Board board;
private final Deque<Player> turns;
private final Dice dice;
private final MoveRule rule;
private GameStatus status = GameStatus.NOT_STARTED;
private Player winner;
public Game(Board board, List<Player> players, Dice dice, MoveRule rule) {
if (players.size() < 2) throw new IllegalArgumentException("Need at least two players");
this.board = board;
this.turns = new ArrayDeque<>(players);
this.dice = dice;
this.rule = rule;
}
public Player play() {
if (status != GameStatus.NOT_STARTED) throw new IllegalStateException("Game already played");
status = GameStatus.IN_PROGRESS;
while (status == GameStatus.IN_PROGRESS) takeTurn();
return winner;
}
void takeTurn() {
Player p = turns.pollFirst();
int roll = dice.roll();
int from = p.position();
int to = rule.newPosition(from, roll, board);
p.moveTo(to);
System.out.printf("%s rolled %d and moved from %d to %d%n", p.name(), roll, from, to);
if (to == board.lastCell()) {
winner = p;
status = GameStatus.FINISHED;
} else {
turns.addLast(p);
}
}
}
Deep dives
Modelling snakes and ladders
Weak: Two arrays inside Game, snakes[] and ladders[], and a loop over both after every move. The game class now knows board details, and nothing stops a 'snake' whose tail is above its head.
Good: One Map<Integer, Integer> from start cell to end cell, kept on Board. Moving is one map lookup. Snakes and ladders behave the same way, so one map is enough. This is a fine answer when time is short.
Great: An abstract class Jump with start and end, and subclasses Snake and Ladder whose constructors check their direction. Board keeps Map<Integer, Jump> and validates the whole set when it is built: no jump on the last cell, no two jumps on one cell, no chains. An invalid board can never exist, so the game loop has no checks for it.
Trade-off: The class version costs a few more minutes to write. It pays back when the interviewer asks 'print which snake bit you' or 'add a portal that moves you to a random cell'. If time is very short, start with the map and say how you would refactor.
Making the rules extensible
Weak: Boolean flags on Game: bounceBack, extraTurnOnSix, twoDice. Each flag adds an if inside takeTurn. After three follow-up questions the loop is hard to read and every rule touches the same method.
Good: Pull the changing parts behind interfaces. Dice decides the roll and MoveRule decides the new position. Pass them to the Game constructor. 'Bounce back' becomes a BounceBackRule class, and the loop does not change. This is the strategy pattern.
Great: Do the same, and add a second interface for turn order, such as TurnPolicy, for rules like 'a six gives another turn' or 'three sixes in a row cancel the turn'. Build the game with a small builder that has sensible defaults, so main stays short. Each rule has its own unit test.
Trade-off: More interfaces mean more files to read in a 90-minute round. Add an interface only for a rule that the interviewer has hinted at or that differs between common versions of the game. Do not create an interface for every class.
Testing a game built on random numbers
Weak: Call Math.random() inside takeTurn. You cannot write a test that says 'a roll of 3 from cell 1 takes the ladder to 25'. You can only run the game and read the printout.
Good: Put Random behind the Dice interface and pass it in. In a test, use a ScriptedDice that returns 3, then 4, then 6. Every move is now predictable, and you can test ladders, snakes and the overshoot rule.
Great: Use ScriptedDice in tests and a seeded Random in StandardDice for repeatable demo runs. Instead of printing inside Game, publish a move event to a listener (the observer pattern). A console listener prints, and a test listener collects moves to assert on. The game logic no longer depends on System.out.
Trade-off: A listener is more code than one printf. In a machine-coding round, printing from the loop is acceptable if Dice is injected. Mention the listener as the next step if the interviewer asks about a UI or tests.
Now add one more feature
- Now a roll past the last cell bounces back. What changes? Add `BounceBackRule implements MoveRule` that computes `last - (target - last)` and then resolves jumps. Pass it to Game. No other class changes.
- Now a six gives another turn, but three sixes in a row lose the turn. What changes? Add a `TurnPolicy` that the game asks after each roll whether the same player goes again. Game keeps a count of consecutive sixes for the current player.
- Now play until only one player is left, and print the finishing order. What changes? Game keeps a `List<Player>` of finishers. When a player reaches the last cell, add them to the list and do not put them back in the queue. Stop when one player remains.
- Now add a crocodile cell that sends you back 5 cells, or a mine that skips your next turn. What changes? The crocodile is another `Jump` with a computed end. The mine needs a hook on the player (skip count) and a check in `TurnPolicy`.
What each level is expected to show
SDE-1: A working game in about 60 minutes: Board, Player, Dice, Game with a turn queue, snakes and ladders as jumps, and the exact-finish rule. Clean names, a main method that plays a full game, and a Dice interface so a test can use fixed rolls.
SDE-2: Everything at SDE-1, plus board validation (no chains, no jump on the last cell), rules as strategies, and a clear plan for extra turns and bounce back. Code is testable without reading console output, and each follow-up rule is added as a new class.
Common mistakes
- Putting everything in one `Game` class with arrays for snakes and ladders and a big `while` loop in `main`.
- Calling `Math.random()` directly, so no test can control the dice.
- Not handling a roll past the last cell, or moving the player off the board.
- Allowing chains or loops of jumps (a ladder top on a snake head) that make the game run forever.
- Using boolean flags for each rule variant instead of replaceable rule classes.
- Spending 30 minutes on input parsing and leaving the game loop unfinished.