Snake & Ladder

Advanced · 16 min read · ▶ live playground · ✦ checkpoint

“Design Snake & Ladder.” Rolling a die is one line of code. The design work is deciding who owns overshoot, jumps, turn order, and randomness so a lucky demo does not hide an untestable game loop.

We will model the familiar 10×10 board, make the ambiguous rules explicit, and drive a complete turn through the objects before writing the loop.

Requirements

Start with the rules the model must preserve, not the artwork printed around the board.

KindRequirement for this design
FunctionalThe board is N×N; the default size is 10×10, giving logical cells 1 through 100.
FunctionalThe board contains snakes from a higher cell to a lower cell and ladders from a lower cell to a higher cell.
FunctionalA game has at least two players, and every player has a stable identity, name, and current position.
FunctionalPlayers take turns in the order supplied to the game and roll one six-sided die per turn.
FunctionalA legal roll advances the current player, then applies a snake or ladder that begins on the landing cell.
FunctionalA player wins by reaching the final cell under the agreed overshoot rule.
FunctionalAfter a non-winning turn, the game advances to the next player and wraps to the first player.
FunctionalOnce a winner is recorded, the game rejects later turns.
Non-functionalRandomness must be seedable or replaceable so tests reproduce the same rolls.
Non-functionalThe game engine must not depend on a console, GUI, network session, or animation.

A graphical board, networking, persistence, betting, player accounts, bots, and tournament scoring are out of scope. The core returns turn data; another layer may animate it or print it.

Clarifying questions to ask

Small rule choices change observable behavior, so close them before assigning methods.

  • Must a player land exactly on the final cell? Yes. A roll that would pass the final cell leaves the player in place, still consumes the turn, and applies no jump.
  • How many dice are used? One standard six-sided die. Rolling a six does not grant another turn in this version; a multiple-dice or bonus-turn rule is an extension.
  • Where do players begin? At position 0, a staging position before cell 1. The first roll moves them onto the numbered board.
  • How many players are allowed? Any number greater than or equal to two. IDs must be unique, and the constructor order is the turn order.
  • Can two players share a cell? Yes. Cells do not own occupants, and landing on another player does not capture or move either token.
  • How are board cells numbered? The domain uses consecutive logical numbers from 1 to N². A GUI may draw alternating left-to-right and right-to-left rows without changing movement rules.
  • Can two jumps start on the same cell? No. A landing cell has at most one effect, so board construction rejects duplicate jump starts.
  • Do jumps chain? No. A turn applies at most one jump. If a ladder ends on another jump's start, that second jump waits; allowing chains later needs a loop and cycle detection.
  • May a snake or ladder begin on the first or final cell? No. Jump starts must be between cells 2 and N²−1. A jump may end on the final cell, which produces a win after the move resolves.
  • How do we distinguish a snake from a ladder? One Jump stores from and to. from > to means snake; from < to means ladder. They share every resolution rule, so separate subclasses add names but no behavior here.
  • How is the die made deterministic for tests? Dice receives a RandomSource. SeededRandom reproduces a pseudo-random stream, while a tiny scripted source can supply exact rolls for a scenario test.
  • When is victory checked? After applying the one optional jump. A ladder may take a player to the final cell, and a snake must move the player before the win check.
  • Can two clients request a turn together? Networking is excluded, so the skeleton serializes in-memory calls. A service version needs a game version or per-game command queue to preserve turn order.

Entity extraction

Mark the nouns in the clarified prompt before merging overlapping ideas:

  1. A game has a board, a turn order, a die, multiple players, a status, and an optional winner.
  2. A board has N² cells, a first cell, a final cell, and several snakes and ladders.
  3. A snake has a higher head and lower tail; a ladder has a lower bottom and higher top.
  4. A player has an identity, a name, and a position.
  5. A turn has a roll, an initial landing cell, an optional jump, a final position, and a possible win.

Sort those candidates by identity, behavior, and lifecycle:

BucketNounsModel choice
ControllergameGame owns status, current-player index, and the turn workflow
Board entitiesboard, cellBoard owns dimensions and jump lookup; Cell gives each logical square an identity
Unified board effectsnake, ladder, head, tail, bottom, topOne Jump(from, to); direction derives JumpKind
Domain entityplayerPlayer owns identity and position
Random mechanismdie, rollDice produces bounded rolls through a RandomSource contract
Valuesboard size, position, status, turn resultNumbers, enums, and a result interface rather than more entities
Actor / boundaryhuman player, game loop, UIInvokes Game.playTurn() but stays outside the core model
Excluded conceptsaccount, wager, network room, animationNo class until a requirement gives it behavior

Turn does not need mutable identity. playTurn() returns an immutable TurnResult containing the roll and movement facts. If replay or event sourcing arrives, that result can become the persisted move record without changing who owns the rules.

Now assign the verbs:

Verb phraseOwnerReason
choose the current playerGameIt owns the ordered player collection and index
roll within die boundsDiceDie sides and random-source validation belong together
apply exact-landing overshootBoard.landingFrom()The board owns its final cell and movement boundary
move a tokenPlayer.moveTo()Position is the player's state
find a snake or ladderBoard.jumpAt()The board owns the jump index keyed by landing cell
move through a jumpGame coordinates; Jump provides the destinationThe turn workflow composes player movement with board topology
detect a winGame compares final player position with Board.lastCellVictory changes the game's lifecycle
advance the turnGameOnly the game owns turn order and terminal status

The design keeps the controller focused. Game orders the steps, while Board, Dice, and Player protect the facts used by each step.

Core class diagram

The core uses eight types. Snake and Ladder do not appear as subclasses because their shared Jump representation already captures the only required difference.

class diagram

Core Snake & Ladder model

Core Snake & Ladder model
  1. 1Game owns lifecycle, current-player order, and the complete turn workflow.
  2. 2The game owns one Board, which defines the final cell and movement boundary.
  3. 3Game retains two or more Players and advances one ordered index after each turn.
  4. 4Game asks Dice for one bounded roll without knowing how randomness is produced.
  5. 5Board owns N squared logical Cells numbered independently from their visual layout.
  6. 6Board indexes one optional Jump at each landing cell.
  7. 7Dice delegates number generation through a replaceable RandomSource.
  8. 8SeededRandom reproduces a roll stream without changing Dice or Game.

Jump is a small domain abstraction, not a premature pattern. Snakes and ladders share validation, lookup, and application. Their direction is data: from > to produces Snake, while from < to produces Ladder.

The randomness seam is different. RandomSource is an engineering contract that keeps Dice.roll() honest in production and reproducible in tests. Game still owns when a roll is allowed.

Critical-flow sequence diagram

The critical flow is “take a turn.” This example lands on cell 12, climbs a ladder to 91, does not win, and passes control to the next player.

sequence diagram

Take one turn and apply a ladder

Take one turn and apply a ladder
  1. 1Game starts the current player's turn and receives one bounded die roll.
  2. 2Board applies exact-landing bounds before Player changes position.
  3. 3A Jump at the landing cell moves the same Player once more.
  4. 4Game checks victory, advances only a non-winning turn, and returns the full result.

The diagram follows the non-winning branch so turn advancement stays visible. If the final position were 100, message 10 would disappear: Game would record the winner and return a terminal result with no next player.

On an overshoot, Board.landingFrom() returns the player's current position. The game skips jump lookup because no landing occurred, then advances the turn under the same fairness rule.

TypeScript skeleton

The skeleton gives the board an indexed jump map, injects randomness into Dice, and implements a complete playTurn(). The driver uses a scripted random source for a three-turn game; SeededRandom is available when a repeatable pseudo-random stream is preferred.

export {};
 
enum JumpKind {
  Snake = "snake",
  Ladder = "ladder",
}
 
enum GameStatus {
  InProgress = "in-progress",
  Won = "won",
}
 
class Cell {
  public constructor(public readonly number: number) {
    if (!Number.isInteger(number) || number < 1) {
      throw new Error("cell number must be a positive integer");
    }
  }
}
 
class Jump {
  public constructor(
    public readonly from: number,
    public readonly to: number,
  ) {
    if (
      !Number.isInteger(from) ||
      !Number.isInteger(to) ||
      from < 1 ||
      to < 1 ||
      from === to
    ) {
      throw new Error("a jump needs distinct positive integer cells");
    }
  }
 
  public get kind(): JumpKind {
    return this.from > this.to
      ? JumpKind.Snake
      : JumpKind.Ladder;
  }
}
 
interface RandomSource {
  next(): number;
}
 
class SeededRandom implements RandomSource {
  private state: number;
 
  public constructor(seed: number) {
    if (!Number.isInteger(seed)) {
      throw new Error("seed must be an integer");
    }
    this.state = seed >>> 0;
  }
 
  public next(): number {
    this.state = (
      Math.imul(1_664_525, this.state) + 1_013_904_223
    ) >>> 0;
 
    return this.state / 0x1_0000_0000;
  }
}
 
class ScriptedRandom implements RandomSource {
  private index = 0;
 
  public constructor(
    private readonly rolls: readonly number[],
    private readonly sides: number,
  ) {
    if (rolls.length === 0) {
      throw new Error("at least one scripted roll is required");
    }
 
    if (
      rolls.some(
        (roll) =>
          !Number.isInteger(roll) || roll < 1 || roll > sides,
      )
    ) {
      throw new Error("a scripted roll is outside the die");
    }
  }
 
  public next(): number {
    const roll = this.rolls[this.index];
    if (roll === undefined) {
      throw new Error("scripted rolls are exhausted");
    }
 
    this.index += 1;
    return (roll - 0.5) / this.sides;
  }
}
 
class Dice {
  public constructor(
    private readonly sides = 6,
    private readonly random: RandomSource =
      new SeededRandom(Date.now()),
  ) {
    if (!Number.isInteger(sides) || sides < 2) {
      throw new Error("dice sides must be an integer of at least 2");
    }
  }
 
  public roll(): number {
    const sample = this.random.next();
 
    if (sample < 0 || sample >= 1 || !Number.isFinite(sample)) {
      throw new Error("random source must return a value in [0, 1)");
    }
 
    return Math.floor(sample * this.sides) + 1;
  }
}
 
class Board {
  public readonly size: number;
  public readonly lastCell: number;
  private readonly cells: readonly Cell[];
  private readonly jumps = new Map<number, Jump>();
 
  public constructor(
    size = 10,
    jumps: readonly Jump[] = [],
  ) {
    if (!Number.isInteger(size) || size < 2) {
      throw new Error("board size must be an integer of at least 2");
    }
 
    this.size = size;
    this.lastCell = size * size;
    this.cells = Array.from(
      { length: this.lastCell },
      (_, index) => new Cell(index + 1),
    );
 
    for (const jump of jumps) {
      if (jump.from <= 1 || jump.from >= this.lastCell) {
        throw new Error("a jump cannot start on the first or final cell");
      }
 
      if (jump.to > this.lastCell) {
        throw new Error("a jump ends outside the board");
      }
 
      if (this.jumps.has(jump.from)) {
        throw new Error(`duplicate jump from cell ${jump.from}`);
      }
 
      this.jumps.set(jump.from, jump);
    }
  }
 
  public landingFrom(position: number, roll: number): number {
    if (
      !Number.isInteger(position) ||
      position < 0 ||
      position > this.lastCell
    ) {
      throw new Error("player position is outside the board");
    }
 
    if (!Number.isInteger(roll) || roll < 1) {
      throw new Error("roll must be a positive integer");
    }
 
    const candidate = position + roll;
    return candidate > this.lastCell ? position : candidate;
  }
 
  public jumpAt(cellNumber: number): Jump | null {
    this.cellAt(cellNumber);
    return this.jumps.get(cellNumber) ?? null;
  }
 
  private cellAt(cellNumber: number): Cell {
    const cell = this.cells[cellNumber - 1];
    if (cell === undefined) {
      throw new Error(`cell ${cellNumber} is outside the board`);
    }
    return cell;
  }
}
 
class Player {
  private currentPosition = 0;
 
  public constructor(
    public readonly id: string,
    public readonly name: string,
  ) {
    if (id.trim().length === 0 || name.trim().length === 0) {
      throw new Error("player id and name are required");
    }
  }
 
  public get position(): number {
    return this.currentPosition;
  }
 
  public moveTo(position: number): void {
    if (!Number.isInteger(position) || position < 1) {
      throw new Error("a board position must be a positive integer");
    }
    this.currentPosition = position;
  }
}
 
interface TurnResult {
  readonly player: string;
  readonly start: number;
  readonly roll: number;
  readonly landedOn: number;
  readonly jump: Jump | null;
  readonly finalPosition: number;
  readonly won: boolean;
  readonly nextPlayer: string | null;
}
 
class Game {
  private readonly players: readonly Player[];
  private currentIndex = 0;
  private status = GameStatus.InProgress;
  private winningPlayer: Player | null = null;
 
  public constructor(
    private readonly board: Board,
    players: readonly Player[],
    private readonly dice: Dice,
  ) {
    if (players.length < 2) {
      throw new Error("a game needs at least two players");
    }
 
    const ids = new Set(players.map((player) => player.id));
    if (ids.size !== players.length) {
      throw new Error("player ids must be unique");
    }
 
    this.players = [...players];
  }
 
  public get currentPlayer(): Player {
    return this.players[this.currentIndex];
  }
 
  public get winner(): Player | null {
    return this.winningPlayer;
  }
 
  public playTurn(): TurnResult {
    if (this.status === GameStatus.Won) {
      throw new Error("game is already over");
    }
 
    const player = this.currentPlayer;
    const start = player.position;
    const roll = this.dice.roll();
    const landedOn = this.board.landingFrom(start, roll);
    let jump: Jump | null = null;
 
    if (landedOn !== start) {
      player.moveTo(landedOn);
      jump = this.board.jumpAt(landedOn);
 
      if (jump !== null) {
        player.moveTo(jump.to);
      }
    }
 
    const won = player.position === this.board.lastCell;
 
    if (won) {
      this.status = GameStatus.Won;
      this.winningPlayer = player;
    } else {
      this.currentIndex =
        (this.currentIndex + 1) % this.players.length;
    }
 
    return {
      player: player.name,
      start,
      roll,
      landedOn,
      jump,
      finalPosition: player.position,
      won,
      nextPlayer: won ? null : this.currentPlayer.name,
    };
  }
}
 
function describeTurn(result: TurnResult): string {
  const jumpText = result.jump === null
    ? ""
    : ` (${result.jump.kind} ${result.jump.from} -> ${result.jump.to})`;
 
  return (
    `${result.player} rolled ${result.roll}: ` +
    `${result.start} -> ${result.finalPosition}${jumpText}`
  );
}
 
const board = new Board(4, [
  new Jump(2, 14),
  new Jump(8, 3),
]);
 
const dice = new Dice(
  6,
  new ScriptedRandom([2, 3, 2], 6),
);
 
const game = new Game(
  board,
  [new Player("p1", "Ada"), new Player("p2", "Lin")],
  dice,
);
 
let result: TurnResult;
do {
  result = game.playTurn();
  console.log(describeTurn(result));
} while (!result.won);
 
console.log(`winner: ${game.winner?.name}`);

Ada rolled 2: 0 -> 14 (ladder 2 -> 14) Lin rolled 3: 0 -> 3 Ada rolled 2: 14 -> 16 winner: Ada

The 4×4 driver keeps the transcript short while exercising the same N×N code. Ada's first roll lands on 2 and applies the ladder to 14. Lin receives the next turn, then Ada lands exactly on cell 16 and wins; Game does not advance the current-player index after that terminal move.

ScriptedRandom is test support, not another domain entity in the core diagram. Replacing it with new SeededRandom(42) gives a longer but repeatable game without changing Dice, Board, or Game.

Design-review checklist

Expect the interviewer to change one rule at a time and watch which class moves.

  • Overshoot policy: The skeleton requires exact landing and consumes the turn on overshoot. Board.landingFrom() owns that boundary because it knows lastCell; an “any roll past the end wins” variant changes this method or an injected movement policy, not Player.
  • Deterministic tests: A seeded generator makes failures reproducible, while a scripted source makes one scenario precise. Mocking Game.playTurn() would skip the workflow under test; injecting below Dice.roll() preserves it.
  • One Jump abstraction: Snake and ladder resolution is identical: look up by from, move to to, and derive the label from direction. Subclasses become useful only if the two effects gain different rules or data.
  • Board validation: Reject out-of-range endpoints, duplicate starts, and starts on cell 1 or the final cell during construction. Invalid topology should not become a surprise in the middle of a turn.
  • Jump chaining: This design applies one jump. If chaining is requested, repeatedly resolve the destination and track visited starts so a malformed cycle such as 12→91→12 cannot loop forever.
  • Shared cells: Player positions are independent; Cell has no occupant field. That directly supports two players on one cell and avoids a board-token consistency problem the rules do not require.
  • Turn fairness: Constructor order defines a stable round-robin sequence. Every non-winning call consumes one turn, including overshoot and snake movement; there is no hidden extra-roll rule for six.
  • Multiple dice: Introduce a roll policy or a DiceSet that returns a total and any doubles metadata. Do not teach Board how physical dice combine, and clarify whether multiple dice alter overshoot or bonus-turn rules.
  • Power-ups and new landing effects: If effects gain behavior beyond one destination, replace Jump with a LandingEffect contract implemented by jump, skip-turn, or boost objects. That extension is not justified while every effect is only from → to.
  • Separating the game loop from I/O: Game.playTurn() returns TurnResult; it never asks for a name, sleeps for animation, or prints. A console, web socket, or GUI may decide when to call and how to present the same result.
  • Complexity: Jump lookup is O(1) through a map, and a turn does constant work. The N² Cell objects make board identity explicit; a very large sparse board could validate numeric ranges without allocating every cell.
  • Concurrent callers: A hosted game should accept a command only when its stored version matches, or process one queue per game. A process-local current index cannot protect turn order across servers.
  • Testing the rule matrix: Cover a normal move, ladder, snake, exact win, overshoot, a jump to the final cell, shared positions, three-player wraparound, post-win rejection, duplicate jump starts, invalid endpoints, seeded repeatability, and exhausted scripted rolls.
  • With more time: Add immutable turn history, persistence with optimistic locking, configurable movement and bonus-turn policies, typed domain errors, and an adapter that translates logical cells into a serpentine display.

Checkpoint

Answer all three to mark this lesson complete

The final model stays close to the rules: Game orders the turn, Board owns movement boundaries and jumps, Player owns position, and Dice isolates randomness. That is the interview habit behind both case studies—generalize the pressure the prompt names, and leave the rest concrete.

+50 XP on completion