Design Snake and Ladder
Design a Snake and Ladder LLD: a 100-square board, jumps, dice, and a turn state machine that does not invent a win or chain two teleports.
Page content
Alice is on square 96. She rolls a 4. There is a snake head on 98. Square 100 wins.
If you wrap 100 % 100, she teleports to 0 instead of winning. If you bounce a larger roll (96 + 6) off 100, she stops on 98 — the snake — and “almost won” becomes a slide to 28. If you treat any total at or above 100 as a win, she wins on a 5 she never legally spent.
Bob is on 10 and rolls 4. Square 14 is a ladder to 67. If 67 is also a snake head and you keep applying jumps, you designed a pinball table. Most interviewers want a board game, not a cascade.
This is an LLD interview: objects, one move function, and a turn machine. It is not a multiplayer game backend. If they later say “10,000 concurrent games,” you mint 10,000 Game instances. Each instance is this design. Same habit as Design a Parking Lot: one object graph, many callers.
The main design question is:
How do we model the board as positions plus jumps, take turns, and decide a winner without ambiguous landing rules?
We will start with if (square == 98) goTo(28) and watch the next snake force an edit. A jump map, an exact-100 rule, and a one-jump apply appear when that if is no longer enough.
1. Clarify the problem
“Design Snake and Ladder” can mean a weekend kata or Ludo with extra pieces. The questions that change the objects:
- Board size? Classic is 100.
- Start on 0 (off the board) or on 1?
- How many players? 2–4 is the usual table.
- Exact 100, bounce, or wrap? Chain jumps after a ladder?
- Extra turn on a 6? One token, or “crooks” (two pieces)?
- Is this a rules engine, or a server many phones talk to?
If the interviewer gives no extra constraints, I would state:
Board Squares 1–100; tokens start at 0 (off the board)
Players 2–4, one token each, turn order fixed at create
Win Exact 100; overshoot stays put (deny the move)
Jumps At most one per roll; snakes and ladders are the same map
Dice One six-sided; extra turn on 6 (stated product choice)
Same square Allowed; this is not Ludo
Crooks / cut Out of scope until they ask
Network / lobby Out of scope; one Game object, one thread of turns
Matchmaking, chat, replays, and animations sit on the same Game. They do not change applyRoll. Say so and move on.
2. Functional requirements
The system must:
- Create a game with a board and two to four named players.
- Let only the current player roll.
- Move that player’s token by the roll, then apply at most one jump.
- Reject a roll that would pass 100; the token stays.
- Mark a winner on exact 100 and refuse further rolls.
- Rotate turns; optionally grant an extra roll on 6.
- Forfeit a disconnected player without corrupting the others.
Not in v1: a lobby, ranked matchmaking, persisted replay of a million games, two tokens, “cut” on landing. Those reuse Board, Dice, and applyRoll. They do not rewrite the landing rule.
3. Non-functional requirements
This is not a 50k QPS problem. Interviewers want unambiguous landing and a testable dice, not a cluster.
| Requirement | Target |
|---|---|
| Landing | Same (position, roll, board) always yields the same TurnResult |
| Win | Only position == 100 after a legal apply |
| Turns | One roll in flight per game; illegal roll is a rejected call |
| Test | Dice is an interface; tests inject a sequence |
| Clock | Turns are event-sourced by rolls, not by wall time |
One WAITING_ROLL → apply → NEXT_PLAYER | WON transition wins. Durability is optional until they ask to serialize the Game. Ten thousand concurrent tables are ten thousand heaps, not a log.
Edge cases
- Alice on 96 rolls 4 (win) and rolls 5 (stay). Wrap or bounce would hit the snake on 98.
- Bob lands on 14 (ladder to 67); 67 is also a snake — we do not chain.
- Two tokens on 40; roll 6 extra turn; three 6s hogging;
roll()afterWON. - Disconnect on your turn; two jumps from 14; a snake from 100; a die of 0 or 7.
4. Scale
Say the numbers so nobody spends the interview on QPS.
Squares / board 100
Players / game 2–4
Jumps / board a few dozen
Turns / game tens to a few hundred
Concurrent games 10,000 = 10,000 Game instances
State / game jump map + a handful of ints
A scan of 100 squares to “find the snake” is a toy: the snake is data, not a search. If they push “10,000 tables,” do not invent Kafka. Route by game_id. Same move as garages in the parking lot LLD: many instances, one object model.
Process
├── Game g_alice ← this design
├── Game g_bob ← this design
└── Game g_cara ← this design
5. Entities / what we store
Six names, and one map that does the interesting work.
Square just an int 0..100. Do not grow a class until it holds art.
Snake head, tail (config; head > tail)
Ladder bottom, top (config; top > bottom)
Jump from → to (runtime; snakes and ladders collapse here)
Dice roll() → 1..6
Player id, name, position, status ACTIVE | FORFEITED
Board lastSquare, jumps: Map<from, to>
Game board, players[], currentIndex, status, extraSixesThisTurn
TurnResult roller, die, from, landed, dest, jumped, won, nextPlayer
A jump is the source of truth for “what happens if you stop on 14.” A snake and a ladder are constructors that insert into that map with opposite direction checks. After Board is built, the game loop does not know the words snake or ladder.
Board jumps
14 → 67 // ladder
98 → 28 // snake
62 → 19 // snake
Alice 96 + 4 → landed 100 → dest 100 → WON
Bob 10 + 4 → landed 14 → dest 67 → still playing
player_id authorizes roll; the name is a label. Last square is board.last, not a literal in Game. TurnResult is a return value. If the UI and Player.position disagree, the player wins.
6. APIs or class methods
In LLD you may show a service, not HTTP. Either is fine. I would write methods.
createGame(names, boardConfig) -> Game
roll(gameId, playerId) -> TurnResult
forfeit(gameId, playerId) -> Game
snapshot(gameId) -> GameView
Create and a legal extra-6 roll:
createGame { "names": ["Alice", "Bob"], "board": "classic_100" }
{ "game_id": "g_9f3a", "status": "IN_PROGRESS",
"current_player": "p_alice", "positions": { "p_alice": 0, "p_bob": 0 } }
roll { "game_id": "g_9f3a", "player_id": "p_alice" }
{ "die": 6, "from": 0, "landed": 6, "dest": 6, "jumped": false,
"won": false, "extra_turn": true, "next_player": "p_alice" }
Overshoot denied (96 + 5):
{ "die": 5, "from": 96, "landed": 96, "dest": 96,
"denied": true, "won": false, "next_player": "p_bob" }
Wrong player, or status != IN_PROGRESS, is ILLEGAL_TURN. The die is not a request field. The client does not choose 6.
forfeit(g_9f3a, p_bob) marks Bob FORFEITED. If Alice is the last ACTIVE player, she WON. snapshot is a read. It must not roll.
7. Start with a naive if per snake — show the edit trap
The first design everyone writes:
function move(player, roll):
player.position += roll
if player.position == 98: player.position = 28
if player.position == 62: player.position = 19
if player.position == 14: player.position = 67
if player.position >= 100: player.wins()
It is easy to draw and easy to break.
Product: add a snake 47 → 12
Engineer: open Game.move, add another if
Next week: ladder 47 → 84 // same square, silent overwrite in if-order
QA: Alice 96 + 5 → 101 ≥ 100 → she “wins”
Three bugs in one function:
- Data in code. A new snake is a source edit, not a board config.
- Order is a hidden rule. If 14 goes to 67 and 67 goes to 19, both
ifs fire. That is chaining by accident. >= 100is not a win rule. Alice on 96 with a 5 never touched 100.
The bug is not the if. The bug is treating the board as control flow. A snake is a pair of integers. Adding a snake must not require a pull request to Game.
hardcoded ifs wrong
if-chain that loops a different game
bounce / wrap a different win
map lookup, once the interview
We will use the map. The if is what you say you threw away.
8. Board as a jump map
Before we roll anything, we have to know what a square does when you land on it. Draw a slice, then state the rule.
10 11 12 13 [14↑67] 15 16
...
94 95 96 97 [98↓28] 99 [100 WIN]
14 and 98 are not special types of square. They are keys in jumps.
jumps[14] = 67
jumps[98] = 28
destination(sq) = jumps.get(sq, sq) // identity if absent
Snake and ladder are the same jump type. Direction is a validation concern at load time, not a branch at play time.
addSnake(head, tail):
require 1 <= tail < head <= last
require head != last // 100 is a win, not a head
require head not in jumps
jumps[head] = tail
addLadder(bottom, top):
require 1 <= bottom < top <= last
require bottom not in jumps
jumps[bottom] = top
A ladder to 100 is legal: you can win by climbing. A snake from 100 is not: the win square must be terminal. Two jumps from 14 is a config error, not “last write wins.” A jump to a square that is itself a key is allowed in the map. We still apply the map once. That is the product line that kills Bob’s pinball.
chain? no destination(destination(landed)) // I will not do this
chain? yes while jumps.contains(pos): pos = jumps[pos]
Chaining needs a cycle check at build (14 → 67 → 14). No-chain does not. I pick no chain / at most one jump unless they ask for the arcade version. Say it out loud. The trade-off: a printed set that does stack slides will not match this code. Most whiteboards do not want that argument.
Square as a class with type = SNAKE is optional sugar for a renderer. The game loop asks the map, not the type tag. If the tag and the map disagree, the map wins.
9. The move: add, deny, jump once
Requirement: a roll either places the token on a legal square or is a no-op. Constraint: 100 is the only win. Decision: deny overshoot, then apply one jump. Trade-off: late-game stalls are common; bounce is “more fun” and more ambiguous.
applyRoll(player, roll):
from = player.position
tentative = from + roll
if tentative > board.last:
return TurnResult(denied=true, from, dest=from, won=false)
landed = tentative
dest = board.destination(landed) // at most one lookup
player.position = dest
won = (dest == board.last)
return TurnResult(from, landed, dest, jumped=dest!=landed, won)
Alice 96 + 4 100, dest 100, won
Alice 96 + 5 101 > last, stay 96, denied
Bob 10 + 4 landed 14, dest 67 // destination(67) is NOT consulted
Wrap is (96 + 5) % 100 = 1 — a teleport. Bounce is 100 - (101 - 100) = 99; 96 + 6 bounced lands on 98 and slides to 28. Bounce is a coherent game. I do not default to it: interviewers use exact 100 to catch >=. If they want bounce, only landed changes; the jump-once line does not.
Start at 0: 0 + 4 lands on 4. “Need a 6 to enter” is a flag on applyRoll, not a second board. Illegal: jump before the deny. Order is add → compare to 100 → maybe jump.
10. Turn state machine
Two machines. Do not squash them into player.status = "alice_is_sliding".
Game
IN_PROGRESS ──── last token hits 100 ────► WON
IN_PROGRESS ──── one ACTIVE remains ─────► WON (forfeit win)
IN_PROGRESS ──── no ACTIVE remains ──────► ABORTED
There is no PAUSED unless they ask. Create starts IN_PROGRESS with currentIndex = 0.
Turn (the one they are grading)
WAITING_ROLL
│ roll() by current ACTIVE player
▼
MOVING // applyRoll; not a visible hang, a step
│
├── dest == last ──────────────────────────────► WON
│
├── extraOnSix and die == 6
│ and extrasThisTurn < MAX_EXTRA ──────────► WAITING_ROLL
│ (same player)
│
└── else ── next ACTIVE player ────────────────► WAITING_ROLL
MOVING is a step inside roll(), not a hang across the network. The client sees WAITING_ROLL or WON.
Extra turn on 6 is a product choice. I turn it on, with MAX_EXTRA = 2 (a third 6 still moves, then we pass). Without a cap Alice can starve Bob. “Three 6s cancel the move” is a folk rule; I will not add it unless they ask.
Illegal transitions:
roll by a non-current player
roll when status is WON or ABORTED
roll by FORFEITED
client-supplied die
jump applied twice
WON → IN_PROGRESS
The pair that must stay together:
BEGIN (single-threaded Game)
die = dice.roll()
result = applyRoll(current, die)
if result.won: status = WON
else: advance or keep on 6
END
Write Player.position and currentIndex / status in the same method. A double-click is ILLEGAL_TURN on the second call. Same habit as the parking-lot pair FREE → OCCUPIED plus ticket OPEN.
11. A board snippet and a full turn sequence
Use a short board so the walk stays on one screen.
Board last = 100
jumps:
14 → 67 ladder
67 → 19 snake (present in data; must NOT fire after 14)
98 → 28 snake
Alice 96 Bob 10
status IN_PROGRESS current = Alice extras = 0
Alice rolls 4 — 96 + 4 = 100, WON. She never touched 98. Bob’s roll is ILLEGAL_TURN.
Reset: Alice still on 96, she rolls 5
WAITING_ROLL (Alice)
die 5
96 + 5 = 101 > 100 denied
Alice stays 96
extra? no
current = Bob
WAITING_ROLL (Bob)
Bob then rolls 4
WAITING_ROLL (Bob)
die 4
10 + 4 = 14
destination(14) = 67
Bob = 67 // not 19
extra? no
current = Alice
WAITING_ROLL (Alice)
If you had written while (jumps.contains(pos)), Bob would sit on 19 and you would have to defend a different game. Point at 67 in the map and stop.
A 6, then a 3, same visit
Alice 0, WAITING_ROLL, extras = 0
die 6 → dest 6, extra_turn, extras = 1, current Alice
Alice 6, WAITING_ROLL
die 3 → dest 9, extras = 0, current Bob
The extra is “you may roll again,” not “you move 6+3 as one add.” Two applyRolls. A snake on 6 would have fired on the first result, then she rolls from the tail.
12. Dice as an interface
A real die is a side effect. Tests that call Random are flaky and do not prove 96+4. Inject the die.
interface Dice
roll() -> int // 1..6, or the implementor fails fast
FairDice
roll() = uniform 1..6
SequenceDice // tests, demos, “loaded”
values = [4, 5, 4, 6, 3]
roll() = next value; fail if exhausted
Game never new Random(). Production passes FairDice. Landing tests pass SequenceDice. Reject n outside 1..faces at the boundary. A die that always returns 6 is legal and is how you demo the extra-turn cap. Do not put roll() on Player — Alice does not own entropy.
13. Class sketch
Ideas, not a framework dump. Callers share one Game per table.
Dice roll() -> int
FairDice
SequenceDice values[]
Jump from, to
Board last, jumps
destination(square) -> square
fromConfig(snakes, ladders) -> Board
Player id, name, position, status
TurnResult die, from, landed, dest, denied, jumped, won,
extraTurn, nextPlayerId
Game
board, players[], currentIndex, status, extrasThisTurn, dice
roll(playerId) -> TurnResult
forfeit(playerId) -> void
snapshot() -> GameView
GameFactory
create(names, boardConfig, dice) -> Game
Game.roll in one breath:
roll(playerId):
if status != IN_PROGRESS: throw ILLEGAL_TURN
p = players[currentIndex]
if p.id != playerId or p.status != ACTIVE: throw ILLEGAL_TURN
die = dice.roll()
result = applyRoll(p, die)
if result.won:
status = WON
result.nextPlayerId = p.id
return result
if extraOnSix and die == 6 and extrasThisTurn < MAX_EXTRA:
extrasThisTurn += 1
result.extraTurn = true
result.nextPlayerId = p.id
return result
extrasThisTurn = 0
currentIndex = nextActive(currentIndex)
result.nextPlayerId = players[currentIndex].id
return result
applyRoll is the only writer of Player.position. nextActive skips FORFEITED and crowns a sole remaining ACTIVE player.
14. Win, draw, disconnect
These three are where a clean machine earns its keep.
Win
Alice’s token is on 100 after applyRoll. status = WON. Further roll is ILLEGAL_TURN. A ladder to 100 is just dest == last. Do not also check >= last “to be safe.”
Draw (optional)
v1 has no draw and no turn clock. If they want one: everyone remaining forfeits, or the host aborts. ABORTED is not WON.
ACTIVE left = 0 → ABORTED // optional draw / cancelled table
ACTIVE left = 1 → WON // last standing
token on last → WON
Disconnect — forfeit
Bob closes the tab. He is not “skipped forever” with a live token that Alice can never pass. He is FORFEITED. His position freezes for the snapshot (audit: he was on 67). He is not nextActive.
forfeit(p_bob):
Bob.status = FORFEITED
if activeCount() == 1:
status = WON // Alice last standing
else if current is Bob:
extrasThisTurn = 0
currentIndex = nextActive(Bob)
Do not applyRoll on forfeit, and do not slide him as punishment. A second forfeit on Bob is a no-op. If you ever network this, say “idempotency key” and refuse to design Kafka — a retry must not roll twice.
15. Tests that matter
Skip getters. Test the landing rule.
- Land on a snake. Token on 97,
SequenceDice(1),jumps[98]=28. Dest 28. Not 98. Game stillIN_PROGRESS. - Land on a ladder. Token on 10, die 4,
jumps[14]=67. Dest 67. - Roll past 100. Token on 96, die 5. Dest 96,
denied. Next player (or extra only if the die was 6 — here it is not). - Exact 100. Token on 96, die 4. Dest 100,
won,status=WON. A secondrollthrows. - No chain.
jumps[14]=67andjumps[67]=19. Die 4 from 10. Dest 67, not 19. - Extra 6. Die 6 from 0, then die 3. Same
playerIdtwice; positions 6 then 9. - Forfeit. Bob forfeits on his turn; Alice is current; Bob cannot roll.
- Config. Building a board with two jumps from 14 throws. A snake from 100 throws.
Test (3) and (4) are the offer. If they only have time for two, run deny-overshoot and exact-win. Test (5) is the Senior add-on: prove the map is applied once.
FairDice is untested except “in 1..6.” Randomness is not a landing test.
16. Extensions
Stay on objects. Do not add a message bus.
Crooks (two pieces). Player.positions[2]; roll takes a pieceIndex. Win when both are on 100 — or either, if they want casual. Say which. applyRoll is per piece. Extra 6 still belongs to the player. “Land on opponent, send home” is Ludo; I would not glue it on.
More dice. Sum two roll()s; exact-100 uses the sum. Restate extra-turn (a 6? doubles?). The jump map does not care.
Bounce / chain / N×N. Bounce is one branch. Chain is a while plus a cycle check at build. last = n*n; a 2D array is only for a renderer.
Persist / undo. Serialize positions, currentIndex, status. Undo is a TurnResult stack — not v1.
17. Final architecture / object diagram
One table. Many rolls. One apply.
Alice UI Bob UI
| |
| roll(playerId) |
v v
+-----------------------------------------------+
| Game |
| roll / forfeit / snapshot |
| status IN_PROGRESS | WON | ABORTED |
| currentIndex extrasThisTurn |
+-----------+------------------+----------------+
| |
+---------v---+ +-----v------+
| Board | | Dice |
| last = 100 | | Fair / Seq |
| jumps[] | | roll() |
| destination | +------------+
+------+------+
|
v
14→67 98→28 67→19 (data only)
|
+------v-------+
| Player[] |
| pos, status |
+--------------+
roll legal? -> die.roll -> applyRoll (add, deny, one jump)
-> WON | extra 6 | next ACTIVE
Kafka, Redis, and a region pair are not in this picture. If those words appear, you left LLD.
Source of truth Player.position + Game.status + currentIndex
Derived “Alice is winning”, board highlights, TurnResult
Ephemeral dice animation, UI countdown
Config jump map, last square, extra-on-6 flag
Audit optional list of TurnResult after the table ends
18. Interview-ready summary
How to walk through in 10–15 minutes
0–2 min. Alice 96 + 4; snake on 98; wrap/bounce/>= all lie. LLD, not a lobby.
2–4 min. Board 100, 2–4 players, exact win, no chain, extra 6 with a cap.
4–6 min. Entities: jump map, Player, Dice, Game. Snake = ladder = from → to.
6–9 min. Naive if (98), then applyRoll: add, deny, one lookup.
9–12 min. Turn machine, extra 6, forfeit → last standing.
12–15 min. SequenceDice tests: snake, ladder, past 100, exact 100, no chain. Crooks / second die if they push. 10k games = 10k instances.
Key decisions to remember
- This is objects and one move function, not a multiplayer backend.
- Snakes and ladders are one
Map<from, to>validated at load. - At most one jump per roll; chaining is a different game.
- Exact 100 wins; overshoot is a denied no-op (bounce only if they ask).
- Never
>= lastand never wrap. - Order is add → deny → jump. Do not slide first.
Diceis an interface; production is fair, tests are a sequence.- Extra turn on 6 is a product flag with a cap, not a second move formula.
- Forfeit marks
FORFEITEDand may crown the lastACTIVEplayer. - Ten thousand tables are ten thousand
Games, routed bygame_id.
Likely interviewer follow-up questions
- Exact 100 or bounce back?
- What if a ladder lands on a snake?
- Extra turn on 6? What about three 6s?
- How do you add a snake without editing
Game? - Can two players sit on the same square?
- How do you unit-test a random die?
- What if Bob disconnects mid-turn?
- Two pieces per player (crooks)?
- Two dice?
- How would you persist or undo a table?
Senior-level points that differentiate the answer
- Name wrap, bounce, and
>=before drawing classes. Classes without a landing rule are a vocabulary list. - Collapse snake and ladder into one jump; keep the words only on the config API.
- Prove no-chain with a board that could chain (14 → 67 → 19) and stop at 67.
- Inject
Dice. A test that stubsRandomis how juniors fail the 96+4 case. - Separate Game status from the turn step; do not store
"sliding". - Treat 10,000 concurrent games as instance fan-out, the same way a parking lot fans out by garage — see Design a Parking Lot.
- Cap extra 6s out loud so Alice cannot starve Bob.
A 1–2 minute verbal answer
Snake and Ladder is a board of squares, a jump map, a die, and a turn machine. I store snakes and ladders as one
from → tomap so adding a snake is data, not anif. A roll adds to the current position; if that sum is past 100 I deny the move and stay put; otherwise I land and apply at most one jump. Exact 100 wins. I will not wrap, and I will not bounce unless we agree to. I will not follow a ladder onto a snake in the same roll — that is a different game. Dice is an interface so tests inject4on 96 and4on 10. Turns goWAITING_ROLL → apply → next player, with an optional extra roll on 6 capped so one player cannot hog the table. A disconnect forfeits that player; last one standing wins. This stays in oneGameobject. Ten thousand concurrent games are ten thousand instances, not a queue.
For how this sits next to other LLD and HLD prompts, see the System Design Interview Complete Guide and the questions hub.
