Why Snake and Ladder is a real interview problem
On the surface, Snake and Ladder looks trivial: roll a die, move a token, slide down snakes, climb up ladders, first to 100 wins. That simplicity is exactly why it shows up in machine-coding rounds — there is nothing to hide behind. The interviewer is not testing whether you can code the rules; they are watching whether you model the domain cleanly, name responsibilities precisely, and leave seams for extension. A weak candidate writes a 40-line main() with a 2D array and a giant while loop. A strong SDE-1 produces a handful of small classes where each one has a single reason to change, then casually says “and if you wanted weighted dice or a 15×15 board, here’s the one place that changes.”
This lesson walks the full problem the way you should walk it live: clarify, model, draw, implement a skeleton, attack edge cases, name the patterns, and pre-answer the extension questions.
The grading rubric for problems like this is almost never “does it run.” It is: clean entity boundaries, correct handling of edge cases (overshoot, chained jumps, snake on final cell, cycles), and the ability to extend without rewriting. Optimize your 45 minutes for those three things.
Step 1 — Clarify requirements (don’t skip this)
Spend the first 3-4 minutes here. Saying these out loud is worth marks even before you write code. Split into functional, the few that actually shape the design, and explicitly out of scope.
Functional requirements
- A board of
Ncells (classically 100, laid out 1..100). - Multiple players (≥ 2), each with a token starting off-board (position 0) or at cell 1 — ask which.
- Players take turns in a fixed order.
- On a turn, the player rolls one die (1..6), advances by that amount.
- Snakes move a token down (head → tail); ladders move it up (bottom → top).
- First player to land exactly on the final cell wins.
The clarifying questions that change the design (ask these explicitly):
- Overshoot rule: if a player at 98 rolls a 5, do they (a) stay put, (b) bounce back, or (c) move only if exact? This affects the win/move logic. Default and most common: you must land exactly; an overshooting roll is forfeited (stay put).
- Chained jumps: can a ladder’s top be a snake’s head (so you climb then immediately slide)? Decide whether jumps resolve once or repeatedly until stable. I default to repeat until stable and reject cyclic configs at construction.
- Number of dice / die faces: one die 1..6, or configurable? Model
Diceas injectable from the start — cheap insurance. - Board size and jump positions: fixed 100, or constructor-injected? Always inject.
- Roll-a-six bonus turn? Common variant — ask, then keep it behind a flag/strategy, don’t bake it in.
Out of scope (state these to bound the problem): persistence, networking/multiplayer over a wire, UI/rendering, concurrency between human players. Mention thread-safety only if asked.
Interview tip: write the requirements as a short bulleted list on the shared doc. When you later make a decision (“overshoot forfeits the turn”), point back to it. This signals you’re designing to a spec, not improvising.
Step 2 — Identify the core entities
Map nouns to classes, verbs to methods. The clean decomposition:
| Entity | Responsibility | Notable: what it does NOT do |
|---|---|---|
Cell | Holds its position and an optional Jump anchored at it | Doesn’t know about players |
Jump | A relocation from start to end (snake if end < start, ladder if end > start) | Doesn’t know “snake vs ladder” as separate types |
Board | Owns cells, validates config, exposes getNextPosition | Doesn’t roll dice or manage turns |
Dice | Produces a roll (sum of n faces) from an injected RNG | Doesn’t know board or players |
Player | Identity + current position | Doesn’t move itself; the game moves it |
Game | Orchestrates turn order, win condition, the loop | Doesn’t compute jump targets itself |
The single most important modeling decision: a snake and a ladder are the same thing — a Jump. Both are “if you land on cell X, relocate to cell Y.” Creating separate Snake and Ladder classes is a classic over-modeling trap; the only difference is the sign of end - start. Unifying them removes duplicate logic and makes “custom jump rules” trivial later. We keep a single helper, isSnake(), used only for reporting (logging which kind of jump fired) — the move logic never branches on it.
The two most common modeling mistakes here: (1) separate Snake and Ladder classes that share 100% of their behavior, and (2) putting the dice roll, jump resolution, and turn loop all inside Game as private methods on a 2D array. Both fail the “single reason to change” test. Keep jump resolution on Board, turn orchestration on Game.
Step 3 — ASCII UML class diagram
+-------------------+
| Game |
+-------------------+
| - board: Board |
| - dice: Dice |
| - players: Queue |
| - winner: Player? |
+-------------------+
| + play(): Player |
| - takeTurn(p) |
+-------------------+
| | \
| uses | uses \ moves
v v v
+-----------+ +--------+ +-----------+
| Board | | Dice | | Player |
+-----------+ +--------+ +-----------+
| - size | | -count | | - id |
| - cells[] | | -faces | | - name |
+-----------+ | -rng | | - position|
|+getNext() | +--------+ +-----------+
|-validate()| | +roll()|
+-----------+ +--------+
| owns *
v
+-----------+ anchored 0..1
| Cell |-------------------+
+-----------+ v
| - position| +-----------+
| - jump? | | Jump |
+-----------+ +-----------+
| - start |
| - end |
+-----------+
| +isSnake()| (reporting only)
+-----------+
Relationships in words: Game has-a Board, Dice, and an ordered collection of Player. Board owns Cells; each Cell may have one Jump. Dice has-a injected RNG. Game mutates Player.position based on Dice.roll() and Board.getNextPosition().
Step 4 — Key method signatures and the game loop
Before writing classes, pin down the contract that matters most — Board.getNextPosition. Given where a die would land you, it returns the final resting cell after resolving jumps:
getNextPosition(landingPosition: int) -> int- Precondition (contract):
1 <= landingPosition <= size. The caller guarantees this by checking overshoot before calling, sogetNextPositionpurely resolves jumps and never has to invent a “forfeit” sentinel. - Resolve jumps iteratively until the cell has no jump. Because the constructor has already rejected cyclic configs, this loop is guaranteed to terminate, and the returned cell is always a non-jumping resting cell.
- Precondition (contract):
getNextPosition indexes cells[pos] with no internal bound check. That is a deliberate, documented precondition: the caller must pass 1..size. In C++ an out-of-range index is undefined behavior, so in real code add assert(landing >= 1 && landing <= size_) at the top. In an interview, say the precondition out loud — implicit contracts are exactly what graders probe.
The turn loop in Game.play():
while no winner:
player = players.dequeue()
roll = dice.roll()
target = player.position + roll
if target <= board.size: # guard the precondition
target = board.getNextPosition(target) # resolve snakes/ladders
player.position = target
if player.position == board.size:
winner = player
break
# else: overshoot -> forfeit, position unchanged
players.enqueue(player) # back of the queue
return winner
Using a queue for players makes round-robin turn order fall out naturally: dequeue at the front, enqueue at the back.
Step 5 — Tri-language reference skeleton
The core domain — Jump, Cell, Board, Dice, Player — kept deliberately small and idiomatic. Note the four construction-time validations on Board (bounds, duplicate starts, winning cell, cycles), and that Dice takes an injected RNG so tests can be deterministic.
TypeScript note: the skeleton uses Set and arrow callbacks, so it requires "target": "es2015" (or later) and compiles cleanly under --strict with the typed callback params shown. State this required tsconfig level when you present it; on a default/ES5 config Set won’t resolve.
// Requires tsconfig: "target": "es2015" (or later); compiles under --strict.
// Injectable RNG: returns an int in [1, faces] inclusive.
type Rng = (faces: number) => number;
const defaultRng: Rng = (faces) => 1 + Math.floor(Math.random() * faces);
// ---- Jump: a snake (end<start) or ladder (end>start) ----
class Jump {
constructor(readonly start: number, readonly end: number) {}
isSnake(): boolean { return this.end < this.start; } // reporting only
}
class Cell {
jump?: Jump;
constructor(readonly position: number) {}
}
class Board {
readonly size: number;
private cells: Cell[];
constructor(size: number, jumps: Jump[]) {
this.size = size;
this.cells = [];
for (let i = 0; i <= size; i++) this.cells.push(new Cell(i));
const starts = new Set<number>();
for (const j of jumps) {
// (a) bounds
if (j.start < 1 || j.start > size || j.end < 1 || j.end > size)
throw new Error("jump out of bounds");
if (j.start === j.end) throw new Error("jump start == end");
// (b) winning cell must not be a jump start (else unwinnable)
if (j.start === size) throw new Error("jump anchored on winning cell");
// (c) no duplicate starts (else silent overwrite)
if (starts.has(j.start)) throw new Error("duplicate jump start");
starts.add(j.start);
this.cells[j.start].jump = j;
}
this.assertNoCycles(); // (d)
}
// Walk every jump chain; a chain that revisits a cell is a cycle.
private assertNoCycles(): void {
for (const cell of this.cells) {
if (!cell.jump) continue;
const seen = new Set<number>();
let pos = cell.position;
while (this.cells[pos].jump) {
if (seen.has(pos)) throw new Error("cyclic jump configuration");
seen.add(pos);
pos = this.cells[pos].jump!.end;
}
}
}
// Precondition: 1 <= landing <= size. Terminates: config is acyclic.
getNextPosition(landing: number): number {
let pos = landing;
while (this.cells[pos].jump) pos = this.cells[pos].jump!.end;
return pos;
}
}
class Dice {
constructor(
private count = 1,
private faces = 6,
private rng: Rng = defaultRng, // inject for deterministic tests
) {}
roll(): number {
let sum = 0;
for (let i = 0; i < this.count; i++) sum += this.rng(this.faces);
return sum;
}
}
class Player {
position = 0;
constructor(readonly id: string, readonly name: string) {}
}Now the orchestrator. Note that Game contains no jump logic and no board internals — it only sequences turns and checks the win condition. It also guards the getNextPosition precondition with the target > size overshoot check.
class Game {
private winner?: Player;
constructor(
private board: Board,
private dice: Dice,
private players: Player[], // turn order
) {
if (players.length < 2) throw new Error("need >= 2 players");
}
play(): Player {
const queue = [...this.players];
while (!this.winner) {
const player = queue.shift()!;
this.takeTurn(player);
if (this.winner) break;
queue.push(player);
}
return this.winner!;
}
private takeTurn(player: Player): void {
const roll = this.dice.roll();
const target = player.position + roll;
if (target > this.board.size) return; // overshoot: forfeit
const before = target;
const after = this.board.getNextPosition(target);
if (after !== before) {
const kind = after < before ? "snake" : "ladder"; // uses isSnake-style reporting
console.log(`${player.name} hit a ${kind}: ${before} -> ${after}`);
}
player.position = after;
if (player.position === this.board.size) this.winner = player;
}
}Inject Dice’s RNG through the constructor. In an interview, say: “Dice takes an rng function, so in tests I pass () => 4 for a deterministic sequence and the real PRNG in production.” Because the RNG is a constructor parameter (not Math.random() hard-wired inside roll), this testability claim is backed by the actual code — no monkeypatching or subclassing required.
Step 6 — Edge cases (this is where marks are won)
List these proactively; the interviewer rarely volunteers them.
- Overshoot near the end. Player at 98, rolls 5 → 103 > 100. With the exact-landing rule, forfeit the turn. Verify your loop does not mutate position. This is the #1 case candidates forget, and it is also why the
target <= sizecheck intakeTurndoubles as the guard forgetNextPosition’s precondition. - Snake on the final cell. If cell 100 were a jump
start, you could never win. TheBoardconstructor rejects any jump anchored onsize, so this is impossible by construction rather than a runtime surprise. - Chained jumps. Ladder top = snake head. The
getNextPositionloop resolves repeatedly until it reaches a non-jumping cell. - Jump cycles. A → B and B → A would infinite-loop. We don’t paper over this with a runtime “stop when I see a repeat” hack — a mid-cycle stop would leave the token on an arbitrary, unexplained resting cell. Instead the constructor’s
assertNoCycleswalks every chain and rejects the config up front. ConsequentlygetNextPositionneeds no cycle guard at all: an acyclic, finite board guarantees the loop terminates on a non-jumping cell. - Two jumps starting at the same cell. Ambiguous — the constructor tracks a
startsset and rejects duplicates instead of silently overwriting the earlier jump withcells[start].jump = j. - Degenerate jump (
start == end). Rejected too; it would otherwise be a no-op jump that still trips the “is this a resting cell?” check. - Liveness / stalling near the end. With exact-landing forfeit and a single d6, a player on 99 needs exactly a 1 (probability 1/6 per turn); a player on 95 can also overshoot. The game still terminates almost surely (the expected number of extra turns is finite), but it can stall for several rounds. If asked, note that multiple dice or a “move to nearest possible” rule changes the expected-turns profile — an SSE-grade observation about liveness, not just correctness.
Notice the design choice: every structural invariant (bounds, no duplicate starts, winning cell free, acyclic) is enforced once, at construction, so the hot path (getNextPosition, called every turn) stays a tight, branch-free loop. “Validate at the boundary, trust internally” is the principle graders want to hear you articulate.
Step 7 — Design patterns that naturally emerge
Don’t force patterns; name the ones that genuinely fall out:
- Dependency Injection.
Board,Dice, andplayersare injected intoGame; the RNG is injected intoDice. This is what makes the whole thing testable and configurable. It is the single highest-value pattern here. - Strategy (latent, ready to surface). The overshoot behavior (forfeit vs bounce-back vs nearest-valid) and the dice (single d6 vs weighted vs multi-die) are both natural Strategy seams. We’ve kept dice already pluggable via the injected RNG; overshoot is currently a hard-coded rule but is one method away from a
MoveStrategyinterface. - Factory (optional). A
BoardFactory.standard()that returns the classic 100-cell board with the canonical snake/ladder positions keeps test setup andmainclean. Worth mentioning, not worth coding under time pressure. - Iterator / Queue for turn order. Using a FIFO queue is a deliberate structural choice that encodes round-robin scheduling without an index variable and modulo arithmetic.
In an interview, say: “The patterns I’d name are DI everywhere and Strategy for dice and overshoot; I’d resist adding more — Observer/State here would be speculative gold-plating for a single-process game.”
Step 8 — Extensibility (“what if they ask for X?”)
Pre-answer the common follow-ups and point at the single place each one changes:
- Multiple dice / weighted dice. Already handled:
new Dice(2, 6)rolls 2d6; a weighted die is just a different injectedrng. No other class changes. - Variable board size / custom jumps.
Board(size, jumps)is fully parameterized and validated. A 15×15 board isnew Board(225, [...]). The constructor’s invariants still hold. - Roll-a-six grants another turn. Don’t re-enqueue immediately when
roll === faces; instead let the same player go again (or push to the front). This is a change inGame.takeTurn/playonly —Board/Diceuntouched. Watch the infinite-turn guard (cap consecutive sixes). - Bounce-back overshoot. Swap the overshoot branch for “reflect off
size”:target = size - (target - size), then resolve jumps. This is exactly theMoveStrategyseam — extract overshoot handling into a strategy and the variant is one new class. - Multiplayer over a network / concurrency. Out of scope for the core model, but note:
Player.positionis the only mutable shared state; in a concurrent variant you’d serialize turns through the queue (the game is inherently turn-sequential, so a single lock or an actor aroundGamesuffices).
The closing move that earns the offer: after the skeleton runs, say “the design has exactly three extension seams — the injected RNG, an overshoot Strategy, and the validated jump list — and everything else is closed for modification.” Naming the seams explicitly is what separates SDE-1 from SSE-level answers.