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#
"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."
- 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 —
Boardand its validation;Jumpas 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 |
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?
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#
%% 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 <|.. BounceBackSnakes 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.
# 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.
# 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.
- 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:
ExactEndstays 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 amax_turnsguard
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.
# 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.winnerThe 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.
# 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)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
rankinglist; the game ends when one player is left.winnerbecomesranking[0]. - "Two dice, and doubles earn the extra roll." A
BonusRulestrategy besideDice:MaxTotalBonus(today's rule) andDoublesBonus, which is whyroll()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
Squareeffect looked up after the jump, and per-player state such asshields. The board validation grows a rule: no effect on a jump's start. - "Rooms for online play." A
GameServicemapping 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
ScriptedDicegives 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.
Common mistakes#
SnakeandLadderas 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.randintcalled inside the game, so no test can control a roll and no game can be replayed- A
while Truegame 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
Snake and ladder
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.
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.
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.
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.