Skip to content

Low Level DesignWorked problems

Design snake and ladder

Design snake and ladder step by step for a low level design interview. Grow from a board and a die to validated boards, testable dice, swappable rules, rankings and safe online play, with code in Java, Python, C#, Go and TypeScript.

Foundation30 min core · 46 min full depth

“Design snake and ladder.” Sometimes with “for any number of players”, sometimes with “that people can play online”, and often with “and make sure we can test it”.

Snake and ladder has no decisions in it. Players roll a die and move, and the board does the rest. That’s exactly why it’s asked. With no game logic to hide behind, the only thing left to judge is how you split the model into classes.

Most answers create a Snake class and a Ladder class, put a random number generator inside the game, and loop until someone reaches 100. That runs. But it can’t be tested, a game can’t be replayed, and it happily accepts a board where nobody can ever win.

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. Snakes and ladders are the same thing: a jump. Land on one square, end on another. A snake goes down and a ladder goes up. So the board keeps one map of jumps, not two classes.
  2. The die comes from outside the game. It’s the game’s only source of change. Passing it in lets a test choose every roll, and lets us replay any game exactly.
  3. The board checks itself when it’s built. A snake on the last square, or a jump that leads to another jump, is rejected before anyone plays.
  4. House rules sit behind an interface. “Must you land exactly on 100?” differs between real boards, so it’s a swappable class. A six earning another roll stays a simple if.
  5. Online, two checks protect each turn. A lock stops two requests running at once. A turn number stops a repeated tap from being played as a second roll.

We’ll build this in four stages, then put the game online.

Before drawing anything, we ask a few questions. Everybody knows this game, and that’s the trap: the rules on the box in your house may not be the rules on the interviewer’s box.

Interviewerminute 0

Design snake and ladder.

What they are testing

Whether you find the questions that change the model (the finishing rule, extra turns, what happens after the first winner, who sets up the board) in two or three minutes. Drawing Snake extends Jump before asking anything looks like reciting.

Here are the questions that matter:

  • Must a player land exactly on 100? It differs between boards, so it will be a swappable rule. If it were one fixed rule, it could be a simple if.
  • Does a six earn another roll? Yes. Without it, the turn logic loses one branch.
  • Does the game end at the first winner? It can be set either way: stop, or play on for a full ranking. Stopping at the first winner removes the work of stage 4.
  • Who sets up the board? A config file or an admin, so it can be wrong. That’s why the board checks itself.
  • Local or online? Online: one game on a server, players on their phones. Local play removes the whole online section.

These answers give us one promise that matters more than the others: a test can choose every roll, and a game can be replayed exactly. That’s a design decision, not a test trick. Three smaller promises come with it. A board that can’t be finished, or that loops, is rejected when it’s built. A new finishing rule or a new kind of die doesn’t mean editing Game. And online, two requests for the same turn result in exactly one roll.

We leave out matchmaking, accounts, chat and drawing the board.

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
One die or two? One six-sided die Two dice is a second Dice class that adds two rolls
Can players share a square? Yes, no capturing Capturing (as in Ludo) needs a map from square to players in Game
Is the board always 10×10? No, any size from a config A fixed 100 changes nothing; size is a parameter either way

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).

Interviewerminute 4

Start simple. Two players, one board. What are your classes?

What they are testing

Whether you get a playable game down in about three minutes, and whether snakes and ladders end up as data instead of a class hierarchy. Separate Snake and Ladder classes with the same fields is the answer they hear most.

Start with the smallest thing that plays. Three classes are enough. A Board knows its size and its jumps. A Player is a name. A Game holds the board, the players, where each one stands, and whose turn it is.

The first real decision is how to store snakes and ladders. To the board, they’re the same thing: land on one square, end on another. A snake goes down and a ladder goes up. That difference is just whether the end square is lower or higher, not a reason for two classes. So the board keeps one map from start square to end square, and a move costs one lookup.

The second decision is where a player’s position lives. Putting it on Player feels natural. But position belongs to a game, not to who the player is. Keeping it in Game means the same player can sit in two games at once, and a game can restart without touching its players.

Stage 1: the smallest model that plays a game

1

1

2..*

Game

-positions: int[]

-turn: int

-winner: Player

+playTurn()

Board

+size

-jumps: Map<int,int>

+destination(square) : int

«value»

Player

+name

1

1

2..*

Game

-positions: int[]

-turn: int

-winner: Player

+playTurn()

Board

+size

-jumps: Map<int,int>

+destination(square) : int

«value»

Player

+name

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Game.javagame.pyGame.csgame.gogame.ts
// A player is a value: a name. Where they stand is game state, so it lives in Game,
// which lets the same Player play a second game without carrying an old position along.
record Player(String name) {}
// Board knows squares and jumps, nothing about players or turns.
// A snake and a ladder are the same thing to the board: land on `from`, end on `to`.
class Board {
final int size; // the last square; reaching it wins
private final Map<Integer, Integer> jumps; // start square -> end square
Board(int size, Map<Integer, Integer> jumps) {
// flaw 2: nothing is checked. A snake on the last square, a ladder off the board,
// or a jump that lands on another jump's start all get through. Stage 2 validates.
this.size = size;
this.jumps = Map.copyOf(jumps);
}
// One lookup per move. A square with no jump is its own destination.
int destination(int square) { return jumps.getOrDefault(square, square); }
}
class Game {
private final Board board;
private final List<Player> players;
private final int[] positions; // positions[i] is where players.get(i) stands; 0 is off the board
private int turn; // index of the player who rolls next
private Player winner; // null until someone reaches the last square
// flaw 1: the die is built in. No test can choose a roll, and no game can be replayed.
private final Random random = new Random();
Game(Board board, List<Player> players) {
this.board = board;
this.players = List.copyOf(players);
this.positions = new int[players.size()];
}
void playTurn() {
int roll = random.nextInt(6) + 1;
// flaw 3: overshooting the end just wins, and nothing stops a turn after the game is over.
// Stage 3 makes the finishing rule explicit and swappable.
int target = Math.min(positions[turn] + roll, board.size);
positions[turn] = board.destination(target);
if (positions[turn] == board.size) winner = players.get(turn);
turn = (turn + 1) % players.size();
}
Player winner() { return winner; }
}

This plays a full game, but it has three problems, and we leave them in on purpose. The die is created inside Game, so nothing outside can choose a roll. The board accepts anything, including a snake on the last square. And a roll past the end simply wins, which is only one rule among several. The next two stages fix these.

Stage 2: A board you can trust and a die you control

Section titled “Stage 2: A board you can trust and a die you control”
Interviewerminute 8

How would you test that landing on square 9 sends a player to 3?

What they are testing

Whether you see that the die is the game’s only input, and pull it out behind an interface within a minute. Fixing a global random seed, or mocking a static method, works in a test but leaves the real code impossible to test or replay.

Think about the test. To put a player on square 9, the test needs to choose the rolls that get them there. While the game owns its own random generator, the only way is to hunt for a seed that happens to produce those rolls. That’s fragile, and nobody reading the test can see why it works.

So the die becomes an interface, Dice, with one method: roll(). The game receives a die when it’s created, instead of making one. This is the Strategy pattern: one interface, and a class for each way of rolling. Two classes cover everything:

  • RandomDice wraps a random generator that is passed in. Give it the same seed (the starting number for the generator) and it replays a whole game exactly.
  • FixedDice hands out rolls from a list. A test then reads like the story it checks: roll 2, roll 4, roll 2.

The board gets the same care from the other side. It’s data that someone else sets up, so it checks itself once, when it’s built. Each rule protects against a real failure:

Rule Without it
Every jump starts and ends on the board A ladder to square 140 puts a player off the board
No jump from the last square A snake on 100 means nobody can ever finish
At most one jump starts on a square Which one applies would depend on the map’s order
No jump ends where another starts (no chains) Ladder 20 to 50, snake 50 to 20: a player bounces forever

The last rule is the interesting one. If a jump never ends where another jump starts, every move takes at most one jump. So a loop can’t happen, and the move code never needs a while. The jump also becomes a small value with a name, Jump(from, to), so a turn can report which jump it took.

Stage 2: a validated board and the dice behind an interface

1

1

2..*

1

*

Board

+size

-jumps: Map<int,Jump>

+Board(size, List<Jump>)

+jumpAt(square) : Jump

«value»

Jump

+from

+to

+isLadder() : bool

«interface»

Dice

+roll() : int

RandomDice

-random: Random

-faces

FixedDice

-rolls: Deque<int>

Game

«value»

Player

1

1

2..*

1

*

Board

+size

-jumps: Map<int,Jump>

+Board(size, List<Jump>)

+jumpAt(square) : Jump

«value»

Jump

+from

+to

+isLadder() : bool

«interface»

Dice

+roll() : int

RandomDice

-random: Random

-faces

FixedDice

-rolls: Deque<int>

Game

«value»

Player

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Board.javaboard.pyBoard.csboard.goboard.ts
// A jump is a value. Whether it is a snake or a ladder follows from its direction,
// so there is no Snake and Ladder class pair with nothing to tell them apart but a sign.
record Jump(int from, int to) {
boolean isLadder() { return to > from; }
}
// The squares and their jumps. Every rule is checked once, when the board is built, and the
// board never changes after that.
class Board {
final int size;
private final Map<Integer, Jump> jumps = new HashMap<>(); // keyed by start square
// Every rule about a legal board is checked once, here, so a Game can trust any Board it gets.
Board(int size, List<Jump> jumpList) {
if (size < 2) throw new IllegalArgumentException("board needs at least 2 squares");
this.size = size;
for (Jump j : jumpList) {
if (j.from() < 1 || j.from() > size || j.to() < 1 || j.to() > size)
throw new IllegalArgumentException(j + " leaves the board");
if (j.from() == j.to()) throw new IllegalArgumentException(j + " goes nowhere");
// A snake on the last square would make the game unwinnable.
if (j.from() == size) throw new IllegalArgumentException(j + " starts on the last square");
if (jumps.putIfAbsent(j.from(), j) != null)
throw new IllegalArgumentException("two jumps start on square " + j.from());
}
// No chains: a jump never ends where another starts. So a move takes at most one jump,
// and a loop (a ladder up to a snake back down to the ladder) can't exist.
for (Jump j : jumps.values())
if (jumps.containsKey(j.to())) throw new IllegalArgumentException(j + " ends on another jump");
}
// The jump that starts on this square, or null.
Jump jumpAt(int square) { return jumps.get(square); }
}
// The die is an interface so the game never decides randomness itself.
// Tests script the rolls; replays and online games use a seeded source.
interface Dice {
int roll();
}
// Real dice: each roll is 1 to faces.
class RandomDice implements Dice {
private final Random random;
private final int faces;
// The Random is passed in: give it a seed and the whole game can be replayed exactly.
RandomDice(Random random, int faces) { this.random = random; this.faces = faces; }
public int roll() { return random.nextInt(faces) + 1; }
}
// Rolls given up front. Tests use it to walk a player onto a chosen snake.
class FixedDice implements Dice {
private final Deque<Integer> rolls;
FixedDice(Integer... rolls) { this.rolls = new ArrayDeque<>(List.of(rolls)); }
public int roll() { return rolls.removeFirst(); }
}

Game changes in two lines, shown in full in the next stage. It takes a Dice when it’s created, and it asks the board for jumpAt(square).

What does this cost? One more interface, and one more argument, for a game that rolls a number from 1 to 6. In a game whose only input is the die, that’s the cheapest way to make it testable. And it pays off in production too: a “the snake on 47 didn’t fire” bug report can be replayed from the game’s seed.

Go deeperWhy not allow chains and follow jumps in a loop?1 min

Because then the board has to prove the loop ends. Checking that means searching the jumps for a cycle. Some boards do chain jumps on purpose, and that’s a fine extension: check for cycles when the board is built (a depth-first search over the jumps), and make jumpAt follow the chain. Banning chains keeps the common board simple, and leaves that door open. I’d ask which the product wants.

Interviewerminute 13

Our boards need an exact roll to finish, and a six rolls again. Where does that go?

What they are testing

Whether the finishing rule goes behind an interface and the six stays a plain if, and whether you can say why one deserves an interface and the other doesn’t. Two flags and an if for each is the answer that gets a “now add a third rule” follow-up.

The finishing rule differs between real boards, so it becomes a FinishRule with one method: given where the player stands and what they rolled, where do they land, before any jump? ExactFinish wastes a roll that goes past the end: the player stays where they are. BounceBack counts back from the end: at 98 with a roll of 4, the player goes to 100, then back to 98. A third rule is a third class, and Game never changes. That’s the Strategy pattern again.

The six is different. No real board changes what a six does, so it stays a simple if in playTurn: if the roll is a six and the player didn’t just finish, the turn doesn’t pass. The order matters: a winning six must not give the winner another roll.

Each turn now returns a TurnResult: who rolled, what they rolled, where they started and ended, the jump they took, whether they finished, and whether they roll again. It’s one small class, and it pays three times. The screen animates from it, tests check it, and a list of them is a replay.

Stage 3: the finishing rule and the turn result

1

1

returns

Game

-dice: Dice

-finish: FinishRule

-status: Status

+current() : Player

+playTurn() : TurnResult

«interface»

FinishRule

+land(from, roll, last) : int

ExactFinish

BounceBack

«value»

TurnResult

+player

+roll

+from

+to

+jump: Jump

+finished

+rollsAgain

«enum»

Status

IN_PROGRESS

OVER

Board

«interface»

Dice

1

1

returns

Game

-dice: Dice

-finish: FinishRule

-status: Status

+current() : Player

+playTurn() : TurnResult

«interface»

FinishRule

+land(from, roll, last) : int

ExactFinish

BounceBack

«value»

TurnResult

+player

+roll

+from

+to

+jump: Jump

+finished

+rollsAgain

«enum»

Status

IN_PROGRESS

OVER

Board

«interface»

Dice

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Game.javagame.pyGame.csgame.gogame.ts
// What happens when a roll would carry a player past the last square. Boards in shops
// disagree, so it is a rule passed in, not an if inside Game.
interface FinishRule {
// The square a roll reaches before any jump. Returning `from` means the player stays put.
int land(int from, int roll, int last);
}
// Must land exactly on the last square; a roll that overshoots is wasted.
class ExactFinish implements FinishRule {
public int land(int from, int roll, int last) {
return from + roll > last ? from : from + roll;
}
}
// Overshoot bounces back: from 98 with a 5, count 99, 100, then back to 99, 98, 97.
class BounceBack implements FinishRule {
public int land(int from, int roll, int last) {
int target = from + roll;
return target > last ? last - (target - last) : target;
}
}
// Everything one turn did, as a value. The UI animates from it, tests assert on it,
// and a log of them is a full replay.
record TurnResult(Player player, int roll, int from, int to, Jump jump, boolean finished, boolean rollsAgain) {}
// Whether the game is still being played.
enum Status { IN_PROGRESS, OVER }
class Game {
private final Board board;
private final Dice dice;
private final FinishRule finish;
private final List<Player> players;
private final int[] positions;
private int turn;
private Status status = Status.IN_PROGRESS;
private Player winner;
Game(Board board, Dice dice, FinishRule finish, List<Player> players) {
if (players.size() < 2) throw new IllegalArgumentException("need at least two players");
this.board = board;
this.dice = dice;
this.finish = finish;
this.players = List.copyOf(players);
this.positions = new int[players.size()];
}
Player current() { return players.get(turn); }
TurnResult playTurn() {
// Locally, rolling after the end is a bug in the caller, not player input, so it throws.
if (status == Status.OVER) throw new IllegalStateException("game is over");
Player p = players.get(turn);
int roll = dice.roll();
int from = positions[turn];
int landed = finish.land(from, roll, board.size);
Jump jump = board.jumpAt(landed); // at most one, because the board has no chains
int to = jump == null ? landed : jump.to();
positions[turn] = to;
boolean finished = to == board.size;
// A six earns another roll, unless it just won the game.
boolean again = roll == 6 && !finished;
if (finished) { status = Status.OVER; winner = p; }
else if (!again) turn = (turn + 1) % players.size();
return new TurnResult(p, roll, from, to, jump, finished, again);
}
Status status() { return status; }
Player winner() { return winner; }
int positionOf(Player p) { return positions[players.indexOf(p)]; }
}

The order of steps inside playTurn is the design:

  1. Roll the die.
  2. Apply the finishing rule.
  3. Apply at most one jump.
  4. Save the new position.
  5. Decide whether the player finished.
  6. Decide whether the turn passes.

Because the jump is looked up after the finishing rule, a snake can bite a player who bounced back onto its head, with no special case.

Go deeperWhy an interface for one rule, and an if for the other?1 min

Interfaces go where things actually vary. Real boards disagree about finishing, so it’s swappable. Nobody disagrees about the six, so it stays one readable line. If a product someday wants “three sixes in a row loses the turn”, the six logic moves behind its own interface then, and not before.

Go deeperWhy does playTurn throw after the game is over?1 min

In a local game, the only caller is the game loop, and it can check whether the game is over. Rolling after the end means the loop has a bug, and bugs should be loud. Online it’s different: a late tap from a phone is normal traffic. So the online entry point, later on, returns a named answer (GAME_OVER) instead of throwing.

Interviewerminute 18

With four players, we want to keep playing after the first one finishes, and show the final ranking. And the screen wants to animate every move. What changes?

What they are testing

Whether removing a finished player from the turn order keeps the order right (an off-by-one error most people make live), and whether the screen hooks in without Game knowing anything about screens.

Playing on means a finished player leaves the turn order. So the turn order becomes a list of the players still playing, called active, and next is a position in that list.

When the current player finishes, they’re removed from the list. Here’s the subtle part. Removing an item shifts everyone after it down by one place. So next already points at the following player. Moving next forward as well would skip someone. The only fix needed is to wrap back to the start when the finisher was last in the list. When one player is left, they take last place and the game ends.

The screen needs to hear about every turn, and so might a sound player or a move log. So Game keeps a list of GameListeners and calls each one after every turn, and at the end. Game only knows the interface, so a new screen is a new listener, not an edit to the rules. This is the Observer pattern.

Stage 4: the active rotation, the ranking and listeners

1

1

notifies

*

receives

Game

-active: List<int>

-ranking: List<Player>

-listeners: List<GameListener>

-playToTheEnd: bool

+addListener(GameListener)

+playTurn() : TurnResult

+ranking() : List<Player>

«interface»

GameListener

+onTurn(TurnResult)

+onGameOver(ranking)

«value»

TurnResult

«interface»

FinishRule

«interface»

Dice

Board

1

1

notifies

*

receives

Game

-active: List<int>

-ranking: List<Player>

-listeners: List<GameListener>

-playToTheEnd: bool

+addListener(GameListener)

+playTurn() : TurnResult

+ranking() : List<Player>

«interface»

GameListener

+onTurn(TurnResult)

+onGameOver(ranking)

«value»

TurnResult

«interface»

FinishRule

«interface»

Dice

Board

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Game.javagame.pyGame.csgame.gogame.ts
// Anything that reacts to the game: the UI, a sound player, a move log. Game calls these
// and never knows what is listening, so a new screen doesn't mean editing the rules.
interface GameListener {
void onTurn(TurnResult turn);
default void onGameOver(List<Player> ranking) {}
}
// The rules engine for one game. It knows whose turn it is and where everyone stands, and tells
// its listeners after every turn. It has no lock, so only one caller may use it at a time;
// online play wraps it in one.
class Game {
private final Board board;
private final Dice dice;
private final FinishRule finish;
private final List<Player> players; // seating order, never changes
private final int[] positions; // by seat
private final List<Integer> active = new ArrayList<>(); // seats still playing, in turn order
private final List<Player> ranking = new ArrayList<>(); // finishing order
private final List<GameListener> listeners = new ArrayList<>();
private final boolean playToTheEnd; // false: the first to finish wins and the game stops
private int next; // index into `active` of the seat that rolls next
private Status status = Status.IN_PROGRESS;
Game(Board board, Dice dice, FinishRule finish, List<Player> players, boolean playToTheEnd) {
if (players.size() < 2) throw new IllegalArgumentException("need at least two players");
this.board = board;
this.dice = dice;
this.finish = finish;
this.players = List.copyOf(players);
this.positions = new int[players.size()];
this.playToTheEnd = playToTheEnd;
for (int seat = 0; seat < players.size(); seat++) active.add(seat);
}
void addListener(GameListener l) { listeners.add(l); }
Player current() { return players.get(active.get(next)); }
// One roll for the current player: move, take at most one jump, then decide who goes next.
// A six rolls again, unless that roll finished the game. When playing to the end, finished
// players drop out and the rest go on until one is left.
TurnResult playTurn() {
if (status == Status.OVER) throw new IllegalStateException("game is over");
int seat = active.get(next);
Player p = players.get(seat);
int roll = dice.roll();
int from = positions[seat];
int landed = finish.land(from, roll, board.size);
Jump jump = board.jumpAt(landed);
int to = jump == null ? landed : jump.to();
positions[seat] = to;
boolean finished = to == board.size;
boolean again = roll == 6 && !finished;
if (finished) {
ranking.add(p);
// Removing by index shifts everyone after this seat down by one, so `next`
// already points at the following player. Advancing it as well would skip them.
active.remove(next);
if (next == active.size()) next = 0;
if (!playToTheEnd || active.size() == 1) {
if (playToTheEnd) ranking.add(players.get(active.get(0))); // the last one left
status = Status.OVER;
}
} else if (!again) {
next = (next + 1) % active.size();
}
TurnResult result = new TurnResult(p, roll, from, to, jump, finished, again);
for (GameListener l : listeners) l.onTurn(result);
if (status == Status.OVER) for (GameListener l : listeners) l.onGameOver(List.copyOf(ranking));
return result;
}
Status status() { return status; }
// A copy, so callers can't change the finishing order.
List<Player> ranking() { return List.copyOf(ranking); }
int positionOf(Player p) { return positions[players.indexOf(p)]; }
}
Go deeperOne turn that climbs a ladder, step by step1 min

Here is one turn from the roll to the listeners. The game asks the die, applies the finishing rule, finds a ladder at the landing square, saves the new position, and then tells each listener.

Sequence: one turn that climbs a ladder
GameListenerBoardFinishRuleDiceGameDriver (UI loop or server)GameListenerBoardFinishRuleDiceGameDriver (UI loop or server)position = 7, not finished, turn passesplayTurn()roll()2land(0, 2, 10)2jumpAt(2)Jump(2, 7)onTurn(TurnResult)TurnResultGameListenerBoardFinishRuleDiceGameDriver (UI loop or server)GameListenerBoardFinishRuleDiceGameDriver (UI loop or server)position = 7, not finished, turn passesplayTurn()roll()2land(0, 2, 10)2jumpAt(2)Jump(2, 7)onTurn(TurnResult)TurnResult
Scroll or pinch to zoom, drag to pan
  • Color key
  • Interface
  • Entity
  • Facade

What do listeners cost? A slow or failing listener runs on the game’s time. A listener that throws would stop the turn from returning. So real listeners should hand their work off (to a queue or the screen’s thread) and return at once.

This is enough for a mid-level answer. Jumps are data, the die is passed in so tests choose the rolls, and the finishing rule sits behind an interface. The rest of the page is what makes it a senior answer.

Here is everything together. Each class is here because a requirement needed it. OnlineGame comes from the next section.

Snake and ladder: full class diagram

1

1

returns

1

1

2..*

*

returns

1

*

OnlineGame

-lock

-turnsPlayed: int

+roll(Player, turnNumber) : RollResponse

Game

-players: List<Player>

-positions: int[]

-active: List<int>

-ranking: List<Player>

-status: Status

+current() : Player

+playTurn() : TurnResult

+addListener(GameListener)

Board

+size

-jumps: Map<int,Jump>

+jumpAt(square) : Jump

«value»

Jump

+from

+to

+isLadder() : bool

«value»

Player

+name

«value»

TurnResult

+player

+roll

+from

+to

+jump

+finished

+rollsAgain

«value»

RollResponse

+outcome: Outcome

+turn: TurnResult

+turnNumber

«interface»

Dice

+roll() : int

RandomDice

FixedDice

«interface»

FinishRule

+land(from, roll, last) : int

ExactFinish

BounceBack

«interface»

GameListener

+onTurn(TurnResult)

+onGameOver(ranking)

«enum»

Status

IN_PROGRESS

OVER

«enum»

Outcome

OK

STALE

GAME_OVER

NOT_YOUR_TURN

1

1

returns

1

1

2..*

*

returns

1

*

OnlineGame

-lock

-turnsPlayed: int

+roll(Player, turnNumber) : RollResponse

Game

-players: List<Player>

-positions: int[]

-active: List<int>

-ranking: List<Player>

-status: Status

+current() : Player

+playTurn() : TurnResult

+addListener(GameListener)

Board

+size

-jumps: Map<int,Jump>

+jumpAt(square) : Jump

«value»

Jump

+from

+to

+isLadder() : bool

«value»

Player

+name

«value»

TurnResult

+player

+roll

+from

+to

+jump

+finished

+rollsAgain

«value»

RollResponse

+outcome: Outcome

+turn: TurnResult

+turnNumber

«interface»

Dice

+roll() : int

RandomDice

FixedDice

«interface»

FinishRule

+land(from, roll, last) : int

ExactFinish

BounceBack

«interface»

GameListener

+onTurn(TurnResult)

+onGameOver(ranking)

«enum»

Status

IN_PROGRESS

OVER

«enum»

Outcome

OK

STALE

GAME_OVER

NOT_YOUR_TURN

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
snake-and-ladder· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
// A player is a value: a name. Where they stand is game state, so it lives in Game,
// which lets the same Player play a second game without carrying an old position along.
record Player(String name) {}
// The squares and their jumps. Every rule is checked once, when the board is built, and the
// board never changes after that.
class Board {
final int size;
private final Map<Integer, Jump> jumps = new HashMap<>(); // keyed by start square
// Every rule about a legal board is checked once, here, so a Game can trust any Board it gets.
Board(int size, List<Jump> jumpList) {
if (size < 2) throw new IllegalArgumentException("board needs at least 2 squares");
this.size = size;
for (Jump j : jumpList) {
if (j.from() < 1 || j.from() > size || j.to() < 1 || j.to() > size)
throw new IllegalArgumentException(j + " leaves the board");
if (j.from() == j.to()) throw new IllegalArgumentException(j + " goes nowhere");
// A snake on the last square would make the game unwinnable.
if (j.from() == size) throw new IllegalArgumentException(j + " starts on the last square");
if (jumps.putIfAbsent(j.from(), j) != null)
throw new IllegalArgumentException("two jumps start on square " + j.from());
}
// No chains: a jump never ends where another starts. So a move takes at most one jump,
// and a loop (a ladder up to a snake back down to the ladder) can't exist.
for (Jump j : jumps.values())
if (jumps.containsKey(j.to())) throw new IllegalArgumentException(j + " ends on another jump");
}
// The jump that starts on this square, or null.
Jump jumpAt(int square) { return jumps.get(square); }
}
// The rules engine for one game. It knows whose turn it is and where everyone stands, and tells
// its listeners after every turn. It has no lock, so only one caller may use it at a time;
// online play wraps it in one.
class Game {
private final Board board;
private final Dice dice;
private final FinishRule finish;
private final List<Player> players; // seating order, never changes
private final int[] positions; // by seat
private final List<Integer> active = new ArrayList<>(); // seats still playing, in turn order
private final List<Player> ranking = new ArrayList<>(); // finishing order
private final List<GameListener> listeners = new ArrayList<>();
private final boolean playToTheEnd; // false: the first to finish wins and the game stops
private int next; // index into `active` of the seat that rolls next
private Status status = Status.IN_PROGRESS;
Game(Board board, Dice dice, FinishRule finish, List<Player> players, boolean playToTheEnd) {
if (players.size() < 2) throw new IllegalArgumentException("need at least two players");
this.board = board;
this.dice = dice;
this.finish = finish;
this.players = List.copyOf(players);
this.positions = new int[players.size()];
this.playToTheEnd = playToTheEnd;
for (int seat = 0; seat < players.size(); seat++) active.add(seat);
}
void addListener(GameListener l) { listeners.add(l); }
Player current() { return players.get(active.get(next)); }
// One roll for the current player: move, take at most one jump, then decide who goes next.
// A six rolls again, unless that roll finished the game. When playing to the end, finished
// players drop out and the rest go on until one is left.
TurnResult playTurn() {
if (status == Status.OVER) throw new IllegalStateException("game is over");
int seat = active.get(next);
Player p = players.get(seat);
int roll = dice.roll();
int from = positions[seat];
int landed = finish.land(from, roll, board.size);
Jump jump = board.jumpAt(landed);
int to = jump == null ? landed : jump.to();
positions[seat] = to;
boolean finished = to == board.size;
boolean again = roll == 6 && !finished;
if (finished) {
ranking.add(p);
// Removing by index shifts everyone after this seat down by one, so `next`
// already points at the following player. Advancing it as well would skip them.
active.remove(next);
if (next == active.size()) next = 0;
if (!playToTheEnd || active.size() == 1) {
if (playToTheEnd) ranking.add(players.get(active.get(0))); // the last one left
status = Status.OVER;
}
} else if (!again) {
next = (next + 1) % active.size();
}
TurnResult result = new TurnResult(p, roll, from, to, jump, finished, again);
for (GameListener l : listeners) l.onTurn(result);
if (status == Status.OVER) for (GameListener l : listeners) l.onGameOver(List.copyOf(ranking));
return result;
}
Status status() { return status; }
// A copy, so callers can't change the finishing order.
List<Player> ranking() { return List.copyOf(ranking); }
int positionOf(Player p) { return positions[players.indexOf(p)]; }
}
// A jump is a value. Whether it is a snake or a ladder follows from its direction,
// so there is no Snake and Ladder class pair with nothing to tell them apart but a sign.
record Jump(int from, int to) {
boolean isLadder() { return to > from; }
}
// The die is an interface so the game never decides randomness itself.
// Tests script the rolls; replays and online games use a seeded source.
interface Dice {
int roll();
}
// Real dice: each roll is 1 to faces.
class RandomDice implements Dice {
private final Random random;
private final int faces;
// The Random is passed in: give it a seed and the whole game can be replayed exactly.
RandomDice(Random random, int faces) { this.random = random; this.faces = faces; }
public int roll() { return random.nextInt(faces) + 1; }
}
// Rolls given up front. Tests use it to walk a player onto a chosen snake.
class FixedDice implements Dice {
private final Deque<Integer> rolls;
FixedDice(Integer... rolls) { this.rolls = new ArrayDeque<>(List.of(rolls)); }
public int roll() { return rolls.removeFirst(); }
}
// What happens when a roll would carry a player past the last square. Boards in shops
// disagree, so it is a rule passed in, not an if inside Game.
interface FinishRule {
// The square a roll reaches before any jump. Returning `from` means the player stays put.
int land(int from, int roll, int last);
}
// Must land exactly on the last square; a roll that overshoots is wasted.
class ExactFinish implements FinishRule {
public int land(int from, int roll, int last) {
return from + roll > last ? from : from + roll;
}
}
// Everything one turn did, as a value. The UI animates from it, tests assert on it,
// and a log of them is a full replay.
record TurnResult(Player player, int roll, int from, int to, Jump jump, boolean finished, boolean rollsAgain) {}
// Whether the game is still being played.
enum Status { IN_PROGRESS, OVER }
// Anything that reacts to the game: the UI, a sound player, a move log. Game calls these
// and never knows what is listening, so a new screen doesn't mean editing the rules.
interface GameListener {
void onTurn(TurnResult turn);
default void onGameOver(List<Player> ranking) {}
}
// Every reason the server can turn a roll request away. A stale or out-of-turn request
// is normal traffic online (a double tap, a retry), so it is an answer, not an exception.
enum Outcome { OK, STALE, GAME_OVER, NOT_YOUR_TURN }
// turnNumber is always the game's current count, so a client that got STALE knows where it is.
record RollResponse(Outcome outcome, TurnResult turn, int turnNumber) {}
// The server's handle on one game. Game is a single-threaded rules engine;
// this class adds the two things online play needs: one request at a time, and turn numbers.
// Only this class holds the Game, so no caller can reach it without the lock.
class OnlineGame {
private final Game game;
private final Object lock = new Object(); // private, so no outside code can lock on us and stall play
private int turnsPlayed;
OnlineGame(Game game) { this.game = game; }
// The client sends the turn number it last saw. A request for an older turn is STALE, so a
// double tap or a retry can't roll twice.
RollResponse roll(Player p, int turnNumber) {
synchronized (lock) {
// Checks and the turn itself run under one lock, so two requests can't both pass
// "is it your turn?" before either has moved.
if (turnNumber != turnsPlayed) return new RollResponse(Outcome.STALE, null, turnsPlayed);
if (game.status() == Status.OVER) return new RollResponse(Outcome.GAME_OVER, null, turnsPlayed);
if (!game.current().equals(p)) return new RollResponse(Outcome.NOT_YOUR_TURN, null, turnsPlayed);
// The server rolls, never the client: a client that sends its own roll can send a
// six.
TurnResult t = game.playTurn();
turnsPlayed++;
return new RollResponse(Outcome.OK, t, turnsPlayed);
}
}
// Readers take the lock too, so they see a turn's writes all or none.
int positionOf(Player p) { synchronized (lock) { return game.positionOf(p); } }
}
Read onlyJava 21Every file builds in CI16 files207 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI16 files249 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI16 files210 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI14 files281 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI15 files241 lines

Three ideas hold it together:

  • All input enters through one interface. The die is the only source of change in the game, and it comes from outside. That one decision makes every test predictable, and every game replayable from a seed or a list of rolls.
  • The board is valid from the moment it exists. Every rule about a legal board runs when it’s built. So Game never checks for loops or boards nobody can win, and a move takes at most one jump.
  • The things that vary sit behind interfaces (shown in amber): the die and the finishing rule (Strategy), and the listeners (Observer). A new house rule or a new screen is a new class. To callers, Game is a simple front door with one method, playTurn (a facade).

So far, one caller plays the game. Now it’s online: one game on a server, and each player’s phone sends requests.

Interviewerminute 25

It’s online now. A player taps “roll” twice quickly, and both requests reach the server. What happens?

What they are testing

Whether you separate the race (two threads in playTurn at once) from the duplicate (a second copy of the same request), and whether the server, not the phone, rolls the die. “Add synchronized” alone fixes only one of the two.

Two different things can go wrong.

The race. Both requests run at the same moment, on two server threads. Both check “is it your turn?”, both see yes, and the player moves twice.

The duplicate. The two requests arrive one after the other. But the player just rolled a six, so it really is their turn again. The second copy of the first tap is accepted as a brand new roll. A lock fixes the race. But no lock fixes the duplicate, because the second request is valid by every rule Game knows.

The clearly correct fix for the race is a lock per game. Every request for a game holds that game’s lock while it runs. Two requests can no longer interleave. But the duplicate still gets through.

The fix for the duplicate is a turn number. The server counts the turns played. Each request says which turn it’s rolling for, for example “turn 7”. If turn 7 has already been played, the request is refused as stale. So each tap can count at most once, however many times it arrives. Doing an action once no matter how many copies arrive is called being idempotent.

Option How Fixes the race Fixes the duplicate
1. A lock per game Every request for the game holds its lock Yes No
2. One thread per game All requests for a game go through its own queue Yes No
3. Turn number The phone sends the turn it’s rolling for; a mismatch is stale No, alone Yes
4. Lock plus turn number (our choice) Both Yes Yes

Game stays exactly as stage 4 left it: a single-threaded rules engine. A new class, OnlineGame, wraps it. It holds the lock and the turn count, and it’s the only thing that holds the Game. So no request can reach the rules without passing both checks.

Sequence: a double tap after a six
GameOnlineGameTap 2 (same request)Tap 1GameOnlineGameTap 2 (same request)Tap 1lock held by tap 1, tap 2 waitstap 2 gets the lockroll(Ana, turn 7)roll(Ana, turn 7)playTurn()rolled 6, rolls againOK, turn number is now 8STALE (turn 7 already played)GameOnlineGameTap 2 (same request)Tap 1GameOnlineGameTap 2 (same request)Tap 1lock held by tap 1, tap 2 waitstap 2 gets the lockroll(Ana, turn 7)roll(Ana, turn 7)playTurn()rolled 6, rolls againOK, turn number is now 8STALE (turn 7 already played)
Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
OnlineGame.javaonline_game.pyOnlineGame.csonline_game.goonline-game.ts
// Every reason the server can turn a roll request away. A stale or out-of-turn request
// is normal traffic online (a double tap, a retry), so it is an answer, not an exception.
enum Outcome { OK, STALE, GAME_OVER, NOT_YOUR_TURN }
// turnNumber is always the game's current count, so a client that got STALE knows where it is.
record RollResponse(Outcome outcome, TurnResult turn, int turnNumber) {}
// The server's handle on one game. Game is a single-threaded rules engine;
// this class adds the two things online play needs: one request at a time, and turn numbers.
// Only this class holds the Game, so no caller can reach it without the lock.
class OnlineGame {
private final Game game;
private final Object lock = new Object(); // private, so no outside code can lock on us and stall play
private int turnsPlayed;
OnlineGame(Game game) { this.game = game; }
// The client sends the turn number it last saw. A request for an older turn is STALE, so a
// double tap or a retry can't roll twice.
RollResponse roll(Player p, int turnNumber) {
synchronized (lock) {
// Checks and the turn itself run under one lock, so two requests can't both pass
// "is it your turn?" before either has moved.
if (turnNumber != turnsPlayed) return new RollResponse(Outcome.STALE, null, turnsPlayed);
if (game.status() == Status.OVER) return new RollResponse(Outcome.GAME_OVER, null, turnsPlayed);
if (!game.current().equals(p)) return new RollResponse(Outcome.NOT_YOUR_TURN, null, turnsPlayed);
// The server rolls, never the client: a client that sends its own roll can send a
// six.
TurnResult t = game.playTurn();
turnsPlayed++;
return new RollResponse(Outcome.OK, t, turnsPlayed);
}
}
// Readers take the lock too, so they see a turn's writes all or none.
int positionOf(Player p) { synchronized (lock) { return game.positionOf(p); } }
}

Why is this correct? Every read and write of the game happens under one lock. So a turn’s checks and its changes are one step that no other request can split. The turn number makes each roll count once: a request names its turn, and once that turn is played, every later copy is refused, in whatever order the copies arrive. Readers take the lock too, so nobody sees a player moved but the turn not yet passed.

Go deeperWho rolls the die, and with which generator1 min

The server rolls the die. If the phone sent “I rolled a 6”, a modified app could roll sixes forever. The random generator matters. java.util.Random, Python’s random and Go’s math/rand are predictable: someone who sees enough rolls can work out the next one. That’s fine for a casual game. If rolls decide money, use a secure generator (SecureRandom, secrets, crypto/rand), and replay from a saved list of rolls instead of a seed.

What does it cost? Anyone holding the raw Game could skip the lock, which is why only OnlineGame holds it. And a lock in one server’s memory only works on one server.

Go deeperWhen the game is shared by several servers1 min

Across several servers, a lock in one server’s memory isn’t enough. The turn number then becomes a condition in the database update: UPDATE games SET ... WHERE id = ? AND turns = ?. If another server played the turn first, the update changes no rows, and the request is stale. The turn number we added for double taps turns out to be exactly what several servers need too.

Go deeperTelling listeners while holding the lock1 min

It looks safer to collect events under the lock and call listeners after letting go. But then two turns can deliver their events out of order: turn 8’s thread may reach the listeners before turn 7’s. Calling listeners under the lock keeps the order. That’s why listeners must be quick and only hand work off.

Go deeperWhy a lock per game, not one for the whole server?1 min

Games share nothing. One lock for the whole server would make a million unrelated games wait for each other for no reason. With a lock per game, the only requests that ever wait are from the handful of players in that game, and a turn takes microseconds. TypeScript in one Node process needs no lock at all, since requests don’t run at the same moment. But it still needs the turn number, because retries and double taps still happen.

Because the die is passed in, every test is a short script of rolls. The ones that matter:

  • A ladder climbs and a snake bites, on a ten-square board where the arithmetic is obvious.
  • A roll past the end is wasted under exact finish, and the turn still passes.
  • A six rolls again, and a winning six doesn’t.
  • When a player finishes, the next player in order rolls next, not the one after.
  • The board rejects a chain, and a snake on the last square.
  • The same seed replays the same game, turn for turn.
  • Sixteen requests racing for one turn play exactly once.
Go deeperThe tests in five languages2 min

The turn-order test is the one that catches real bugs: three players, the first one finishes, and the test checks that the second one rolls next. The replay test protects the idea the whole design is built on. If someone adds a hidden random generator anywhere, it starts failing. The race test can pass by luck, like every race test, so pair it with the reasoning above.

Main.javaexample.pyProgram.csmain.goexample.ts
static final Player ANA = new Player("Ana"), BO = new Player("Bo"), CY = new Player("Cy");
// Ten squares, a ladder from 2 to 7 and a snake from 9 to 3. Small enough to reason about.
static Board board() { return new Board(10, List.of(new Jump(2, 7), new Jump(9, 3))); }
static Game game(Dice dice, boolean toTheEnd, Player... ps) {
return new Game(board(), dice, new ExactFinish(), List.of(ps), toTheEnd);
}
@Test
void aLadderClimbsAndASnakeBites() {
Game g = game(new FixedDice(2, 4, 2), false, ANA, BO);
TurnResult up = g.playTurn(); // Ana rolls 2: lands on the ladder
assertEquals(7, up.to());
assertTrue(up.jump().isLadder());
g.playTurn(); // Bo rolls 4
assertEquals(3, g.playTurn().to()); // Ana rolls 2: 7 + 2 = 9, the snake
}
@Test
void anOvershootIsWastedUnderExactFinish() {
Game g = game(new FixedDice(2, 1, 5), false, ANA, BO);
g.playTurn(); g.playTurn(); // Ana on 7, Bo on 1
TurnResult t = g.playTurn(); // Ana needs 3, rolls 5
assertEquals(7, t.to());
assertEquals(BO, g.current()); // and the turn still passes
}
@Test
void aSixRollsAgainButNotAfterAWin() {
Game g = game(new FixedDice(6), false, ANA, BO);
assertTrue(g.playTurn().rollsAgain());
assertEquals(ANA, g.current()); // Ana again
Game w = game(new FixedDice(4, 1, 6), false, ANA, BO);
w.playTurn(); w.playTurn(); // Ana on 4, Bo on 1
TurnResult win = w.playTurn(); // 4 + 6 = 10: a winning six
assertTrue(win.finished());
assertFalse(win.rollsAgain()); // the game is over, so no extra roll
}
@Test
void whenAPlayerFinishesTheNextSeatStillMoves() {
// Ana: 2 (ladder to 7), Bo: 1, Cy: 1, Ana: 3 finishes. The next roll must be Bo's, not Cy's.
Game g = game(new FixedDice(2, 1, 1, 3, 4), true, ANA, BO, CY);
for (int i = 0; i < 4; i++) g.playTurn();
assertEquals(List.of(ANA), g.ranking());
assertEquals(BO, g.current());
}
@Test
void theBoardRejectsChainsAndASnakeOnTheLastSquare() {
assertThrows(IllegalArgumentException.class,
() -> new Board(10, List.of(new Jump(2, 5), new Jump(5, 8)))); // 2 -> 5 -> 8 chains
assertThrows(IllegalArgumentException.class,
() -> new Board(10, List.of(new Jump(10, 1)))); // unwinnable
}
@Test
void theSameSeedReplaysTheSameGame() {
List<Integer> a = new ArrayList<>(), b = new ArrayList<>();
for (List<Integer> out : List.of(a, b)) {
Game g = game(new RandomDice(new Random(42), 6), false, ANA, BO);
while (g.status() != Status.OVER) out.add(g.playTurn().to());
}
assertEquals(a, b);
}
@Test
void racingRollsForOneTurnPlayExactlyOnce() throws Exception {
OnlineGame g = new OnlineGame(game(new RandomDice(new Random(1), 6), false, ANA, BO));
int n = 16;
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(n);
List<Future<RollResponse>> fs = new ArrayList<>();
for (int i = 0; i < n; i++) fs.add(pool.submit(() -> { start.await(); return g.roll(ANA, 0); }));
start.countDown(); // release all sixteen at once
int ok = 0;
for (Future<RollResponse> f : fs) if (f.get().outcome() == Outcome.OK) ok++;
pool.shutdown();
assertEquals(1, ok); // every other copy came back STALE
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
Snake on the last square Rejected when the board is built Board constructor
Ladder 20 to 50 and snake 50 to 20 Rejected: a jump ends where another starts Board constructor
Two jumps on one square Rejected Board constructor
Roll goes past the end Follows the FinishRule: wasted, or bounced back FinishRule.land
Bounce back lands on a snake The snake bites; jumps come after the finishing rule Game.playTurn
Winning roll is a six No extra roll; the finish check comes first Game.playTurn
The last player in the order finishes next wraps to 0 Game.playTurn
One player left They take last place and the game ends Game.playTurn
Fewer than two players Throws when the game is created: a bug, not input Game constructor
Roll after the game ended, locally Throws: the loop should have checked Game.playTurn
Roll after the game ended, online GAME_OVER OnlineGame.roll
Double tap or retry The second copy is STALE turn number check
A listener throws The turn is applied but the online count isn’t; wrap each listener call in production Game.playTurn
Decision We chose We gave up
Snakes and ladders One Jump(from, to) value; direction tells them apart Nothing, until they behave differently
Positions In Game, not on Player Nothing; players stay plain values
Randomness A Dice passed in, seeded in production One more interface
Board rules Checked once when built; no chains Boards that chain on purpose need a cycle check
Finishing rule Strategy (FinishRule) A little indirection
Extra turn on six An if in playTurn Moves behind an interface if the rule grows
Turn report A TurnResult value per turn One small class
Screen updates Observer (GameListener), called in order Listeners must be quick and must not throw
Online play OnlineGame wrapper: lock per game plus turn number A conditional database update across servers
Go deeperGenuinely different designs, and when to pick them2 min

A precomputed array instead of a jump map. Build an array once, where next[s] is the final square after landing on s. A move becomes one array read. It’s a good choice for a bot that plays millions of games to estimate who wins. It loses the Jump value a turn reports, so the screen can’t tell a snake from a plain move without a second lookup.

Event sourcing. Store only the rolls. Positions, rankings and status are always rebuilt by replaying the rolls through a FixedDice. This is the passed-in die taken all the way, and it’s the natural design once games are saved, checked or disputed.

A Markov chain model. For analysis, snake and ladder is a Markov chain: each square is a state, and each roll moves to another state with probability 1/6. Solving it gives the expected length of a game on a board. A board designer uses it to check a new layout isn’t too long. It answers a different question than the game engine, and it only works because the board is pure data.

  • 0 to 3. The finishing rule, the extra turn on six, play to the end or stop, local or online.
  • 3 to 5. The promises. Name “every roll can be chosen by a test” as the hard one.
  • 5 to 22. Board, players and game; then the checked board and the passed-in die; then the finishing rule and turn result; then the ranking and listeners. Code playTurn and the board checks, and describe the listeners.
  • 22 to 24. Walk through the full diagram: one input, a board valid from the start, three interfaces.
  • 24 to 33. Online play: the race and the duplicate, the lock and the turn number, the server rolls.
  • 33 to 45. One extension, usually power-ups or two dice, then your own questions.

If time is short, describe the listeners in a sentence. Never skip pulling out the die: it’s what the question is really about.

Interviewerminute 33

Some squares should give a power-up instead of a jump: roll again, or skip the next player. Where does that go?

What they are testing

Whether you turn Jump into “something that happens on a square” only now that a second kind exists, and whether the change stays out of the core of Game.playTurn.

Most extensions fit into one place:

Extension What changes Touches Game?
Two dice A SumDice that rolls two and adds them No
Bounce-back rule Already a FinishRule No
Power-up squares Jump becomes one kind of SquareEffect; others say “roll again” or “skip next” One call in playTurn
Three sixes lose the turn Count sixes in a row; move the six logic behind a TurnRule Yes, once
Replays and saved games Store the seed or the roll list; replaying is feeding it to FixedDice No
Spectators A listener that sends each TurnResult to watchers No
Chained jumps on purpose Check for cycles when building; jumpAt follows the chain No
Go deeperAdding power-up squares, step by step2 min

Today, a square can hold one kind of thing: a jump. Power-ups are a second kind. That’s the moment to generalize, and not before.

  1. A SquareEffect interface. It has one method, apply(turn), that changes what happens on this turn. Jump becomes one kind of effect: it changes where the player ends up.
  2. Two new effects. RollAgain marks the turn as “roll again”, the same flag a six sets. SkipNext marks the next player to be skipped once.
  3. The board maps squares to effects. jumpAt(square) becomes effectAt(square). The board’s checks stay the same: at most one effect per square, nothing on the last square, and no jump that ends on another effect’s square.
  4. playTurn applies whatever is there. Step 3 of the turn changes from “apply at most one jump” to “apply at most one effect”. The other steps don’t change.

SkipNext needs one new piece of state in Game: a set of players who skip their next turn. When the turn passes to a skipped player, the game removes them from the set and moves on. Watch the same off-by-one as stage 4: skipping must move forward exactly one more place, and wrap at the end.

TurnResult gets an effect field in place of jump, so the screen can show a power-up differently from a ladder. The listeners don’t change at all.

Go deeperFour more follow-up questions2 min
Interviewer asksHow would you check that a board isn't too long or too short to be fun?

Simulate it. Because the board is data and the die is passed in, a script can play a hundred thousand games with a seeded die and report the average game length and its spread. For an exact answer, treat the board as a Markov chain and solve for the expected number of turns. Either way, it runs when the board is built, next to the board checks.

Interviewer asksA player disconnects mid-game. What happens?

The game waits; Game has no clock. A timer outside it applies the policy: after 30 seconds, the server rolls for the player, or removes them. It does that through OnlineGame, so the action takes the same lock and turn number as a real request. A late tap from the returning player is then stale.

Interviewer asksHow would you add a bot player?

A bot has no decisions to make in this game. So a bot is just a caller that sends roll when it’s its turn. After each turn, the server checks whether the current player is a bot and rolls for it. Game doesn’t know bots exist, just as it doesn’t know which phone a person is on.

Interviewer asksHow would you run a hundred thousand games at once?

Each game is a few hundred bytes and shares nothing, so one server holds many thousands. Across servers, send every request for a game to the server that holds it, by hashing the game’s ID. Or keep games in a database and make the turn number a condition on the update.