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:

  1. Create a game with a board and two to four named players.
  2. Let only the current player roll.
  3. Move that player’s token by the roll, then apply at most one jump.
  4. Reject a roll that would pass 100; the token stays.
  5. Mark a winner on exact 100 and refuse further rolls.
  6. Rotate turns; optionally grant an extra roll on 6.
  7. 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.

RequirementTarget
LandingSame (position, roll, board) always yields the same TurnResult
WinOnly position == 100 after a legal apply
TurnsOne roll in flight per game; illegal roll is a rejected call
TestDice is an interface; tests inject a sequence
ClockTurns 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() after WON.
  • 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:

  1. Data in code. A new snake is a source edit, not a board config.
  2. Order is a hidden rule. If 14 goes to 67 and 67 goes to 19, both ifs fire. That is chaining by accident.
  3. >= 100 is 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 496 + 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.

  1. Land on a snake. Token on 97, SequenceDice(1), jumps[98]=28. Dest 28. Not 98. Game still IN_PROGRESS.
  2. Land on a ladder. Token on 10, die 4, jumps[14]=67. Dest 67.
  3. 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).
  4. Exact 100. Token on 96, die 4. Dest 100, won, status=WON. A second roll throws.
  5. No chain. jumps[14]=67 and jumps[67]=19. Die 4 from 10. Dest 67, not 19.
  6. Extra 6. Die 6 from 0, then die 3. Same playerId twice; positions 6 then 9.
  7. Forfeit. Bob forfeits on his turn; Alice is current; Bob cannot roll.
  8. 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

  1. This is objects and one move function, not a multiplayer backend.
  2. Snakes and ladders are one Map<from, to> validated at load.
  3. At most one jump per roll; chaining is a different game.
  4. Exact 100 wins; overshoot is a denied no-op (bounce only if they ask).
  5. Never >= last and never wrap.
  6. Order is add → deny → jump. Do not slide first.
  7. Dice is an interface; production is fair, tests are a sequence.
  8. Extra turn on 6 is a product flag with a cap, not a second move formula.
  9. Forfeit marks FORFEITED and may crown the last ACTIVE player.
  10. Ten thousand tables are ten thousand Games, routed by game_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 stubs Random is 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 → to map so adding a snake is data, not an if. 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 inject 4 on 96 and 4 on 10. Turns go WAITING_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 one Game object. 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.