Skip to content

Design snake and ladder

TL;DR

  • You reuse the BoardGame template from tic-tac-toe and add five methods: a roll is the move, a position is the state, first one home wins.
  • Three decisions carry the interview: dice are a Strategy so every test is exact, the board validates itself at construction (no doubled heads, no chains, no cycles), and the overshoot rule is configuration, not an if buried in the turn loop.
  • Patterns that earn their place: Template Method, Strategy, Factory, Observer. A Snake and Ladder class hierarchy is deliberately not used.

Problem statement

"Design snake and ladder. Two or more players take turns rolling a die and moving that many squares along a track of 100. Snakes send you down, ladders send you up. The first player to reach the last square wins — decide and defend whether the final roll must be exact. Support different dice, report a ranking at the end, and keep a log of the game. Show me the classes and how you would prove the board is not broken."

Requirements

Functional

  • A track of N squares (default 100) carrying snakes and ladders.
  • Two to eight players taking turns in a fixed rotation.
  • A pluggable dice policy: a fair die, several dice at once, a loaded die, a scripted sequence.
  • A configurable exact-finish rule: stay put on an overshoot, bounce back, or win on any roll that reaches the end.
  • Board validation: no two jumps starting on the same square, no jump landing on the head of another (unless chains are explicitly enabled), and never a cycle.
  • A winner, and a ranking of everyone else.
  • A game log that an observer receives without polling.

Non-functional and constraints

  • Deterministic: the random generator is injected and seeded, so a game replays identically.
  • Correct when several clients drive turns concurrently: one record per turn, exactly one winner.
  • In-memory, single process, standard library only.

Out of scope: Ludo-style multiple tokens, betting, board rendering, networking.

Clarifying questions and assumptions

Question to ask Assumption taken here
Must the winning roll be exact? Configurable, defaulting to exact (OvershootRule.STAY). Ask this out loud — it is the rule interviewers most often leave unstated.
Can a jump land you on another jump? Not by default; Board rejects such a board. allow_chained_jumps=True enables it and switches validation to cycle detection.
Does a six grant another turn? Not here. It is an override of advance_turn, which is why that hook exists on the base class.
Do players knock each other back to the start? No. It would be a rule in apply_move reading the other players' positions; the entities do not change.
One die or several? Whatever the DiceStrategy says. The game never counts dice.
Does the game stop at the first winner? Yes by default. play_to_last=True keeps going so every position in the ranking is earned rather than inferred.
How big is the board? GameConfig.size, minimum 4. Nothing in the code assumes 100 or a 10 x 10 grid.

Core entities and relationships

  • SnakeAndLadderGame — the subclass. It supplies the five template steps plus one hook override, and adds take_turn, position, records and ranking.
  • Board — the track and its jumps. Its constructor is where every board rule is enforced; step is the only method a turn calls.
  • GameConfig — a frozen bundle of every rule a variant changes: size, overshoot rule, chained jumps, play-to-last.
  • Jump — a frozen (start, end) pair. Whether it is a snake or a ladder is derived from the numbers, so the two can never disagree.
  • Position — a frozen square number, 0 meaning "not on the board yet" and size meaning home.
  • TurnRecord — the immutable log line: turn number, player, roll, start, end, and the jumps taken. Everything a replay or an audit needs.
  • BoardFactory — classic() returns the Milton Bradley layout; random_board(rng, ...) generates a valid one by construction.
  • DiceStrategy — FairDice, LoadedDice, ScriptedDice.
  • GameLog — the observer, imported unchanged from the tic-tac-toe package.

Multiplicities: game 1 -> 1 board, game 1 -> 1 dice, board 1 -> 1 config, board 1 -> * jumps, game 1 -> * turn records, game 1 -> * observers.

Class diagram

One subclass, one validated board, one dice interface: everything else is inherited.

classDiagram
    class BoardGame {
        <<abstract>>
        +play() GameResult
        +play_turn()
        +advance_turn()
        +choose_move(player) M
        +apply_move(player, move)
        +is_over() bool
        +winner() str
    }
    class SnakeAndLadderGame {
        +board Board
        +dice DiceStrategy
        +take_turn(player) TurnRecord
        +position(player) Position
        +ranking() List~str~
        +records() tuple
    }
    class Board {
        +size int
        +step(position, roll) tuple
        +landing_square(position, roll) Position
        +resolve(position) tuple
        +jump_at(square) Jump
    }
    class GameConfig {
        +size int
        +overshoot OvershootRule
        +allow_chained_jumps bool
        +play_to_last bool
    }
    class Jump {
        +start int
        +end int
        +kind JumpKind
    }
    class Position {
        +square int
        +is_home(size) bool
    }
    class TurnRecord {
        +number int
        +player str
        +roll int
        +jumps tuple
        +blocked bool
    }
    class BoardFactory {
        +classic(config) Board
        +random_board(rng, snakes, ladders) Board
    }
    class DiceStrategy {
        <<interface>>
        +roll() int
        +max_roll int
    }
    class FairDice
    class LoadedDice
    class ScriptedDice
    class GameLog {
        +events() tuple
        +lines() List~str~
    }
    BoardGame <|-- SnakeAndLadderGame
    SnakeAndLadderGame "1" *-- "1" Board
    SnakeAndLadderGame --> DiceStrategy
    SnakeAndLadderGame "1" o-- "many" TurnRecord
    SnakeAndLadderGame --> "many" GameLog : notifies
    Board "1" *-- "1" GameConfig
    Board "1" o-- "many" Jump
    Board ..> Position
    BoardFactory ..> Board : creates
    DiceStrategy <|.. FairDice
    DiceStrategy <|.. LoadedDice
    DiceStrategy <|.. ScriptedDice

Design patterns applied

Pattern Where Why it earns its place
Template Method BoardGame from the tic-tac-toe package This whole problem is five overrides on a skeleton you already wrote. Saying that in the room, and pointing at the shared file, is worth more than a second turn loop.
Strategy DiceStrategy with fair, loaded and scripted dice ScriptedDice is what turns "simulate and hope" into an exact assertion, and multiple dice or a loaded die become one class each.
Factory BoardFactory.classic and random_board Board construction has rules of its own. random_board pairs distinct squares so the result is valid by construction rather than by retrying.
Observer GameLog subscribed to the game The log is a subscriber, not a field on the game. The same class serves tic-tac-toe and bowling.
Configuration object GameConfig Four rule flags travel together and are read together. As separate constructor arguments they drift; as a frozen dataclass they are one thing you can print in a bug report.
Value objects Jump, Position, TurnRecord Frozen and comparable, so a test asserts records[0].jumps == (Jump(3, 12),) instead of picking apart integers.

What was deliberately not used: a Snake / Ladder class hierarchy. The two differ only in whether end is below or above start; Jump.kind derives it. Two subclasses would let you construct a Snake(10, 30) that climbs, which is a state the type system was supposed to prevent. Also skipped: a Singleton board. Tests build a dozen boards; the classic one is a factory call, not a global.

Key flows

One turn, driven from outside: turn check, roll, overshoot rule, jump chain, log.

sequenceDiagram
    autonumber
    actor T as Table
    participant G as SnakeAndLadderGame
    participant D as FairDice
    participant B as Board
    participant L as GameLog
    T->>G: take_turn("Ana")
    G->>G: require_turn("Ana")
    G->>D: roll()
    D-->>G: 5
    G->>B: step(Position(97), 5)
    B->>B: landing_square applies the overshoot rule
    alt the move lands on the track
        B->>B: resolve follows the jump chain
        B-->>G: Position(78) via snake 98-78
    else exact finish forfeits the move
        B-->>G: Position(97), no jumps
    end
    G->>G: record the turn, check for home
    G-)L: on_event, buffered then flushed
    G-->>T: TurnRecord

How one roll resolves. The order matters and is the thing to draw: the overshoot rule is applied first, then jumps. Bouncing back onto the head of a snake is legal, and the demo below does exactly that.

flowchart TD
    roll["roll = dice.roll()"] --> target["target = square + roll"]
    target --> fits{"target within the board?"}
    fits -->|"yes"| land["land on target"]
    fits -->|"no"| rule{"overshoot rule"}
    rule -->|"STAY"| stay["stay put, move forfeited"]
    rule -->|"BOUNCE"| bounce["land on size minus the excess"]
    rule -->|"ANY"| home["land on the last square"]
    land --> jump{"a jump starts here?"}
    bounce --> jump
    home --> jump
    jump -->|"no"| done["turn ends"]
    jump -->|"yes"| follow["move to jump.end"]
    follow --> chained{"chains allowed?"}
    chained -->|"yes"| jump
    chained -->|"no"| done
    stay --> done

Implementation

Write the vocabulary first, then the board's constructor — that is where the interesting code is — and only then the game, which is short because the template already exists.

The enums are the rule vocabulary. OvershootRule is the one to name out loud before you write anything else:

code/lld/snake_and_ladder/models.py — enums and errors
class JumpKind(StrEnum):
    SNAKE = "snake"  # end < start
    LADDER = "ladder"  # end > start


class OvershootRule(StrEnum):
    """What happens when a roll would take you past the last square."""

    STAY = "stay"  # you must land exactly; an overshoot forfeits the move
    BOUNCE = "bounce"  # you walk to the last square and back down by the excess
    ANY = "any"  # reaching or passing the last square wins


class InvalidJumpError(ValidationError):
    """The board is illegal: a jump is off the track, doubled, chained or cyclic."""


class GameFinishedError(InvalidStateError):
    """The game already has a winner."""

Jump.kind is derived, Position is a number with a name, and TurnRecord.blocked gives the log a word for "the exact-finish rule stopped you":

code/lld/snake_and_ladder/models.py — values
@dataclass(frozen=True, slots=True, order=True)
class Position:
    """A square on the track. 0 means 'not on the board yet'; ``size`` is home."""

    square: int = 0

    def is_home(self, size: int) -> bool:
        return self.square == size


@dataclass(frozen=True, slots=True)
class Jump:
    """A snake or a ladder. Which one it is follows from the numbers, not from a flag."""

    start: int
    end: int

    @property
    def kind(self) -> JumpKind:
        return JumpKind.LADDER if self.end > self.start else JumpKind.SNAKE

    def __str__(self) -> str:
        return f"{self.kind.value} {self.start}-{self.end}"


@dataclass(frozen=True, slots=True)
class GameConfig:
    """Every rule a variant changes, in one immutable object."""

    size: int = DEFAULT_SIZE
    overshoot: OvershootRule = OvershootRule.STAY
    allow_chained_jumps: bool = False
    play_to_last: bool = False  # keep playing until only one player is left, for a full ranking

    def __post_init__(self) -> None:
        if self.size < MIN_SIZE:
            raise ValidationError(f"a board needs at least {MIN_SIZE} squares, got {self.size}")


@dataclass(frozen=True, slots=True)
class TurnRecord:
    """One line of the game log: everything needed to audit or replay a turn."""

    number: int
    player: str
    roll: int
    start: Position
    end: Position
    jumps: tuple[Jump, ...]

    @property
    def blocked(self) -> bool:
        """True when the exact-finish rule forfeited the move."""
        return self.end == self.start and not self.jumps

Now the board. Three invariants are established once, in the constructor, so that no turn ever has to re-check them: a jump stays on the track, no two jumps share a head, and jumps neither chain nor loop. landing_square and resolve are separate because the overshoot rule and the jump rule compose in a fixed order:

code/lld/snake_and_ladder/models.py — board and validation
class Board:
    """The track plus its jumps. Validating the board is most of this class.

    Three invariants are checked once, at construction, so no turn ever has to:
    a jump stays on the track, no two jumps start on the same square, and jumps do
    not chain (or, when chains are allowed, do not form a cycle). A cyclic board is
    the failure interviewers ask about: a token would jump forever.
    """

    def __init__(self, jumps: Iterable[Jump], config: GameConfig | None = None) -> None:
        self.config = config or GameConfig()
        self.size = self.config.size
        self._jumps: dict[int, Jump] = {}
        for jump in jumps:
            self._validate(jump)
            self._jumps[jump.start] = jump
        if self.config.allow_chained_jumps:
            self._reject_cycles()
        else:
            self._reject_chains()

    @property
    def jumps(self) -> dict[int, Jump]:
        return dict(self._jumps)

    def jump_at(self, square: int) -> Jump | None:
        return self._jumps.get(square)

    def landing_square(self, position: Position, roll: int) -> Position:
        """Apply the overshoot rule. Jumps are resolved separately, by ``step``."""
        target = position.square + roll
        if target <= self.size:
            return Position(target)
        if self.config.overshoot is OvershootRule.ANY:
            return Position(self.size)
        if self.config.overshoot is OvershootRule.BOUNCE:
            return Position(max(1, self.size - (target - self.size)))
        return position  # STAY: the move is forfeited

    def resolve(self, position: Position) -> tuple[Position, tuple[Jump, ...]]:
        """Follow the jump at this square, and the chain after it when chains are allowed."""
        taken: list[Jump] = []
        seen: set[int] = set()
        current = position
        while (jump := self._jumps.get(current.square)) is not None:
            if current.square in seen:  # unreachable: validation rejected cyclic boards
                raise InvalidStateError(f"jump cycle at square {current.square}")
            seen.add(current.square)
            taken.append(jump)
            current = Position(jump.end)
            if not self.config.allow_chained_jumps:
                break
        return current, tuple(taken)

    def step(self, position: Position, roll: int) -> tuple[Position, tuple[Jump, ...]]:
        """One roll: overshoot rule first, then jumps."""
        landed = self.landing_square(position, roll)
        if landed == position:
            return position, ()
        return self.resolve(landed)

    def _validate(self, jump: Jump) -> None:
        if not (1 <= jump.start <= self.size and 1 <= jump.end <= self.size):
            raise InvalidJumpError(f"{jump} leaves a board of {self.size} squares")
        if jump.start == jump.end:
            raise InvalidJumpError(f"{jump} goes nowhere")
        if jump.start == self.size:
            raise InvalidJumpError(f"{jump} starts on the last square")
        if jump.start in self._jumps:
            raise InvalidJumpError(f"{jump} overlaps {self._jumps[jump.start]} on square {jump.start}")

    def _reject_chains(self) -> None:
        for jump in self._jumps.values():
            if jump.end in self._jumps:
                raise InvalidJumpError(f"{jump} lands on the head of {self._jumps[jump.end]}")

    def _reject_cycles(self) -> None:
        for start, jump in self._jumps.items():
            seen = {start}
            square = jump.end
            while square in self._jumps:
                if square in seen:
                    raise InvalidJumpError(f"the chain from square {start} loops back to {square}")
                seen.add(square)
                square = self._jumps[square].end

Dice are the smallest possible Strategy: one method. ScriptedDice is the reason the tests below assert exact squares instead of statistical properties:

code/lld/snake_and_ladder/strategies.py — dice
class DiceStrategy(Protocol):
    """Roll something. The game never asks how many dice there are or how fair they are."""

    def roll(self) -> int: ...

    @property
    def max_roll(self) -> int: ...


class FairDice:
    """``count`` uniform dice of ``sides`` faces each. The generator is injected, always."""

    def __init__(self, rng: random.Random, sides: int = 6, count: int = 1) -> None:
        if sides < 2 or count < 1:
            raise ValidationError("dice need at least 2 sides and at least 1 die")
        self._rng = rng
        self.sides = sides
        self.count = count

    def roll(self) -> int:
        return sum(self._rng.randint(1, self.sides) for _ in range(self.count))

    @property
    def max_roll(self) -> int:
        return self.sides * self.count


class LoadedDice:
    """A weighted die: the classic "is this board still winnable" follow-up in one class."""

    def __init__(self, rng: random.Random, weights: Sequence[int]) -> None:
        if len(weights) < 2 or any(w < 0 for w in weights) or sum(weights) == 0:
            raise ValidationError("weights must cover at least 2 faces and not all be zero")
        self._rng = rng
        self._faces = list(range(1, len(weights) + 1))
        self._weights = list(weights)

    def roll(self) -> int:
        return self._rng.choices(self._faces, weights=self._weights, k=1)[0]

    @property
    def max_roll(self) -> int:
        return self._faces[-1]


class ScriptedDice:
    """A queue of rolls. This is how a game becomes a unit test instead of a simulation."""

    def __init__(self, rolls: Iterable[int]) -> None:
        self._queue: deque[int] = deque(rolls)
        self._max = max(self._queue, default=1)

    def roll(self) -> int:
        if not self._queue:
            raise InvalidStateError("the dice script ran out")
        return self._queue.popleft()

    @property
    def max_roll(self) -> int:
        return self._max

The factory hides the two ways a board comes into existence. Note the comment on random_board: sampling distinct squares and pairing them makes overlap and chaining impossible, so there is no retry loop:

code/lld/snake_and_ladder/services.py — board factory
class BoardFactory:
    """Factory: callers ask for a *kind* of board and get a validated one, or an error."""

    @staticmethod
    def classic(config: GameConfig | None = None) -> Board:
        return Board(CLASSIC_JUMPS, config)

    @staticmethod
    def random_board(
        rng: random.Random,
        snakes: int = 8,
        ladders: int = 8,
        config: GameConfig | None = None,
    ) -> Board:
        """Sample 2 x (snakes + ladders) distinct squares and pair them up.

        Because every square is used at most once, no two jumps can share a head and
        no jump can land on another jump's head - the board is valid by construction
        instead of by retrying until ``Board`` stops complaining.
        """
        config = config or GameConfig()
        needed = 2 * (snakes + ladders)
        if needed > config.size - 2:
            raise ValidationError(f"{needed} squares needed but only {config.size - 2} are usable")
        squares = rng.sample(range(2, config.size), needed)
        pairs = [(squares[2 * i], squares[2 * i + 1]) for i in range(snakes + ladders)]
        jumps = [Jump(min(a, b), max(a, b)) for a, b in pairs[:ladders]]
        jumps += [Jump(max(a, b), min(a, b)) for a, b in pairs[ladders:]]
        return Board(jumps, config)

And the game. Everything above the divider is the template's five steps plus one hook; below it are the four methods a caller actually uses. Compare the shape with TicTacToeGame — same skeleton, different rules:

code/lld/snake_and_ladder/services.py — the game
class SnakeAndLadderGame(BoardGame[int]):
    """Snake and ladder is five overrides on the shared skeleton.

    Compare it with ``TicTacToeGame``: same ``play`` loop, same turn cursor, same
    observers, same lock. What differs is what a move *is* (a dice roll rather than a
    cell), and one hook - ``advance_turn`` skips players who already went home when
    the table is playing for full rankings.
    """

    MIN_PLAYERS = 2
    MAX_PLAYERS = 8

    def __init__(
        self,
        players: Sequence[str],
        dice: DiceStrategy,
        board: Board | None = None,
        *,
        turn_limit: int = DEFAULT_TURN_LIMIT,
    ) -> None:
        super().__init__(players, turn_limit=turn_limit)
        self.board = board or BoardFactory.classic()
        self.dice = dice
        self._positions: dict[str, Position] = dict.fromkeys(self.players, Position(0))
        self._finished: list[str] = []
        self._records: list[TurnRecord] = []

    # -- the template's steps ---------------------------------------------------------
    def setup(self) -> None:
        self._positions = dict.fromkeys(self.players, Position(0))
        self._finished.clear()
        self._records.clear()

    def choose_move(self, player: str) -> int:
        return self.dice.roll()

    def apply_move(self, player: str, move: int) -> None:
        if move < 1:
            raise ValidationError(f"a roll of {move} is not a move")
        start = self._positions[player]
        end, jumps = self.board.step(start, move)
        self._positions[player] = end
        self._records.append(TurnRecord(len(self._records) + 1, player, move, start, end, jumps))
        if end.is_home(self.board.size) and player not in self._finished:
            self._finished.append(player)

    def after_move(self, player: str, move: int) -> None:
        self.emit(self._describe(self._records[-1]), actor=player)

    def is_over(self) -> bool:
        if self.board.config.play_to_last:
            return len(self._finished) >= len(self.players) - 1
        return bool(self._finished)

    def winner(self) -> str | None:
        return self._finished[0] if self._finished else None

    def advance_turn(self) -> None:
        """Hook override: a player who is already home no longer takes a turn."""
        for _ in range(len(self.players)):
            super().advance_turn()
            if self.players[self._turn_index] not in self._finished:
                return

    # -- what the template does not give you -------------------------------------------
    def take_turn(self, player: str) -> TurnRecord:
        """The externally driven turn: whoever calls out of order is told so."""
        with self._lock:
            self.require_turn(player)
            self.play_turn()
            return self._records[-1]

    def position(self, player: str) -> Position:
        with self._lock:
            return self._positions[player]

    def records(self) -> tuple[TurnRecord, ...]:
        with self._lock:
            return tuple(self._records)

    def ranking(self) -> list[str]:
        """Finishers in the order they went home, then the rest by how far they got."""
        with self._lock:
            unfinished = sorted(
                (p for p in self.players if p not in self._finished),
                key=lambda p: (-self._positions[p].square, self.players.index(p)),
            )
            return [*self._finished, *unfinished]

    def _describe(self, record: TurnRecord) -> str:
        if record.blocked:
            needed = self.board.size - record.start.square
            return f"{record.player} rolls {record.roll}, needs exactly {needed}, stays on {record.start.square}"
        trail = "".join(f" via {jump}" for jump in record.jumps)
        return f"{record.player} rolls {record.roll}: {record.start.square} to {record.end.square}{trail}"

Running python -m lld.snake_and_ladder.demo plays a seeded game on the classic board, then shows the three overshoot rules and two boards that never get built:

--- classic board, three players, one seeded six-sided die ---
[ 2] Bo rolls 1: 0 to 38 via ladder 1-38
[ 3] Cy rolls 1: 0 to 38 via ladder 1-38
[11] Bo rolls 6: 43 to 11 via snake 49-11
[29] Bo rolls 6: 22 to 84 via ladder 28-84
[38] Bo rolls 6: 89 to 75 via snake 95-75
[52] Ana rolls 3: 61 to 60 via snake 64-60
winner: Cy after 69 turns, ranking ['Cy', 'Ana', 'Bo']
the log observer recorded 71 events
--- rolling a 5 from square 97, one rule at a time ---
  stay: 97 + 5 lands on 97
bounce: 97 + 5 lands on 78 via snake 98-78
   any: 97 + 5 lands on 100
--- boards rejected at construction, never at turn 40 ---
overlap: snake 10-5 overlaps ladder 10-30 on square 10
cycle: the chain from square 10 loops back to 10

The bounce line is the one to point at: rolling a 5 from square 97 walks to 100, bounces back to 98, and 98 is the head of a snake, so the player ends on 78. Two rules composed in the right order, with no special case for it anywhere in the code.

Concurrency and edge cases

Which lock protects what. BoardGame._lock, inherited, guards the turn cursor, the positions dictionary, the finish order and the record list — everything a turn mutates. take_turn holds it across the turn check and the roll and the position update, which is the point: without that, two clients could both pass require_turn, both roll, and both write a position for the same turn number. The board itself is immutable after construction, so nothing about jumps needs a lock at all — that is a direct benefit of validating in the constructor.

The race it prevents. The concurrency test drives one game from six worker threads calling take_turn for a cycling player, and asserts three invariants: the number of accepted calls equals game.turns equals the number of records; the record numbers are 1..n with no gap or duplicate; and each player's stored position is the end position of that player's last record. Losers get NotYourTurnError, which is the correct answer rather than a lost update.

Edge cases handled: a board whose jumps overlap, chain, loop, leave the track, go nowhere or start on the last square; a chain that is followed only when the config allows it; the overshoot rule in all three settings; a bounce that lands on a snake; a player who is already home being skipped by advance_turn when playing to last place; a roll of zero or less; a scripted die that runs out; a random board request that needs more squares than the track has; a one-player game; and a turn played after the game ended. The inherited turn limit converts a pathological board — one where the exact-finish rule makes the end unreachable — into a clean TurnLimitError instead of an infinite loop.

Common mistake

Validating the board lazily, inside the turn loop, or not at all. A jump whose end is another jump's head produces a chain you did not intend; three of them in a ring produce a token that jumps forever and a game that hangs at turn 40 with no useful stack trace. Reject it in the constructor, in a method you can name — _reject_chains or _reject_cycles — and say which invariant each one establishes. It is four lines of graph walking and it is the single strongest signal in this problem.

Extensibility and follow-ups

  • Ludo: each player owns several tokens, so Position moves from the game's dictionary onto a Token, and apply_move becomes "roll, then choose which token to move" — a second Strategy. Safe zones are a predicate on the board; sending an opponent home is a rule in apply_move that reads other tokens.
  • A six grants another turn: override advance_turn to return without rotating when the last roll was the maximum. One method, no other change — that is exactly why the base class exposes it as a hook rather than inlining rotation.
  • Undo: snake and ladder needs less machinery than tic-tac-toe, because TurnRecord already stores the start position. Undo is "pop the record, restore start, restore the turn cursor Memento".
  • Knock-back on collision: when two players share a square, the later arrival sends the earlier one back. It reads other players' positions inside apply_move; the entities do not change, but say out loud that it makes turns order-dependent and therefore harder to parallelise.
  • Board generation with a difficulty target: keep random_board, add a scoring function (expected turns to finish, estimated by simulation with a seeded generator) and resample until it lands in a band.
  • A networked table: TurnRecord is already serialisable, take_turn is already turn-checked, and the game lock already makes two sockets safe. What is missing is reconnection, which is a replay of the records.

Interview tip

When the interviewer says "now make the dice loaded" or "now allow two dice", do not add a parameter to the game. Say "that is a DiceStrategy implementation" and write the four-line class. Then add: "and the game's test does not change, because it already injects a scripted die." Naming the seam and its test in the same breath is what separates a P2 answer from a P0 one.

Tests

tests/test_snake_and_ladder.py has 17 cases. The scripted game is the one to walk through: it pins a ladder, a snake and an exact finish in nine turns with no randomness anywhere.

code/lld/snake_and_ladder/tests/test_snake_and_ladder.py — a scripted game and the overshoot rule
def test_a_scripted_game_takes_a_ladder_then_a_snake_then_finishes_exactly() -> None:
    game = SnakeAndLadderGame(["A", "B"], ScriptedDice([3, 2, 3, 2, 6, 2, 6, 2, 3]), small_board())
    result = game.play()
    records = game.records()

    assert records[0].jumps == (Jump(3, 12),) and records[0].jumps[0].kind is JumpKind.LADDER
    assert records[2].jumps == (Jump(15, 5),) and records[2].jumps[0].kind is JumpKind.SNAKE
    assert (result.status, result.winner, result.turns) == (GameStatus.WON, "A", 9)
    assert game.position("A") == Position(20) and game.position("B") == Position(8)


@pytest.mark.parametrize(
    ("rule", "expected"),
    [(OvershootRule.STAY, 18), (OvershootRule.BOUNCE, 17), (OvershootRule.ANY, 20)],
)
def test_the_overshoot_rule_decides_the_last_five_squares(rule: OvershootRule, expected: int) -> None:
    board = Board([], GameConfig(size=20, overshoot=rule))
    landed, jumps = board.step(Position(18), 5)  # 18 + 5 = 23, three past home
    assert (landed.square, jumps) == (expected, ())

The board validation cases are a single parametrized table, which is how you should write them under time pressure — one row per invariant, each asserting on the message fragment so a refactor that swallows an error fails loudly:

code/lld/snake_and_ladder/tests/test_snake_and_ladder.py — board validation
@pytest.mark.parametrize(
    ("jumps", "config", "fragment"),
    [
        ([Jump(10, 30), Jump(10, 5)], MEDIUM, "overlaps"),
        ([Jump(10, 30), Jump(30, 50)], MEDIUM, "lands on the head"),
        (
            [Jump(10, 30), Jump(30, 50), Jump(50, 10)],
            GameConfig(size=60, allow_chained_jumps=True),
            "loops back",
        ),
        ([Jump(10, 90)], MEDIUM, "leaves a board"),
        ([Jump(60, 10)], MEDIUM, "starts on the last square"),
        ([Jump(10, 10)], MEDIUM, "goes nowhere"),
    ],
)
def test_broken_boards_are_rejected_at_construction(
    jumps: list[Jump], config: GameConfig, fragment: str
) -> None:
    with pytest.raises(InvalidJumpError, match=fragment):
        Board(jumps, config)
code/lld/snake_and_ladder/tests/test_snake_and_ladder.py — concurrency
def test_concurrent_turns_produce_exactly_one_record_per_turn() -> None:
    game = SnakeAndLadderGame(["A", "B", "C"], FairDice(random.Random(3)))
    accepted = 0

    def attempt(i: int) -> bool:
        try:
            game.take_turn(game.players[i % 3])
            return True
        except HandbookError:  # not this player's turn, or the game just ended
            return False

    for _ in range(20):
        if game.status not in (GameStatus.NOT_STARTED, GameStatus.IN_PROGRESS):
            break
        with ThreadPoolExecutor(max_workers=6) as pool:
            accepted += sum(pool.map(attempt, range(90)))

    records = game.records()
    assert game.status is GameStatus.WON
    assert accepted == len(records) == game.turns
    assert [r.number for r in records] == list(range(1, len(records) + 1))
    for player in game.players:  # every position is the end of that player's last turn
        own = [r for r in records if r.player == player]
        assert game.position(player) == own[-1].end
    assert game.ranking()[0] == game.result().winner

The rest cover: a seeded classic game replaying identically twice, with the observer receiving one event per turn plus a start and a win; chains followed only when enabled; out-of-turn, finished and one-player games refused; playing to last place skipping finishers and ranking everyone; the three dice classes for seeding, bounds and validation; and randomly generated boards being valid by construction. Run them with uv run pytest code/lld/snake_and_ladder -q.

45-minute pacing

Minutes What to do What to say or write
0–5 Clarify Exact finish or not? Chained jumps? Six grants another turn? How many dice? Out of scope: Ludo tokens, rendering, networking.
5–9 Reuse "This is the same turn loop as tic-tac-toe." Draw BoardGame with its four abstract methods and say what this game fills in.
9–14 Entities Board, Jump, Position, GameConfig, TurnRecord, DiceStrategy. Point out that snake versus ladder is derived, not a subclass.
14–24 Board validation Write the constructor: on-track, no duplicate head, no chain or no cycle. Walk the cycle detection out loud; it is the strongest part of the answer.
24–32 Turn resolution landing_square then resolve, in that order. Draw the bounce-onto-a-snake case and show that no special case is needed.
32–38 Game and concurrency The five overrides, advance_turn skipping finishers, then the single game lock and what take_turn makes atomic.
38–45 Extensions Ludo, six-grants-a-turn as a hook override, undo from TurnRecord, a networked table.