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

  1. `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.
  2. `main` creates a `Game` with the board, two players, a `StandardDice` and an `ExactFinishRule`, then calls `play()`.
  3. `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.
  4. 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.
  5. 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.
  6. 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.

Lessons behind this answer