Design Snake and Ladder
Board, dice, players and turns, with jumps as data.
- Modeling boards and grids — Cells, moves, win checks and bounds for games and simulators.
- Strategy — Swapping an algorithm at runtime by passing an object.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
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, withresolve(square).Dice: an interface withroll();RandomDiceand a scriptedFixedDiceimplement 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.
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
Game.play_turntakes the player at the front of the deque.Dice.rollreturns a number; the target is position plus roll.- If the target passes the last square, the player stays; otherwise the board resolves jumps from the target.
- The position updates, a
MoveResultis built, and reaching the last square sets the winner. - 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
rotatewhen the roll is a six; if rules multiply, extract aTurnPolicythat 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
endis computed. Keep it insideGame, 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
FixedDicecover a ladder, a snake, an overshoot that stays put, an exact finish, and a looping board rejected at construction. - Common mistake: a
Squareclass 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
- GitHub · awesome-low-level-design, Hard
- workat.tech · workat.tech machine coding (SDE I)
- systemdesign.academy · systemdesign.academy solved
- LLD Canvas · Immutable board, only player position changes
- thita.ai · thita.ai LLD sheet, foundational