Back to the path
Level 6 expert 26 min #lld#machine-coding#snake-and-ladder#design-patterns#oop#interview

Designing Snake and Ladder: A Full SSE Machine-Coding Walkthrough

A complete, interview-grade LLD solution for Snake and Ladder — from clarifying requirements and modeling Board, Jump, Dice, Player, and Game, through an ASCII UML diagram and tri-language reference skeleton with construction-time validation (bounds, duplicate starts, winning-cell, cycles), to edge cases, the patterns that naturally emerge, and how to extend for multiple dice, variable boards, and custom jump rules.

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.

Key idea

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 N cells (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):

  1. 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).
  2. 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.
  3. Number of dice / die faces: one die 1..6, or configurable? Model Dice as injectable from the start — cheap insurance.
  4. Board size and jump positions: fixed 100, or constructor-injected? Always inject.
  5. 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:

EntityResponsibilityNotable: what it does NOT do
CellHolds its position and an optional Jump anchored at itDoesn’t know about players
JumpA relocation from start to end (snake if end < start, ladder if end > start)Doesn’t know “snake vs ladder” as separate types
BoardOwns cells, validates config, exposes getNextPositionDoesn’t roll dice or manage turns
DiceProduces a roll (sum of n faces) from an injected RNGDoesn’t know board or players
PlayerIdentity + current positionDoesn’t move itself; the game moves it
GameOrchestrates turn order, win condition, the loopDoesn’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.

Common pitfall

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, so getNextPosition purely 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.
Watch out

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.

Tip

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;
}
}
Tip

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 <= size check in takeTurn doubles as the guard for getNextPosition’s precondition.
  • Snake on the final cell. If cell 100 were a jump start, you could never win. The Board constructor rejects any jump anchored on size, so this is impossible by construction rather than a runtime surprise.
  • Chained jumps. Ladder top = snake head. The getNextPosition loop 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 assertNoCycles walks every chain and rejects the config up front. Consequently getNextPosition needs 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 starts set and rejects duplicates instead of silently overwriting the earlier jump with cells[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.
Key idea

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, and players are injected into Game; the RNG is injected into Dice. 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 MoveStrategy interface.
  • Factory (optional). A BoardFactory.standard() that returns the classic 100-cell board with the canonical snake/ladder positions keeps test setup and main clean. 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 injected rng. No other class changes.
  • Variable board size / custom jumps. Board(size, jumps) is fully parameterized and validated. A 15×15 board is new 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 in Game.takeTurn/play only — Board/Dice untouched. 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 the MoveStrategy seam — 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.position is 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 around Game suffices).
Tip

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.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. Why does the corrected `Board` reject cyclic jump configurations in the constructor rather than guarding against them inside `getNextPosition` with a `seen` set?

2. Which validations does the corrected `Board` constructor perform? (select all that apply) (select all)

3. Why is `getNextPosition` written with NO internal bounds check, and what makes that safe?

4. How does the corrected `Dice` make the 'pass a deterministic stub in tests' claim true in code rather than just in prose?

Design problem 5

Extend the design to support a 'roll-again on rolling the maximum face' rule (rolling a 6 on a d6 grants the same player another immediate turn), while keeping the rest of the design intact. Describe which class(es) change, the exact loop modification, and the safeguard you must add. Sketch the modified turn logic.