InterviewKata
Low-level design · Problem P05 of 15 · Warm-up problems

Snake and ladder

A gentle problem whose real lesson is testability: once the dice is an interface, a game full of randomness becomes a deterministic test.

1. The prompt

The prompt"Design snake and ladder for several players. The board has snakes and ladders; players take turns rolling a die; the first to reach the last square exactly wins."

2. Clarify the requirements

Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.

AskAssume (unless told otherwise)
Overshoot the last square?You stay where you are (a common house rule; confirm it).
Roll a 6: extra turn?Not by default; a follow-up.
Can a jump land on another jump?No: validate when building the board.

Must do

  • Any number of players, taking turns in order
  • Snakes move you down, ladders up
  • You must land exactly on the last square to win

Must be

  • Board configurable (size, jumps)
  • Dice swappable (e.g. two dice, a loaded die for tests)
  • Validate the board

Out of scope

  • Graphics
  • Saving a game
  • Online play

3. The classes

The class diagram: arrows show who uses or holds whom.
ClassIts one job
GameTurn order, positions, and the win rule
BoardSize and jumps; one method: where does this square send you?
DiceAn interface; RandomDice for play, ScriptedDice for tests

4. How it works

5. Patterns and why

PatternWhereWhy it's worth it here
Strategy / dependency injectionDice passed into GameSwap a random die for a scripted one in tests, or two dice in a variant
Data over classesSnakes and ladders are just a map of jumpsNo Snake and Ladder classes needed: they behave identically
QueuePlayers in a deque; rotate after each turnTurn order without index arithmetic
Memory hook: "A jump is a jump"Snakes and ladders are one map from square to square; the dice is an interface so tests can script it.

6. The code

The demo uses ScriptedDice, so every run prints the same game. The real game passes RandomDice.

from __future__ import annotations
import random
from abc import ABC, abstractmethod
from collections import deque


class Dice(ABC):
    @abstractmethod
    def roll(self) -> int: ...


class RandomDice(Dice):
    def __init__(self, faces: int = 6, seed: int | None = None) -> None:
        self.faces, self.rng = faces, random.Random(seed)

    def roll(self) -> int:
        return self.rng.randint(1, self.faces)


class ScriptedDice(Dice):
    """Rolls a fixed sequence: the game becomes testable without touching its code."""

    def __init__(self, rolls: list[int]) -> None:
        self.rolls = deque(rolls)

    def roll(self) -> int:
        return self.rolls.popleft()


class Board:
    def __init__(self, size: int, jumps: dict[int, int]) -> None:
        for start, end in jumps.items():
            if not (0 < start < size and 0 < end <= size) or start == end:
                raise ValueError(f"bad jump {start}->{end}")
        self.size, self.jumps = size, jumps

    def destination(self, square: int) -> int:
        return self.jumps.get(square, square)


class Game:
    def __init__(self, board: Board, players: list[str], dice: Dice) -> None:
        self.board, self.dice = board, dice
        self.queue = deque(players)
        self.position = {p: 0 for p in players}
        self.winner: str | None = None

    def take_turn(self) -> str:
        player = self.queue[0]
        roll = self.dice.roll()
        start = self.position[player]
        target = start + roll
        if target > self.board.size:   # must land exactly on the last square
            note = f"{player} rolls {roll}: needs {self.board.size - start}, stays on {start}"
        else:
            end = self.board.destination(target)
            kind = "" if end == target else (" ladder up to" if end > target else " snake down to")
            note = f"{player} rolls {roll}: {start} -> {target}" + (f",{kind} {end}" if kind else "")
            self.position[player] = end
            if end == self.board.size:
                self.winner = player
                return note + f". {player} wins"
        self.queue.rotate(-1)
        return note

    def play(self, max_turns: int = 1000) -> None:
        for _ in range(max_turns):
            print(self.take_turn())
            if self.winner:
                return


if __name__ == "__main__":
    board = Board(30, {3: 22, 5: 8, 11: 26, 20: 29, 17: 4, 19: 7, 21: 9, 27: 1})
    Game(board, ["Ana", "Ben"], ScriptedDice([2, 3, 6, 2, 6, 4, 3, 1, 5, 2, 2, 1])).play()
interface Dice { roll(): number; }

class RandomDice implements Dice {
  constructor(private faces = 6) {}
  roll(): number { return 1 + Math.floor(Math.random() * this.faces); }
}

/** Rolls a fixed sequence: the game becomes testable without touching its code. */
class ScriptedDice implements Dice {
  constructor(private rolls: number[]) {}
  roll(): number {
    const r = this.rolls.shift();
    if (r === undefined) throw new Error("out of rolls");
    return r;
  }
}

class Board {
  constructor(readonly size: number, private jumps: Map<number, number>) {
    for (const [s, e] of jumps) if (!(s > 0 && s < size && e > 0 && e <= size) || s === e) throw new Error(`bad jump ${s}->${e}`);
  }
  destination(square: number): number { return this.jumps.get(square) ?? square; }
}

class Game {
  private queue: string[];
  private position = new Map<string, number>();
  winner: string | null = null;

  constructor(private board: Board, players: string[], private dice: Dice) {
    this.queue = [...players];
    for (const p of players) this.position.set(p, 0);
  }

  takeTurn(): string {
    const player = this.queue[0];
    const roll = this.dice.roll();
    const start = this.position.get(player)!;
    const target = start + roll;
    let note: string;
    if (target > this.board.size) { // must land exactly on the last square
      note = `${player} rolls ${roll}: needs ${this.board.size - start}, stays on ${start}`;
    } else {
      const end = this.board.destination(target);
      const kind = end === target ? "" : end > target ? " ladder up to" : " snake down to";
      note = `${player} rolls ${roll}: ${start} -> ${target}` + (kind ? `,${kind} ${end}` : "");
      this.position.set(player, end);
      if (end === this.board.size) {
        this.winner = player;
        return `${note}. ${player} wins`;
      }
    }
    this.queue.push(this.queue.shift()!);
    return note;
  }

  play(maxTurns = 1000): void {
    for (let i = 0; i < maxTurns && !this.winner; i++) console.log(this.takeTurn());
  }
}

const board = new Board(30, new Map([[3, 22], [5, 8], [11, 26], [20, 29], [17, 4], [19, 7], [21, 9], [27, 1]]));
new Game(board, ["Ana", "Ben"], new ScriptedDice([2, 3, 6, 2, 6, 4, 3, 1, 5, 2, 2, 1])).play();
void RandomDice; // the real game would pass new RandomDice()
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

interface Dice {
    int roll();
}

class RandomDice implements Dice {
    private final Random rng = new Random();
    private final int faces;

    RandomDice(int faces) {
        this.faces = faces;
    }

    public int roll() {
        return 1 + rng.nextInt(faces);
    }
}

/** Rolls a fixed sequence: the game becomes testable without touching its code. */
class ScriptedDice implements Dice {
    private final Deque<Integer> rolls;

    ScriptedDice(List<Integer> rolls) {
        this.rolls = new ArrayDeque<>(rolls);
    }

    public int roll() {
        return rolls.removeFirst();
    }
}

class Board {
    final int size;
    private final Map<Integer, Integer> jumps;

    Board(int size, Map<Integer, Integer> jumps) {
        jumps.forEach((s, e) -> {
            if (!(s > 0 && s < size && e > 0 && e <= size) || s.equals(e))
                throw new IllegalArgumentException("bad jump " + s + "->" + e);
        });
        this.size = size;
        this.jumps = jumps;
    }

    int destination(int square) {
        return jumps.getOrDefault(square, square);
    }
}

class Game {
    private final Board board;
    private final Dice dice;
    private final Deque<String> queue;
    private final Map<String, Integer> position = new HashMap<>();
    String winner;

    Game(Board board, List<String> players, Dice dice) {
        this.board = board;
        this.dice = dice;
        this.queue = new ArrayDeque<>(players);
        players.forEach(p -> position.put(p, 0));
    }

    String takeTurn() {
        String player = queue.peekFirst();
        int roll = dice.roll();
        int start = position.get(player), target = start + roll;
        String note;
        if (target > board.size) { // must land exactly on the last square
            note = player + " rolls " + roll + ": needs " + (board.size - start) + ", stays on " + start;
        } else {
            int end = board.destination(target);
            String kind = end == target ? "" : end > target ? " ladder up to" : " snake down to";
            note = player + " rolls " + roll + ": " + start + " -> " + target + (kind.isEmpty() ? "" : "," + kind + " " + end);
            position.put(player, end);
            if (end == board.size) {
                winner = player;
                return note + ". " + player + " wins";
            }
        }
        queue.addLast(queue.removeFirst());
        return note;
    }

    void play(int maxTurns) {
        for (int i = 0; i < maxTurns && winner == null; i++) System.out.println(takeTurn());
    }
}

public class Main {
    public static void main(String[] args) {
        Map<Integer, Integer> jumps = new HashMap<>(Map.of(3, 22, 5, 8, 11, 26, 20, 29, 17, 4, 19, 7, 21, 9, 27, 1));
        Board board = new Board(30, jumps);
        new Game(board, List.of("Ana", "Ben"), new ScriptedDice(List.of(2, 3, 6, 2, 6, 4, 3, 1, 5, 2, 2, 1))).play(1000);
        Dice real = new RandomDice(6); // the real game would pass this instead
        if (real.roll() < 1) throw new AssertionError();
    }
}

Output (the same in all three languages)

Ana rolls 2: 0 -> 2
Ben rolls 3: 0 -> 3, ladder up to 22
Ana rolls 6: 2 -> 8
Ben rolls 2: 22 -> 24
Ana rolls 6: 8 -> 14
Ben rolls 4: 24 -> 28
Ana rolls 3: 14 -> 17, snake down to 4
Ben rolls 1: 28 -> 29
Ana rolls 5: 4 -> 9
Ben rolls 2: needs 1, stays on 29
Ana rolls 2: 9 -> 11, ladder up to 26
Ben rolls 1: 29 -> 30. Ben wins

7. Concurrency and edge cases

8. Follow-up questions

What interviewers ask once the core works, and a short answer for each.

Extra turn on a six?
After the move, don't rotate the queue if the roll was 6; cap it at three sixes in a row.
Two dice?
A CompositeDice that sums two Dice: no change to Game.
How do you test the win rule?
ScriptedDice with the exact rolls that reach the end, then assert the winner.

9. Common mistakes

Separate Snake and Ladder classesThey differ only in direction. One jump map is simpler.
Random numbers inside GameUntestable. Inject the dice.
Index arithmetic for turnsA queue that rotates is clearer and handles any number of players.

10. Check yourself

Why is Dice an interface?

Randomness injected from outside makes the game deterministic in tests.

How are snakes and ladders stored?

Both are a jump; direction is just which square is bigger.

Your turn

  1. Add the extra-turn rule (10 min). Roll a 6, play again, at most three times.
  2. Add two dice (5 min). A Dice that sums two others.