Snake & Ladder
Board with position jumps, configurable snakes and ladders, and a dice strategy so you can swap fair for loaded. Builder pattern keeps board construction clean.
Key Abstractions
Orchestrator managing player turns, dice rolls, and win detection
Grid of cells with registered snakes and ladders, resolves final position after a move
Tracks name, current position, and move history
Strategy interface for rolling. FairDice gives uniform distribution, LoadedDice can be weighted.
Position jump where start > end. Landing on head sends you to tail.
Position jump where start < end. Landing on bottom sends you to top.
Enum tracking IN_PROGRESS, WON, or DRAW
Fluent builder for constructing boards with validated snake and ladder placement
Class Diagram
Snakes and Ladders Are the Same Thing
Both snakes and ladders do exactly one thing: move a player from position A to position B. A snake moves you down, a ladder moves you up, but mechanically they're identical. This means the board can store both as a single jumps map from start position to end position. When a player lands on a cell, check the map. If there's an entry, teleport them. If not, they stay.
Separate Snake and Ladder types still make sense for construction and validation. A snake enforces head > tail, a ladder enforces bottom < top. But once they're registered on the board, the distinction dissolves into a hashmap lookup.
The more interesting design question is how you build the board. You need to validate that no two jumps share a start position, that no snake head sits on cell 1 or 100, and that positions stay within bounds. A Builder handles this cleanly because construction is a multi-step process with cross-field validation that only makes sense once everything is assembled.
Requirements
Functional
- Configurable NxN board (default 100 cells) with arbitrary snake and ladder placement
- 2+ players take turns rolling a dice and moving forward
- Landing on a snake head sends the player to the tail. Landing on a ladder bottom sends them to the top.
- Player must land exactly on the final cell to win. Rolling past it means staying put.
- Dice should be pluggable (fair, loaded, or deterministic for testing)
Non-Functional
- Board construction should validate all constraints before the game starts
- Game events should be observable without modifying game logic
- Position resolution should be O(1) per move
Design Decisions
Why is Board separate from Game?
Game handles turn management, player cycling, and win detection. Board handles position resolution: "if you land here, you actually end up there." Merging them means you can't test snake/ladder logic without setting up players and a dice. Separate Board lets you write a simple test: assert board.resolve_position(27) == 5.
Why Builder for Board instead of a constructor?
A board with 10 snakes and 8 ladders has a lot of construction parameters. More importantly, you need cross-field validation: no duplicate start positions, no snake at cell 1, all positions within bounds. A constructor would either need all of this in one massive parameter list or skip validation entirely. Builder accumulates pieces incrementally and validates everything in build().
Why are Snake and Ladder separate classes if the board merges them?
Type safety during construction. If you just accept (from, to) pairs, nothing stops you from accidentally creating a "snake" that goes upward. Separate types with validation in __post_init__ (Python) or the constructor (Java) catch this at creation time. After they're on the board, yes, they're both just entries in a jump map.
Why Observer for game events?
Without observers, logging means print statements scattered through Game. Testing means capturing stdout. Want to add a GUI later? More print replacements. With observers, Game fires events. A ConsoleLogger prints them. A test listener asserts on them. A GUI listener updates the screen. Game never changes.
Interview Follow-ups
- "How would you handle special dice rules like rolling a 6 gives an extra turn?" Add a
bonus_rollcheck after each roll. If the dice returns its max face, the same player goes again. This stays in Game since it's turn logic, not board logic. - "How would you prevent infinite loops from cyclic jumps?" During
build(), run a cycle detection pass. For each jump destination, follow the chain and check if you revisit a position. Reject the board if a cycle exists. - "How would you add multiplayer over a network?" Extract Game into a GameServer. Each client sends a "roll" command. Server rolls the dice, computes the move, and broadcasts the updated state to all clients.
- "How would you persist and resume a game?" Serialize the board configuration, player positions, and whose turn it is. Board is immutable after construction so it only needs to be stored once. Player state is just a list of (name, position) pairs.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 from enum import Enum
5 import random
6
7
8 class GameState(Enum):
9 IN_PROGRESS = "in_progress"
10 WON = "won"
11
12
13 class Dice(ABC):
14 @abstractmethod
15 def roll(self) -> int: ...
16
17
18 class FairDice(Dice):
19 def __init__(self, num_faces: int = 6):
20 if num_faces < 1:
21 raise ValueError("Dice must have at least 1 face")
22 self._num_faces = num_faces
23
24 def roll(self) -> int:
25 return random.randint(1, self._num_faces)
26
27
28 class LoadedDice(Dice):
29 """Weighted dice where you can bias certain outcomes."""
30
31 def __init__(self, weights: list[float]):
32 if not weights or any(w < 0 for w in weights):
33 raise ValueError("Weights must be positive and non-empty")
34 self._faces = list(range(1, len(weights) + 1))
35 self._weights = weights
36
37 def roll(self) -> int:
38 return random.choices(self._faces, weights=self._weights, k=1)[0]
39
40
41 @dataclass
42 class Snake:
43 head: int
44 tail: int
45
46 def __post_init__(self):
47 if self.head <= self.tail:
48 raise ValueError(f"Snake head ({self.head}) must be above tail ({self.tail})")
49
50
51 @dataclass
52 class Ladder:
53 bottom: int
54 top: int
55
56 def __post_init__(self):
57 if self.bottom >= self.top:
58 raise ValueError(f"Ladder bottom ({self.bottom}) must be below top ({self.top})")
59
60
61 class Board:
62 def __init__(self, size: int, snakes: list[Snake], ladders: list[Ladder]):
63 self._size = size
64 self._jumps: dict[int, int] = {}
65 for snake in snakes:
66 self._jumps[snake.head] = snake.tail
67 for ladder in ladders:
68 self._jumps[ladder.bottom] = ladder.top
69
70 @property
71 def size(self) -> int:
72 return self._size
73
74 def resolve_position(self, position: int) -> int:
75 """Apply any snake or ladder at the given position."""
76 return self._jumps.get(position, position)
77
78
79 class BoardBuilder:
80 def __init__(self):
81 self._size: int = 100
82 self._snakes: list[Snake] = []
83 self._ladders: list[Ladder] = []
84 self._occupied: set[int] = set()
85
86 def set_size(self, size: int) -> "BoardBuilder":
87 if size < 10:
88 raise ValueError("Board size must be at least 10")
89 self._size = size
90 return self
91
92 def add_snake(self, head: int, tail: int) -> "BoardBuilder":
93 if head in self._occupied:
94 raise ValueError(f"Position {head} is already occupied by another snake or ladder")
95 snake = Snake(head, tail)
96 self._snakes.append(snake)
97 self._occupied.add(head)
98 return self
99
100 def add_ladder(self, bottom: int, top: int) -> "BoardBuilder":
101 if bottom in self._occupied:
102 raise ValueError(f"Position {bottom} is already occupied by another snake or ladder")
103 ladder = Ladder(bottom, top)
104 self._ladders.append(ladder)
105 self._occupied.add(bottom)
106 return self
107
108 def build(self) -> Board:
109 for snake in self._snakes:
110 if not (1 < snake.head <= self._size and 1 <= snake.tail < self._size):
111 raise ValueError(f"Snake ({snake.head}->{snake.tail}) out of board bounds")
112 for ladder in self._ladders:
113 if not (1 <= ladder.bottom < self._size and 1 < ladder.top <= self._size):
114 raise ValueError(f"Ladder ({ladder.bottom}->{ladder.top}) out of board bounds")
115 return Board(self._size, list(self._snakes), list(self._ladders))
116
117
118 @dataclass
119 class Player:
120 name: str
121 position: int = 1
122
123
124 class GameEventListener(ABC):
125 """Observer interface for game events."""
126
127 @abstractmethod
128 def on_roll(self, player: Player, roll: int) -> None: ...
129
130 @abstractmethod
131 def on_move(self, player: Player, from_pos: int, to_pos: int, jumped: bool) -> None: ...
132
133 @abstractmethod
134 def on_win(self, player: Player) -> None: ...
135
136
137 class ConsoleLogger(GameEventListener):
138 def on_roll(self, player: Player, roll: int) -> None:
139 print(f" {player.name} rolled a {roll}")
140
141 def on_move(self, player: Player, from_pos: int, to_pos: int, jumped: bool) -> None:
142 if jumped:
143 jump_type = "snake" if to_pos < from_pos else "ladder"
144 print(f" Landed on a {jump_type}! {from_pos} -> {to_pos}")
145 else:
146 print(f" Moved to position {to_pos}")
147
148 def on_win(self, player: Player) -> None:
149 print(f"\n {player.name} wins!")
150
151
152 class Game:
153 def __init__(
154 self,
155 board: Board,
156 players: list[Player],
157 dice: Dice,
158 listeners: list[GameEventListener] | None = None,
159 ):
160 if len(players) < 2:
161 raise ValueError("Need at least 2 players")
162 self._board = board
163 self._players = players
164 self._dice = dice
165 self._listeners = listeners or []
166 self._current_turn = 0
167 self._state = GameState.IN_PROGRESS
168
169 @property
170 def state(self) -> GameState:
171 return self._state
172
173 def _notify_roll(self, player: Player, roll: int) -> None:
174 for listener in self._listeners:
175 listener.on_roll(player, roll)
176
177 def _notify_move(self, player: Player, from_pos: int, to_pos: int, jumped: bool) -> None:
178 for listener in self._listeners:
179 listener.on_move(player, from_pos, to_pos, jumped)
180
181 def _notify_win(self, player: Player) -> None:
182 for listener in self._listeners:
183 listener.on_win(player)
184
185 def take_turn(self) -> GameState:
186 if self._state != GameState.IN_PROGRESS:
187 raise RuntimeError("Game is already over")
188
189 player = self._players[self._current_turn]
190 roll = self._dice.roll()
191 self._notify_roll(player, roll)
192
193 new_pos = player.position + roll
194
195 # Can't move past the last cell
196 if new_pos > self._board.size:
197 self._notify_move(player, player.position, player.position, False)
198 self._current_turn = (self._current_turn + 1) % len(self._players)
199 return self._state
200
201 landed_pos = new_pos
202 resolved_pos = self._board.resolve_position(landed_pos)
203 jumped = resolved_pos != landed_pos
204
205 player.position = resolved_pos
206 self._notify_move(player, landed_pos, resolved_pos, jumped)
207
208 if player.position == self._board.size:
209 self._state = GameState.WON
210 self._notify_win(player)
211 else:
212 self._current_turn = (self._current_turn + 1) % len(self._players)
213
214 return self._state
215
216 def play(self) -> Player:
217 while self._state == GameState.IN_PROGRESS:
218 self.take_turn()
219 return self._players[self._current_turn]
220
221
222 if __name__ == "__main__":
223 board = (
224 BoardBuilder()
225 .set_size(30)
226 .add_snake(27, 5)
227 .add_snake(21, 9)
228 .add_snake(17, 7)
229 .add_ladder(3, 22)
230 .add_ladder(5, 8) # note: 5 is also a snake tail, but it's only the head/bottom that conflicts
231 .add_ladder(11, 26)
232 .add_ladder(20, 29)
233 .build()
234 )
235
236 players = [Player("Alice"), Player("Bob")]
237 dice = FairDice(num_faces=6)
238 logger = ConsoleLogger()
239
240 game = Game(board, players, dice, listeners=[logger])
241
242 print("=== Snake & Ladder Game (board size: 30) ===\n")
243 winner = game.play()
244 print(f"\nGame over. {winner.name} reached position {winner.position}!")Common Mistakes
- ✗Putting snake/ladder logic directly in Game instead of Board. Makes it impossible to test board rules in isolation.
- ✗Using a raw integer for dice without an abstraction. You lose the ability to inject test doubles.
- ✗Forgetting to handle the case where a player rolls past position 100 and should stay put.
- ✗Allowing a snake head and ladder bottom on the same cell. That's an ambiguous board state.
Key Points
- ✓Snakes and ladders are both position jumps. A Snake has start > end, a Ladder has start < end. Same interface, different direction.
- ✓Strategy pattern on Dice lets you inject fair, loaded, or deterministic dice for testing.
- ✓Builder pattern for Board handles validation: no snake head at a ladder bottom, no cycles, no overlapping positions.
- ✓Board is separate from Game so you can test position resolution without dealing with turns or players.