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.
| Kind | Requirement for this design |
|---|---|
| Functional | The board is N×N; the default size is 10×10, giving logical cells 1 through 100. |
| Functional | The board contains snakes from a higher cell to a lower cell and ladders from a lower cell to a higher cell. |
| Functional | A game has at least two players, and every player has a stable identity, name, and current position. |
| Functional | Players take turns in the order supplied to the game and roll one six-sided die per turn. |
| Functional | A legal roll advances the current player, then applies a snake or ladder that begins on the landing cell. |
| Functional | A player wins by reaching the final cell under the agreed overshoot rule. |
| Functional | After a non-winning turn, the game advances to the next player and wraps to the first player. |
| Functional | Once a winner is recorded, the game rejects later turns. |
| Non-functional | Randomness must be seedable or replaceable so tests reproduce the same rolls. |
| Non-functional | The 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
Jumpstoresfromandto.from > tomeans snake;from < tomeans ladder. They share every resolution rule, so separate subclasses add names but no behavior here. - How is the die made deterministic for tests?
Dicereceives aRandomSource.SeededRandomreproduces 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:
- A game has a board, a turn order, a die, multiple players, a status, and an optional winner.
- A board has N² cells, a first cell, a final cell, and several snakes and ladders.
- A snake has a higher head and lower tail; a ladder has a lower bottom and higher top.
- A player has an identity, a name, and a position.
- 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:
| Bucket | Nouns | Model choice |
|---|---|---|
| Controller | game | Game owns status, current-player index, and the turn workflow |
| Board entities | board, cell | Board owns dimensions and jump lookup; Cell gives each logical square an identity |
| Unified board effect | snake, ladder, head, tail, bottom, top | One Jump(from, to); direction derives JumpKind |
| Domain entity | player | Player owns identity and position |
| Random mechanism | die, roll | Dice produces bounded rolls through a RandomSource contract |
| Values | board size, position, status, turn result | Numbers, enums, and a result interface rather than more entities |
| Actor / boundary | human player, game loop, UI | Invokes Game.playTurn() but stays outside the core model |
| Excluded concepts | account, wager, network room, animation | No 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 phrase | Owner | Reason |
|---|---|---|
| choose the current player | Game | It owns the ordered player collection and index |
| roll within die bounds | Dice | Die sides and random-source validation belong together |
| apply exact-landing overshoot | Board.landingFrom() | The board owns its final cell and movement boundary |
| move a token | Player.moveTo() | Position is the player's state |
| find a snake or ladder | Board.jumpAt() | The board owns the jump index keyed by landing cell |
| move through a jump | Game coordinates; Jump provides the destination | The turn workflow composes player movement with board topology |
| detect a win | Game compares final player position with Board.lastCell | Victory changes the game's lifecycle |
| advance the turn | Game | Only 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
- 1Game owns lifecycle, current-player order, and the complete turn workflow.
- 2The game owns one Board, which defines the final cell and movement boundary.
- 3Game retains two or more Players and advances one ordered index after each turn.
- 4Game asks Dice for one bounded roll without knowing how randomness is produced.
- 5Board owns N squared logical Cells numbered independently from their visual layout.
- 6Board indexes one optional Jump at each landing cell.
- 7Dice delegates number generation through a replaceable RandomSource.
- 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
- 1Game starts the current player's turn and receives one bounded die roll.
- 2Board applies exact-landing bounds before Player changes position.
- 3A Jump at the landing cell moves the same Player once more.
- 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 knowslastCell; an “any roll past the end wins” variant changes this method or an injected movement policy, notPlayer. - 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 belowDice.roll()preserves it. - One
Jumpabstraction: Snake and ladder resolution is identical: look up byfrom, move toto, 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;
Cellhas 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
DiceSetthat returns a total and any doubles metadata. Do not teachBoardhow 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
Jumpwith aLandingEffectcontract implemented by jump, skip-turn, or boost objects. That extension is not justified while every effect is onlyfrom → to. - Separating the game loop from I/O:
Game.playTurn()returnsTurnResult; 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²Cellobjects 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.