Snakes and Ladders - Interview Questions & Answers¶
Target Level: Senior/Staff Engineer (6+ years)
Evaluation Focus: Modelling, turn state machine, randomness/testability, concurrency, extensibility
Question 1: Core Design¶
Interviewer: "Design a Snakes and Ladders game with configurable board size, multiple players, and customizable dice."
🎯 Expected Answer¶
One Jump type, validated once. A snake and a ladder are both start -> end; direction tells you which. Board stores dict[start, Jump] and rejects bad layouts in its constructor (off-board, jump on the goal cell, two jumps on one cell, chains). After that, resolve(cell) is one lookup.
Strategy for dice, returning the faces:
@dataclass(frozen=True)
class Roll:
faces: tuple[int, ...]
@property
def total(self): return sum(self.faces)
@property
def is_doubles(self): return len(self.faces) > 1 and len(set(self.faces)) == 1
class Dice(ABC):
@abstractmethod
def roll(self) -> Roll: ...
class StandardDice(Dice): # count dice, `sides` faces, injectable rng
class CrookedDice(Dice): # only even faces
class ScriptedDice(Dice): # fixed sequence, for tests and replay
Returning int from roll() is the common trap: you lose the faces and then can't tell doubles (3+3) from an even sum (1+5).
Observer for output. GameObserver.on_turn(TurnResult) / on_game_over(winner). Console, websocket push and achievements are all observers; Game never prints.
Question 2: Turn Management with Doubles¶
Interviewer: "Two dice. Doubles give another turn, but three doubles in a row forfeits the turn."
roll = self._dice.roll()
self._doubles_streak = self._doubles_streak + 1 if roll.is_doubles else 0
forfeited = self._doubles_streak >= self._max_doubles
...
extra = self._extra_turn_on_doubles and roll.is_doubles and not forfeited and not won
if not extra:
self._doubles_streak = 0
self._current = (self._current + 1) % len(self._players)
Points interviewers look for: the streak is per-turn-holder and resets when the turn passes; winning on doubles ends the game rather than granting a turn; the forfeit check happens before moving.
Figure: one turn with doubles giving an extra turn and a doubles streak limit.
flowchart TD
A[Roll two dice] --> B{"Doubles streak reached the limit?"}
B -- Yes --> F["Forfeit the move, turn passes"]
B -- No --> C["Move, resolve snake or ladder"]
C --> D{"Won?"}
D -- Yes --> W[Game over]
D -- No --> E{"Doubles?"}
E -- Yes --> A
E -- No --> N[Next player]
Question 3: "Now make it online — two clients can press roll at once"¶
Answer: One lock per game. Inside it: check status, check it's this caller's turn, roll, move, advance the turn, append history. Outside callers get NotYourTurnError / GameOverError.
The bug to avoid is check-then-act across the lock boundary:
if game.current_player == me: # check (unlocked)
game.play_turn() # act — another request may have moved in between
play_turn(player_name) does the check inside the critical section. A duplicate request (client retry, double-click) arrives after the turn has passed and is rejected, so it can't double-move. Lock granularity is per game because games share no state. In a multi-node service the same idea becomes optimistic concurrency on a version/turn number (see the HLD).
Why not lock-free? The critical section is microseconds and contention is at most a handful of players; a mutex is the simplest correct answer.
Question 4: Overshoot Rules¶
Interviewer: "What if a player at 97 rolls a 5?"
Three common rules, selected via OvershootPolicy:
| Policy | 97 + 5 | Note |
|---|---|---|
STAY |
97 | Needs an exact roll; the turn is still used |
BOUNCE |
98 (100, back 2) | The bounced cell can hold a snake — still resolve the jump |
ALLOW |
100, wins | Simplest |
Question 5: Board Validation and Generation¶
Interviewer: "How would you programmatically generate valid boards?"
Invariants first (the same ones Board enforces): endpoints on the board, no jump starting on the last cell, one jump start per cell, no jump ending on another's start (no chains → no cycles).
Generation: sample distinct start cells from 2..size-1, pick an end per jump, reject and resample on any invariant violation. Then score the board:
- Expected game length — exact, not simulated: the game is a Markov chain over cells
0..size, so expected turns-to-finish solves a linear system (or value iteration). Monte Carlo (10K games) is fine as a quick approximation. - Variance / fairness — length spread, and first-player advantage (simulate N games, compare win rate by seat).
- Reject boards outside the target range; difficulty = target length.
Question 6: Testing Strategy¶
Interviewer: "How do you test a game built on randomness?"
- Inject the randomness.
ScriptedDice([3, (2, 2), 1])turns every rule into a deterministic unit test (ladder, snake, bounce-onto-snake, third doubles, exact win). - Seeded RNG (
StandardDice(rng=random.Random(7))) for whole-game regression tests and bug reproduction. - Table tests for validation — each invalid board must raise
InvalidBoardError. - Invariant tests under concurrency — N threads hammer
play_turn(name); assert history is strictly round-robin, each turn starts where that player's last turn ended, and exactly one turn is a win. - Statistical tests only at the edges — e.g.
CrookedDicenever returns odd. Don't assert distributions in unit tests; they flake.
Question 7: Persistence and Replay¶
TurnResult is the event. Persist it append-only (moves table / event stream). Replay = feed the stored faces into ScriptedDice and re-run; positions must match. Store the board layout and rule config with the game, because the same rolls on a different layout give a different game.
Question 8: Extensions Interviewers Push On¶
| Ask | Where it lands |
|---|---|
| Play until all finish, rank players | Game: finish_order, skip finished players, end when one remains |
| Power-ups / special cells | Generalise Jump into a CellEffect ABC (apply(player, game)) |
| Undo last move | Pop history; positions are recoverable from start of the popped result |
| AI / bot player | A PlayerController that decides when to roll; for this game there's no decision, so it's just a timer calling play_turn |
| Disconnect timeout | Scheduler calls play_turn(current) on their behalf; the lock makes it race-safe with a late real request |
❌ Common Mistakes¶
roll()returningint, then detecting doubles withdice_value % 2 == 0orisinstance(dice, DoubleDice).- Recursively following jumps without guarding against cycles (snake 50→20 + ladder 20→50 = infinite recursion).
- Silently skipping invalid snakes/ladders (
except ValueError: pass) instead of failing the build. - Allowing a snake head on the final cell — the game becomes unwinnable.
- Overshoot that wraps (
pos - size) instead of staying or bouncing. - Demo or tests depending on real randomness, or mutating private fields (
game._dice = ...) to make them deterministic. - "Thread-safe" claims with the turn check outside the lock.
- One
Cellobject per square with a type enum and nullable fields; most of it is never used.
🎚️ Senior vs Staff Signal¶
- Senior: clean entities, dice as a strategy, correct turn/doubles/overshoot logic, board validation, deterministic tests via injected dice, a lock that covers check-and-move.
- Staff: states the invariants that make the loop simple (no chains → O(1), no cycles) and why; makes
TurnResultthe single event that drives logs, persistence and replay; sizes abstractions honestly (Enum vs ABC); moves the lock story to the service (versioned writes, idempotent roll requests, server-authoritative RNG); knows board fairness is a Markov-chain question, not just "simulate it".