~learn.

LLD 0912 min+60 XP

Design Snake and Ladder.

Design a snake-and-ladder game with pluggable dice and rules, then play it

5 code

Lesson · 12 min

A game every child in India has played, and a favourite first machine-coding round: no hard algorithm, so the interviewer sees nothing but your modelling, your validation and whether your code runs.

Snake and ladder is often the first problem in a machine-coding round, and it is easy to underrate. The game logic fits in twenty lines, so the interviewer isn't scoring an algorithm. They are scoring whether Board, Dice, Player and Game each have one job, whether you validate a board instead of trusting it, whether the parts that families argue about (how many dice, what happens past 100, what a six earns you) are behind interfaces, and whether a seeded run gives the same game twice.

This lesson builds it in that order: the board and its four rules, the dice and end-of-board strategies, the turn loop with the three-sixes rule, a turn check that survives double taps, and a demo that plays a scripted game and a thousand seeded ones.

How the round runs#

Interviewer

"Design snake and ladder. A 100-square board, snakes and ladders at given positions, two or more players taking turns with a die. First to reach 100 wins. Make it easy to change the dice and the rules. Working code with a demo. 90 minutes."

A good 90 minutes
  • 0–10 min —
    clarify: board size, invalid boards, dice, overshooting 100, what a six does, players
  • 10–20 min —
    the classes, and which parts vary: dice and the end rule
  • 20–40 min —
    Board and its validation; Jump as a value object
  • 40–60 min —
    the dice and end-rule strategies, then Game.take_turn()
  • 60–75 min —
    a scripted game, the turn check, a thousand seeded games
  • 75–90 min —
    follow-ups: ranking all players, doubles, power-ups, online rooms, replays

Requirements and clarifying questions#

You ask They answer What it changes
Board size and layout? 100 squares; snakes and ladders as start → end pairs. Board(size, jumps) with one Jump type
What makes a board invalid? You tell me. Four rules, checked in the constructor
How many dice? One today; two later. A Dice interface
A roll that goes past 100? Stay where you are. Some families bounce back instead. An EndRule strategy: exact, or bounce
What does a six do? Roll again. Three sixes in a row void the whole turn. A streak counter inside one turn
How many players? Two or more, in a fixed order; first to 100 wins. A turn index and a winner
Played online? Each player taps "roll" on their own phone. A turn check and one lock per game
Your turn · checkpoint 1not answered

Ladder 4 → 14, snake 14 → 7. What's wrong with this board?

A player lands on 4 and climbs to 14, where a snake's head is waiting. Do they slide to 7? And what happens if the board also has a ladder 7 → 14?

Think it through, or jot it down. Then compare.

One strong answer

It's a chain: one jump ends where another starts. Families disagree on whether the second jump applies, so the game is ambiguous; and with a ladder 7 → 14 and a snake 14 → 7, a "follow every jump" rule loops forever. The fix is a board rule, not a play rule: no jump may end where another jump starts. That one check rules out every cycle too, since a cycle needs a chain. Check it in the Board constructor, so a bad board never reaches a player.

The classes#

DiagramSnake and ladder: the classes
%% title: Snake and ladder: the classes
classDiagram
  class Game {
    +board Board
    +players list
    +dice Dice
    +end_rule EndRule
    +winner str
    +take_turn(name) list
    +play(max_turns) str
  }
  class Board {
    +size int
    +jumps dict
    +destination(square) tuple
  }
  class Jump {
    +start int
    +end int
    +kind str
  }
  class Player {
    +name str
    +position int
  }
  class Dice {
    <<interface>>
    +roll() list
    +max_total int
  }
  class EndRule {
    <<interface>>
    +target(position, roll, size) int
  }
  Game --> Board
  Game "1" *-- "many" Player
  Game --> Dice
  Game --> EndRule
  Board "1" *-- "many" Jump
  Dice <|.. StandardDice
  Dice <|.. ScriptedDice
  EndRule <|.. ExactEnd
  EndRule <|.. BounceBack

Snakes and ladders are one class. A ladder is a jump that goes up and a snake is a jump that goes down; kind is derived from the direction, never stored. Two classes with identical fields and a move() that does the same thing is the most common over-design in this round.

The Board is immutable after construction and knows nothing about players. The Game owns the mutable state: positions, whose turn it is, the winner. That split is what lets one validated board be shared by many games at once.

The interfaces#

The board and its four rules. Validation runs once, in the constructor, and raises with a message a level designer can act on.

pythonJump · 42 lines
# snakes.py, part 1: the board, and the rules it checks before the first roll
from __future__ import annotations

import random
import threading
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass


@dataclass(frozen=True)
class Jump:
    start: int
    end: int

    @property
    def kind(self) -> str:
        return "ladder" if self.end > self.start else "snake"


class InvalidBoard(ValueError): ...


class Board:
    def __init__(self, size: int, jumps: list[Jump]) -> None:
        self.size = size
        self.jumps: dict[int, Jump] = {}
        for j in jumps:
            if not (1 <= j.start <= size and 1 <= j.end <= size) or j.start == j.end:  # rule 1: on the board
                raise InvalidBoard(f"{j.kind} {j.start}->{j.end} is off the board or goes nowhere")
            if j.start == size:  # rule 2: nothing on the winning square
                raise InvalidBoard(f"a {j.kind} can't start on {size}, the winning square")
            if j.start in self.jumps:  # rule 3: one jump per square
                raise InvalidBoard(f"two jumps start on {j.start}")
            self.jumps[j.start] = j
        for j in self.jumps.values():  # rule 4: no chains, which also means no cycles
            if j.end in self.jumps:
                raise InvalidBoard(f"{j.kind} {j.start}->{j.end} ends where another jump starts")

    def destination(self, square: int) -> tuple[int, Jump | None]:
        j = self.jumps.get(square)
        return (j.end, j) if j else (square, None)

The two things families argue about, as strategies. Dice.roll() returns the faces, not just the total, so a later "doubles" rule can see them. max_total is what counts as "a six": 6 with one die, 12 with two. ScriptedDice plays back fixed rolls, which is how you test a game of chance.

pythonDice · 39 lines
# part 2: what varies between families: the dice, and what happens past the last square
class Dice(ABC):
    max_total: int

    @abstractmethod
    def roll(self) -> list[int]: ...


class StandardDice(Dice):
    def __init__(self, count: int = 1, sides: int = 6, rng: random.Random | None = None) -> None:
        self.count, self.sides, self.rng = count, sides, rng or random.Random()
        self.max_total = count * sides

    def roll(self) -> list[int]:
        return [self.rng.randint(1, self.sides) for _ in range(self.count)]


class ScriptedDice(Dice):  # plays back fixed rolls: for tests and demos
    def __init__(self, rolls: list[int], sides: int = 6) -> None:
        self._rolls, self.max_total = iter(rolls), sides

    def roll(self) -> list[int]:
        return [next(self._rolls)]


class EndRule(ABC):
    @abstractmethod
    def target(self, position: int, roll: int, size: int) -> int: ...


class ExactEnd(EndRule):  # overshoot: stay put
    def target(self, position: int, roll: int, size: int) -> int:
        return position + roll if position + roll <= size else position


class BounceBack(EndRule):  # overshoot: count back from the last square
    def target(self, position: int, roll: int, size: int) -> int:
        t = position + roll
        return t if t <= size else size - (t - size)

The patterns, and why#

Pattern Where Why it earns its place
Strategy Dice, EndRule The two rules families change. Two dice, a loaded die for tests, or "bounce back" are new classes, not new ifs in the game.
Value object Jump (frozen), with kind derived Snakes and ladders are the same thing pointing different ways.
Validate on construction Board.__init__ An invalid board can't exist, so the game never checks for one.
Observer Game.listeners A console, a phone UI and a replay log subscribe to the same move lines.
Dependency injection StandardDice(rng=random.Random(seed)) The randomness is passed in, so a seed replays a game exactly: tests, bug reports, and "show me that game again".

Concurrency and edge cases#

Online, each phone's "roll" button is a request on its own server thread. Two problems. Two players tap at once: without a lock, both read current, both move, and the turn order breaks. One player double-taps: both requests say "Asha", and without a check she gets two turns. One lock per game makes "check whose turn it is, roll, move, pass the turn" a single step, and the check inside it means the second tap finds that it is no longer Asha's turn. Listeners are called after the lock is released, so a slow phone can't stall the game. The random generator is only touched inside the lock, so one seeded Random per game stays deterministic.

Edge cases
  • A snake on 100, a jump to square 0 or 101, a jump from a square to itself: rejected by the board
  • Two jumps from one square, or a chain: rejected, which also rules out loops
  • A roll past 100: ExactEnd stays put; a six still earns its extra roll
  • A ladder straight to 100: a win, like any other arrival on 100
  • Three sixes: the token goes back to where the turn began, undoing the first two moves too
  • Rolling out of turn, or after the game is over: a specific exception, not a silent no-op
  • A game that never ends (a bad custom rule): play() takes a max_turns guard

The code#

One turn can be several rolls. take_turn checks whose turn it is, rolls, moves, and rolls again while the roll was a six; on the third six in a row the turn is void and the token goes back to where the turn began. A win ends the turn at once. Only then does the turn pass on.

pythonPlayer · 68 lines
# part 3: players, the turn loop, and the turn check that survives a double tap
@dataclass
class Player:
    name: str
    position: int = 0  # 0 = not on the board yet


class NotYourTurn(Exception): ...


class GameOver(Exception): ...


class Game:
    def __init__(self, board: Board, names: list[str], dice: Dice, end_rule: EndRule | None = None, bonus_limit: int = 3) -> None:
        if len(names) < 2 or len(set(names)) != len(names):
            raise ValueError("need two or more players with different names")
        self.board, self.dice, self.end_rule = board, dice, end_rule or ExactEnd()
        self.players = [Player(n) for n in names]
        self.bonus_limit = bonus_limit
        self.current, self.turns = 0, 0
        self.winner: str | None = None
        self.listeners: list[Callable[[str], None]] = []
        self._lock = threading.Lock()

    def take_turn(self, name: str) -> list[str]:
        with self._lock:
            if self.winner:
                raise GameOver(f"{self.winner} has already won")
            p = self.players[self.current]
            if p.name != name:
                raise NotYourTurn(f"it is {p.name}'s turn")
            log, start, streak = [], p.position, 0
            while True:
                total = sum(self.dice.roll())
                bonus = total == self.dice.max_total
                streak += bonus
                if streak == self.bonus_limit:
                    p.position = start
                    log.append(f"{p.name} rolls {total}: {streak} in a row, turn void, back to {start}")
                    break
                log.append(self._move(p, total))
                if p.position == self.board.size:
                    self.winner = p.name
                    log.append(f"{p.name} wins")
                    break
                if not bonus:
                    break
            self.current = (self.current + 1) % len(self.players)
            self.turns += 1
        for line in log:  # tell the listeners outside the lock
            for fn in self.listeners:
                fn(line)
        return log

    def _move(self, p: Player, total: int) -> str:
        target = self.end_rule.target(p.position, total, self.board.size)
        if target == p.position:
            return f"{p.name} rolls {total}: needs exactly {self.board.size - p.position}, stays on {p.position}"
        land, jump = self.board.destination(target)
        msg = f"{p.name} rolls {total}: {p.position} -> {target}" + (f", {jump.kind} to {land}" if jump else "")
        p.position = land
        return msg

    def play(self, max_turns: int = 10_000) -> str | None:
        while self.winner is None and self.turns < max_turns:
            self.take_turn(self.players[self.current].name)
        return self.winner

The demo builds a classic layout, rejects three broken boards, plays a scripted game that hits every rule, races a double tap, and plays a thousand seeded games.

pythontap · 51 lines
# part 4: broken boards, a scripted game, a double tap, and a thousand seeded games
if __name__ == "__main__":
    ladders = [(1, 38), (4, 14), (9, 31), (21, 42), (28, 84), (36, 44), (51, 67), (71, 91), (80, 100)]
    snakes = [(16, 6), (47, 26), (49, 11), (56, 53), (62, 19), (64, 60), (87, 24), (93, 73), (95, 75), (98, 78)]
    classic = Board(100, [Jump(s, e) for s, e in ladders + snakes])

    for bad in ([Jump(100, 5)], [Jump(4, 14), Jump(4, 20)], [Jump(4, 14), Jump(14, 7)]):
        try:
            Board(100, bad)
        except InvalidBoard as e:
            print("rejected:", e)

    rolls = [4, 6, 6, 6, 2, 1, 3, 6, 5, 5, 2, 6, 1, 4, 4, 3, 5, 1, 6, 4]
    game = Game(classic, ["Asha", "Bilal"], ScriptedDice(rolls))
    game.listeners.append(lambda line: print(" ", line))
    try:
        game.take_turn("Bilal")
    except NotYourTurn as e:
        print("Bilal taps early:", e)
    print("winner:", game.play(), "after", game.turns, "turns")
    try:
        game.take_turn("Bilal")
    except GameOver as e:
        print("after the game:", e)

    tapped = Game(classic, ["Asha", "Bilal"], StandardDice(rng=random.Random(1)))
    results: list[str] = []

    def tap() -> None:
        try:
            tapped.take_turn("Asha")
            results.append("moved")
        except NotYourTurn:
            results.append("rejected")

    threads = [threading.Thread(target=tap) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print("Asha double-taps:", sorted(results), "| now it is", tapped.players[tapped.current].name + "'s turn")

    for label, dice, rule in [("1 die, exact", 1, ExactEnd()), ("2 dice, bounce", 2, BounceBack())]:
        turns = []
        for seed in range(1000):
            g = Game(classic, ["A", "B", "C"], StandardDice(dice, rng=random.Random(seed)), rule)
            g.play()
            turns.append(g.turns)
        print(f"{label}: 1,000 games, average {sum(turns) / len(turns):.1f} turns, longest {max(turns)}")
    same = [Game(classic, ["A", "B"], StandardDice(rng=random.Random(42))).play() for _ in range(2)]
    print("seed 42 twice:", same)
text31 lines
rejected: a snake can't start on 100, the winning square
rejected: two jumps start on 4
rejected: ladder 4->14 ends where another jump starts
Bilal taps early: it is Asha's turn
  Asha rolls 4: 0 -> 4, ladder to 14
  Bilal rolls 6: 0 -> 6
  Bilal rolls 6: 6 -> 12
  Bilal rolls 6: 3 in a row, turn void, back to 0
  Asha rolls 2: 14 -> 16, snake to 6
  Bilal rolls 1: 0 -> 1, ladder to 38
  Asha rolls 3: 6 -> 9, ladder to 31
  Bilal rolls 6: 38 -> 44
  Bilal rolls 5: 44 -> 49, snake to 11
  Asha rolls 5: 31 -> 36, ladder to 44
  Bilal rolls 2: 11 -> 13
  Asha rolls 6: 44 -> 50
  Asha rolls 1: 50 -> 51, ladder to 67
  Bilal rolls 4: 13 -> 17
  Asha rolls 4: 67 -> 71, ladder to 91
  Bilal rolls 3: 17 -> 20
  Asha rolls 5: 91 -> 96
  Bilal rolls 1: 20 -> 21, ladder to 42
  Asha rolls 6: needs exactly 4, stays on 96
  Asha rolls 4: 96 -> 100
  Asha wins
winner: Asha after 15 turns
after the game: Asha has already won
Asha double-taps: ['moved', 'rejected'] | now it is Bilal's turn
1 die, exact: 1,000 games, average 53.2 turns, longest 185
2 dice, bounce: 1,000 games, average 45.5 turns, longest 218
seed 42 twice: ['A', 'A']

Follow Bilal: two sixes take him to 12, the third voids the turn, and he's back on 0. Later a six on 38 takes him to 44, and the bonus roll of 5 lands on the snake at 49, down to 11. Asha climbs four ladders to 96, rolls a six that would overshoot (she stays, but the six still earns a roll) and finishes with an exact 4. A turn is one player's go, bonus rolls included, so 15 turns is eight for Asha and seven for Bilal. The double tap moves Asha once; the second tap finds it is Bilal's turn. Across 1,000 seeded three-player games, two dice with bounce-back finish faster on average than one die with exact landing, and seed 42 plays the same game twice.

Extending it: the follow-ups#

  • "Keep playing until everyone has finished, and rank them." Finished players leave the rotation and go into a ranking list; the game ends when one player is left. winner becomes ranking[0].
  • "Two dice, and doubles earn the extra roll." A BonusRule strategy beside Dice: MaxTotalBonus (today's rule) and DoublesBonus, which is why roll() returns the faces and not just the total.
  • "Add power-ups: a square that skips the next player, or a shield against one snake." A Square effect looked up after the jump, and per-player state such as shields. The board validation grows a rule: no effect on a jump's start.
  • "Rooms for online play." A GameService mapping room codes to games, each with its own lock. Moves go out to phones through the listener.
  • "Replay a game for a dispute." Store the seed (or every roll) and the player list. Replaying with ScriptedDice gives the same game, move for move.
  • "Generate random boards." A generator that places jumps and retries until Board() stops raising: the validation is the generator's test.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • Snake and Ladder as separate classes with identical fields and code
  • No board validation, or validating while playing, so a chain or a snake on 100 is found by a player
  • random.randint called inside the game, so no test can control a roll and no game can be replayed
  • A while True game loop with no turn check, so an online double tap moves a player twice
  • Positions stored on the board as well as the player, so the two disagree after a snake
  • Voiding a three-sixes turn without moving the token back to where the turn began
Quick check3 questions · +5 XP each

Snake and ladder

  1. Q1/3 A board has a ladder 4 → 14 and a snake 14 → 7. What should the Board constructor do?

    Answer & explanation

    Answer: C

    A chain makes the game ambiguous, and with a ladder 7 → 14 it becomes an infinite loop. Banning chains rules out every cycle, and checking in the constructor means no game ever sees a bad board.

  2. Q2/3 Asha is on 96 and rolls a 6 with the exact-landing rule. What happens?

    Answer & explanation

    Answer: C

    With ExactEnd an overshoot leaves her where she is. The six still earns a bonus roll; the bounce-back variant would put her on 98 instead.

  3. Q3/3 Why does the game take a Dice object instead of calling random.randint itself?

    Answer & explanation

    Answer: B

    Injecting the dice makes a game of chance testable: ScriptedDice for exact scenarios, a seeded Random for reproducible games, and two dice later without touching Game.

End of LLD 09 · Design Snake and Ladder
Finish LLD 09Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…