The 100 problems, solved / Games / 067 · Design Snake and Ladder

Design Snake and Ladder

Board, dice, players and turns, with jumps as data.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A machine-coding staple. The game itself is trivial; the marks are for clean separation of board, dice and turn order, and for treating snakes and ladders as data. A board with a subclass per snake is the wrong answer.

  • A game is set up with a board size, a set of jumps (snake heads to tails, ladder bottoms to tops) and two or more players in order.
  • On a turn, the current player rolls and moves forward by the roll.
  • Landing on a jump start moves the player to the jump end, following chains.
  • Overshooting the last square means the player stays put, under the common exact-finish rule.
  • The first player to reach the last square wins; the game reports each move for display.

Out of scope: a UI, networking, saving games. Constraints: the same dice can be seeded so a game replays exactly; configuration validation must reject a board that loops forever; rule variations (roll a six to start, a six gives another turn, multiple dice) must not require editing the board.

2. Core entities and relationships

  • Board: immutable after construction: the size and a map from jump start to jump end, with resolve(square).
  • Dice: an interface with roll(); RandomDice and a scripted FixedDice implement it.
  • Player: a name and a position; the only mutable state besides the turn.
  • MoveResult: an immutable record of one turn.
  • Game: the turn order, rules and winner.
GameFixed setupRecords2..*returnsGameboard: Boarddice: Diceplayers: deque[Player]winner: Player | Noneplay_turn() -> MoveResultplay() -> PlayerBoardsize: intjumps: dict[int, int]resolve(square) -> intDiceroll() -> intRandomDicesidesrngPlayername: strposition: intMoveResultplayerrollstartlandedendwon

The game composes its players' positions: a player's position means nothing outside a game. The board and dice are aggregated and injected, since an immutable board can be shared by any number of games and dice are the seam for tests.

3. Interfaces and key methods

from dataclasses import dataclass
from typing import Protocol

class Dice(Protocol):
    def roll(self) -> int:
        """One roll."""

@dataclass
class Player:
    name: str
    position: int = 0     # 0 is off the board, squares run 1..size

@dataclass(frozen=True)
class MoveResult:
    player: str
    roll: int
    start: int
    landed: int           # after the roll, before any jump
    end: int              # after jumps
    won: bool

class Board:
    def __init__(self, size: int, jumps: dict) -> None:
        """Validates jumps; raises ValueError for a bad board."""
    def resolve(self, square: int) -> int:
        """Follow jumps from a square to where the player finally stands."""

class Game:
    def play_turn(self) -> MoveResult:
        """Roll for the current player, apply the rules, pass the turn."""
    def play(self, max_turns: int = 100000) -> Player:
        """Run to completion and return the winner."""

Invariants: a board is immutable once built and every jump chain ends; a player's position is always in 0 to size and never rests on a jump start; only one player can win, and play_turn after a win raises.

4. Design patterns and the choices behind them

Jumps as data

The force: the naive model has Snake and Ladder classes with a shared parent and no behaviour beyond a start and an end. Both do the same thing: moving a player from one square to another. A single dictionary from start to end captures that completely. Whether a jump is a snake depends only on whether the end is below the start. This is board modeling at its plainest: the board is immutable data, and only player positions change. Lookup is O(1), and adding a jump is a line of configuration.

Dice as a strategy

The force: two dice, a loaded test die, or a rule such as "roll until not six". Dice is a one-method strategy. Injecting it means FixedDice([3, 4, 6]) makes a game completely deterministic, which is the difference between testing this code and hoping about it. The rejected alternative, calling random.randint inside Game, is the common choice and leaves only statistical tests.

Classes from the nouns, validation at the door

A use-case walk finds a board, dice, players and a turn. There is no Square or Cell class, because a square holds no behaviour; an integer is enough. Validation lives in the Board constructor, so a game can never start on an impossible board: a jump from the last square, a jump outside the board, or a chain that returns to itself, such as 10 to 20 and 20 to 10, which would hang resolve.

Who owns the rule about overshooting

The exact-finish rule looks like a board fact, but it is a game rule, and some house rules bounce the player back instead. So Board only answers where a square leads, and Game decides whether the move happens at all. Keeping that line clear means a bounce-back variant changes one expression in play_turn, and the shared immutable board stays correct for every rule set.

5. Core flow and code

  1. Game.play_turn takes the player at the front of the deque.
  2. Dice.roll returns a number; the target is position plus roll.
  3. If the target passes the last square, the player stays; otherwise the board resolves jumps from the target.
  4. The position updates, a MoveResult is built, and reaching the last square sets the winner.
  5. The player rotates to the back of the deque.
import random
from collections import deque
from typing import Optional

class RandomDice:
    def __init__(self, sides: int = 6, count: int = 1, seed: Optional[int] = None) -> None:
        self.sides, self.count = sides, count
        self.rng = random.Random(seed)
    def roll(self) -> int:
        return sum(self.rng.randint(1, self.sides) for _ in range(self.count))

class FixedDice:
    """Scripted rolls for tests."""
    def __init__(self, rolls: list) -> None:
        self.rolls = deque(rolls)
    def roll(self) -> int:
        return self.rolls.popleft()

class Board:
    def __init__(self, size: int, jumps: dict) -> None:
        self.size = size
        self.jumps = dict(jumps)
        self._validate()

    def _validate(self) -> None:
        for start, end in self.jumps.items():
            if start == end:
                raise ValueError(f"jump {start} goes nowhere")
            if not (1 <= start < self.size and 1 <= end <= self.size):
                raise ValueError(f"jump {start} to {end} is off the board")
        for start in self.jumps:
            seen, square = {start}, self.jumps[start]
            while square in self.jumps:
                if square in seen:
                    raise ValueError(f"jumps from {start} form a loop")
                seen.add(square)
                square = self.jumps[square]

    def resolve(self, square: int) -> int:
        while square in self.jumps:
            square = self.jumps[square]
        return square

class Game:
    def __init__(self, board: Board, dice: Dice, names: list) -> None:
        if len(names) < 2:
            raise ValueError("need at least two players")
        self.board, self.dice = board, dice
        self.players = deque(Player(n) for n in names)
        self.winner: Optional[Player] = None

    def play_turn(self) -> MoveResult:
        if self.winner is not None:
            raise RuntimeError("the game is over")
        player = self.players[0]
        roll = self.dice.roll()
        start, target = player.position, player.position + roll
        end = start if target > self.board.size else self.board.resolve(target)
        player.position = end
        won = end == self.board.size
        if won:
            self.winner = player
        self.players.rotate(-1)
        return MoveResult(player.name, roll, start, min(target, self.board.size), end, won)

    def play(self, max_turns: int = 100000) -> Player:
        for _ in range(max_turns):
            if self.play_turn().won:
                return self.winner
        raise RuntimeError("no winner within the turn limit")

if __name__ == "__main__":
    board = Board(10, {3: 7, 8: 2})
    game = Game(board, FixedDice([3, 1, 3, 2]), ["ann", "bo"])
    first = game.play_turn()
    assert (first.landed, first.end) == (3, 7)   # ladder
    game.play_turn()
    last = game.play_turn()
    assert last.player == "ann" and last.end == 10 and last.won

Keeping landed and end separately on MoveResult lets a UI animate the roll and then the jump, without the game knowing there is a UI.

6. Extensions, concurrency and what interviewers probe

  • "A six gives another turn." Skip the rotate when the roll is a six; if rules multiply, extract a TurnPolicy that decides who is next.
  • "Multiple dice." RandomDice(count=2); nothing else changes, and that is the point of the seam.
  • "Land on another player and send them home." Add a rule step after end is computed. Keep it inside Game, since it concerns players and the board knows nothing about them.
  • "What is the expected game length?" A Markov chain over squares, or a simulation with many seeded games. The immutable board makes that safe to run in parallel, since it is the only shared object.
  • Test first: with FixedDice cover a ladder, a snake, an overshoot that stays put, an exact finish, and a looping board rejected at construction.
  • Common mistake: a Square class per cell with snake and ladder subclasses, which adds a hierarchy for what one dictionary does, and randomness hard-wired into the game so it cannot be tested.

02Where this question was reported