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.
- Read first
- Object modelingStrategy
- Related
- Tic-tac-toeChess
“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.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- 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.
- 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.
- 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.
- 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. - 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.
Scope it
Section titled “Scope it”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.
Design snake and ladder.
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 |
Build it
Section titled “Build it”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).
Stage 1: A board, some players, a die
Section titled “Stage 1: A board, some players, a die”Start simple. Two players, one board. What are your classes?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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; }}import randomfrom dataclasses import dataclass
# 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.@dataclass(frozen=True)class Player: name: str
# 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: def __init__(self, size: int, jumps: dict[int, int]): # 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. self.size = size # the last square; reaching it wins self._jumps = dict(jumps) # start square -> end square
# One lookup per move. A square with no jump is its own destination. def destination(self, square: int) -> int: return self._jumps.get(square, square)
class Game: def __init__(self, board: Board, players: list[Player]): self._board = board self._players = list(players) self._positions = [0] * len(players) # by seat; 0 is off the board self._turn = 0 # seat of the player who rolls next self.winner: Player | None = None # None until someone reaches the last square
def play_turn(self) -> None: # flaw 1: the die is built in. No test can choose a roll, and no game can be replayed. roll = random.randint(1, 6) # 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. target = min(self._positions[self._turn] + roll, self._board.size) self._positions[self._turn] = self._board.destination(target) if self._positions[self._turn] == self._board.size: self.winner = self._players[self._turn] self._turn = (self._turn + 1) % len(self._players)// 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.public sealed 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`.public sealed class Board{ public int Size { get; } // the last square; reaching it wins private readonly IReadOnlyDictionary<int, int> _jumps; // start square -> end square
public Board(int size, IDictionary<int, int> 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. Size = size; _jumps = new Dictionary<int, int>(jumps); }
// One lookup per move. A square with no jump is its own destination. public int Destination(int square) => _jumps.GetValueOrDefault(square, square);}
public sealed class Game{ private readonly Board _board; private readonly IReadOnlyList<Player> _players; private readonly int[] _positions; // _positions[i] is where _players[i] stands; 0 is off the board private int _turn; // index of the player who rolls next
// flaw 1: the die is built in. No test can choose a roll, and no game can be replayed. private readonly Random _random = new();
public Player? Winner { get; private set; } // null until someone reaches the last square
public Game(Board board, IEnumerable<Player> players) { _board = board; _players = players.ToList(); _positions = new int[_players.Count]; }
public void PlayTurn() { int roll = _random.Next(1, 7); // 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[_turn]; _turn = (_turn + 1) % _players.Count; }}// 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.type Player struct { Name string}
// 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`.type Board struct { Size int // the last square; reaching it wins jumps map[int]int // start square -> end square}
func NewBoard(size int, jumps map[int]int) *Board { // 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. return &Board{Size: size, jumps: maps.Clone(jumps)}}
// Destination is one lookup per move. A square with no jump is its own destination.func (b *Board) Destination(square int) int { if to, ok := b.jumps[square]; ok { return to } return square}
type Game struct { board *Board players []Player positions []int // positions[i] is where players[i] stands; 0 is off the board turn int // index of the player who rolls next Winner *Player}
func NewGame(board *Board, players []Player) *Game { return &Game{board: board, players: players, positions: make([]int, len(players))}}
func (g *Game) PlayTurn() { // flaw 1: the die is built in. No test can choose a roll, and no game can be replayed. roll := rand.IntN(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. target := min(g.positions[g.turn]+roll, g.board.Size) g.positions[g.turn] = g.board.Destination(target) if g.positions[g.turn] == g.board.Size { g.Winner = &g.players[g.turn] } g.turn = (g.turn + 1) % len(g.players)}// 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.interface Player { readonly name: string;}
// 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 { private readonly jumps: Map<number, number>; // start square -> end square
constructor( readonly size: number, // the last square; reaching it wins jumps: Map<number, number>, ) { // 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.jumps = new Map(jumps); }
// One lookup per move. A square with no jump is its own destination. destination(square: number): number { return this.jumps.get(square) ?? square; }}
class Game { private readonly positions: number[]; // positions[i] is where players[i] stands; 0 is off the board private turn = 0; // index of the player who rolls next winner: Player | null = null; // null until someone reaches the last square
constructor( private readonly board: Board, private readonly players: readonly Player[], ) { this.positions = players.map(() => 0); }
playTurn(): void { // flaw 1: the die is built in. No test can choose a roll, and no game can be replayed. const roll = Math.floor(Math.random() * 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. const target = Math.min(this.positions[this.turn] + roll, this.board.size); this.positions[this.turn] = this.board.destination(target); if (this.positions[this.turn] === this.board.size) this.winner = this.players[this.turn]; this.turn = (this.turn + 1) % this.players.length; }}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”How would you test that landing on square 9 sends a player to 3?
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:
RandomDicewraps 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.FixedDicehands 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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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(); }}import randomfrom collections import dequefrom dataclasses import dataclassfrom typing import Protocol
# 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.@dataclass(frozen=True)class Jump: start: int # `from` is a keyword in Python end: int
@property def is_ladder(self) -> bool: return self.end > self.start
# The squares and their jumps. Every rule is checked once, when the board is built, and the# board never changes after that.class Board: # Every rule about a legal board is checked once, here, so a Game can trust any Board it gets. def __init__(self, size: int, jumps: list[Jump]): if size < 2: raise ValueError("board needs at least 2 squares") self.size = size self._jumps: dict[int, Jump] = {} # keyed by start square for j in jumps: if not (1 <= j.start <= size and 1 <= j.end <= size): raise ValueError(f"{j} leaves the board") if j.start == j.end: raise ValueError(f"{j} goes nowhere") # A snake on the last square would make the game unwinnable. if j.start == size: raise ValueError(f"{j} starts on the last square") if j.start in self._jumps: raise ValueError(f"two jumps start on square {j.start}") self._jumps[j.start] = j # 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 j in self._jumps.values(): if j.end in self._jumps: raise ValueError(f"{j} ends on another jump")
# The jump that starts on this square, or None. def jump_at(self, square: int) -> Jump | None: return self._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.class Dice(Protocol): def roll(self) -> int: ...
# Real dice: each roll is 1 to faces.class RandomDice: # The Random is passed in: give it a seed and the whole game can be replayed exactly. def __init__(self, rng: random.Random, faces: int = 6): self._rng = rng self._faces = faces
def roll(self) -> int: return self._rng.randint(1, self._faces)
# Rolls given up front. Tests use it to walk a player onto a chosen snake.class FixedDice: def __init__(self, *rolls: int): self._rolls = deque(rolls)
def roll(self) -> int: return self._rolls.popleft()// 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.public sealed record Jump(int From, int To){ public bool IsLadder => To > From;}
// The squares and their jumps. Every rule is checked once, when the board is built, and the// board never changes after that.public sealed class Board{ public int Size { get; } private readonly Dictionary<int, Jump> _jumps = new(); // keyed by start square
// Every rule about a legal board is checked once, here, so a Game can trust any Board it gets. public Board(int size, IEnumerable<Jump> jumps) { if (size < 2) throw new ArgumentException("board needs at least 2 squares"); Size = size; foreach (var j in jumps) { if (j.From < 1 || j.From > size || j.To < 1 || j.To > size) throw new ArgumentException($"{j} leaves the board"); if (j.From == j.To) throw new ArgumentException($"{j} goes nowhere"); // A snake on the last square would make the game unwinnable. if (j.From == size) throw new ArgumentException($"{j} starts on the last square"); if (!_jumps.TryAdd(j.From, j)) throw new ArgumentException($"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. foreach (var j in _jumps.Values) if (_jumps.ContainsKey(j.To)) throw new ArgumentException($"{j} ends on another jump"); }
// The jump that starts on this square, or null. public Jump? JumpAt(int square) => _jumps.GetValueOrDefault(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.public interface IDice{ int Roll();}
// The Random is passed in: give it a seed and the whole game can be replayed exactly.public sealed class RandomDice(Random random, int faces = 6) : IDice{ public int Roll() => random.Next(1, faces + 1);}
// Rolls given up front. Tests use it to walk a player onto a chosen snake.public sealed class FixedDice(params int[] rolls) : IDice{ private readonly Queue<int> _rolls = new(rolls); public int Roll() => _rolls.Dequeue();}// Jump is a value. Whether it is a snake or a ladder follows from its direction,// so there is no Snake and Ladder type pair with nothing to tell them apart but a sign.type Jump struct { From, To int}
func (j Jump) IsLadder() bool { return j.To > j.From }
// Board is the squares and their jumps. Every rule is checked once, when the board is built,// and the board never changes after that.type Board struct { Size int jumps map[int]Jump // keyed by start square}
// NewBoard checks every rule about a legal board once, here, so a Game can trust any Board it gets.func NewBoard(size int, jumps []Jump) (*Board, error) { if size < 2 { return nil, errors.New("board needs at least 2 squares") } b := &Board{Size: size, jumps: map[int]Jump{}} for _, j := range jumps { switch { case j.From < 1 || j.From > size || j.To < 1 || j.To > size: return nil, fmt.Errorf("%v leaves the board", j) case j.From == j.To: return nil, fmt.Errorf("%v goes nowhere", j) case j.From == size: // a snake on the last square would make the game unwinnable return nil, fmt.Errorf("%v starts on the last square", j) } if _, dup := b.jumps[j.From]; dup { return nil, fmt.Errorf("two jumps start on square %d", j.From) } b.jumps[j.From] = j } // 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 _, j := range b.jumps { if _, chained := b.jumps[j.To]; chained { return nil, fmt.Errorf("%v ends on another jump", j) } } return b, nil}
// JumpAt returns the jump that starts on this square, if there is one.func (b *Board) JumpAt(square int) (Jump, bool) { j, ok := b.jumps[square] return j, ok}
// Dice is an interface so the game never decides randomness itself.// Tests script the rolls; replays and online games use a seeded source.type Dice interface { Roll() int}
// RandomDice is real dice: each roll is 1 to Faces.type RandomDice struct { rng *rand.Rand // passed in: give it a seed and the whole game can be replayed exactly Faces int}
func NewRandomDice(seed uint64, faces int) *RandomDice { return &RandomDice{rng: rand.New(rand.NewPCG(seed, seed)), Faces: faces}}
func (d *RandomDice) Roll() int { return d.rng.IntN(d.Faces) + 1 }
// FixedDice hands out rolls given up front. Tests use it to walk a player onto a chosen snake.type FixedDice struct { rolls []int}
func (d *FixedDice) Roll() int { r := d.rolls[0] d.rolls = d.rolls[1:] return r}// 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.interface Jump { readonly from: number; readonly to: number;}
const isLadder = (j: Jump): boolean => j.to > j.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 { private readonly jumps = new Map<number, Jump>(); // keyed by start square
// Every rule about a legal board is checked once, here, so a Game can trust any Board it gets. constructor( readonly size: number, jumps: readonly Jump[], ) { if (size < 2) throw new Error('board needs at least 2 squares'); for (const j of jumps) { const name = `jump ${j.from}->${j.to}`; if (j.from < 1 || j.from > size || j.to < 1 || j.to > size) throw new Error(`${name} leaves the board`); if (j.from === j.to) throw new Error(`${name} goes nowhere`); // A snake on the last square would make the game unwinnable. if (j.from === size) throw new Error(`${name} starts on the last square`); if (this.jumps.has(j.from)) throw new Error(`two jumps start on square ${j.from}`); this.jumps.set(j.from, j); } // 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 (const j of this.jumps.values()) { if (this.jumps.has(j.to)) throw new Error(`jump ${j.from}->${j.to} ends on another jump`); } }
// The jump that starts on this square, or undefined. jumpAt(square: number): Jump | undefined { return this.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 { roll(): number;}
// Real dice: each roll is 1 to faces.class RandomDice implements Dice { // The random source is passed in: give it a seed and the whole game can be replayed exactly. // Math.random can't be seeded, so a seedable generator is part of the design, not a detail. constructor( private readonly random: () => number, private readonly faces = 6, ) {}
roll(): number { return Math.floor(this.random() * this.faces) + 1; }}
// mulberry32: a tiny seedable generator, good enough for games (not for anything with money).function seeded(seed: number): () => number { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
// Rolls given up front. Tests use it to walk a player onto a chosen snake.class FixedDice implements Dice { private readonly rolls: number[];
constructor(...rolls: number[]) { this.rolls = rolls; }
roll(): number { const r = this.rolls.shift(); if (r === undefined) throw new Error('out of scripted rolls'); return r; }}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.
Stage 3: Turn rules and results
Section titled “Stage 3: Turn rules and results”Our boards need an exact roll to finish, and a six rolls again. Where does that go?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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)]; }}from dataclasses import dataclassfrom enum import Enumfrom typing import Protocol
# 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.class FinishRule(Protocol): # The square a roll reaches before any jump. Returning `start` means the player stays put. def land(self, start: int, roll: int, last: int) -> int: ...
# Must land exactly on the last square; a roll that overshoots is wasted.class ExactFinish: def land(self, start: int, roll: int, last: int) -> int: return start if start + roll > last else start + roll
# Overshoot bounces back: from 98 with a 5, count 99, 100, then back to 99, 98, 97.class BounceBack: def land(self, start: int, roll: int, last: int) -> int: target = start + roll return last - (target - last) if target > last else 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.@dataclass(frozen=True)class TurnResult: player: Player roll: int start: int end: int jump: Jump | None finished: bool rolls_again: bool
# Whether the game is still being played.class Status(Enum): IN_PROGRESS = "in progress" OVER = "over"
class Game: def __init__(self, board: Board, dice: Dice, finish: FinishRule, players: list[Player]): if len(players) < 2: raise ValueError("need at least two players") self._board = board self._dice = dice self._finish = finish self._players = list(players) self._positions = [0] * len(players) self._turn = 0 self.status = Status.IN_PROGRESS self.winner: Player | None = None
def current(self) -> Player: return self._players[self._turn]
def play_turn(self) -> TurnResult: # Locally, rolling after the end is a bug in the caller, not player input, so it raises. if self.status is Status.OVER: raise RuntimeError("game is over") p = self._players[self._turn] roll = self._dice.roll() start = self._positions[self._turn] landed = self._finish.land(start, roll, self._board.size) jump = self._board.jump_at(landed) # at most one, because the board has no chains end = jump.end if jump else landed self._positions[self._turn] = end
finished = end == self._board.size # A six earns another roll, unless it just won the game. again = roll == 6 and not finished if finished: self.status, self.winner = Status.OVER, p elif not again: self._turn = (self._turn + 1) % len(self._players) return TurnResult(p, roll, start, end, jump, finished, again)
def position_of(self, p: Player) -> int: return self._positions[self._players.index(p)]// 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.public interface IFinishRule{ // 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.public sealed class ExactFinish : IFinishRule{ public int Land(int from, int roll, int last) => from + roll > last ? from : from + roll;}
// Overshoot bounces back: from 98 with a 5, count 99, 100, then back to 99, 98, 97.public sealed class BounceBack : IFinishRule{ 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.public sealed record TurnResult(Player Player, int Roll, int From, int To, Jump? Jump, bool Finished, bool RollsAgain);
// Whether the game is still being played.public enum Status { InProgress, Over }
public sealed class Game{ private readonly Board _board; private readonly IDice _dice; private readonly IFinishRule _finish; private readonly List<Player> _players; private readonly int[] _positions; private int _turn;
public Status Status { get; private set; } = Status.InProgress; public Player? Winner { get; private set; }
public Game(Board board, IDice dice, IFinishRule finish, IEnumerable<Player> players) { _players = players.ToList(); if (_players.Count < 2) throw new ArgumentException("need at least two players"); _board = board; _dice = dice; _finish = finish; _positions = new int[_players.Count]; }
public Player Current => _players[_turn];
public 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 InvalidOperationException("game is over"); var p = _players[_turn]; int roll = _dice.Roll(); int from = _positions[_turn]; int landed = _finish.Land(from, roll, _board.Size); var jump = _board.JumpAt(landed); // at most one, because the board has no chains int to = jump?.To ?? landed; _positions[_turn] = to;
bool finished = to == _board.Size; // A six earns another roll, unless it just won the game. bool again = roll == 6 && !finished; if (finished) { Status = Status.Over; Winner = p; } else if (!again) _turn = (_turn + 1) % _players.Count; return new TurnResult(p, roll, from, to, jump, finished, again); }
public int PositionOf(Player p) => _positions[_players.IndexOf(p)];}// FinishRule decides 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.type FinishRule interface { // Land returns the square a roll reaches before any jump. Returning from means the player stays put. Land(from, roll, last int) int}
// ExactFinish: must land exactly on the last square; a roll that overshoots is wasted.type ExactFinish struct{}
func (ExactFinish) Land(from, roll, last int) int { if from+roll > last { return from } return from + roll}
// BounceBack: from 98 with a 5, count 99, 100, then back to 99, 98, 97.type BounceBack struct{}
func (BounceBack) Land(from, roll, last int) int { if t := from + roll; t > last { return last - (t - last) } else { return t }}
// TurnResult is 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.type TurnResult struct { Player Player Roll int From, To int Jump *Jump // nil when the square had no snake or ladder Finished bool RollsAgain bool}
// Status says whether the game is still being played.type Status int
const ( InProgress Status = iota Over)
var ErrGameOver = errors.New("game is over")
type Game struct { board *Board dice Dice finish FinishRule players []Player positions []int turn int Status Status Winner *Player}
func NewGame(board *Board, dice Dice, finish FinishRule, players []Player) (*Game, error) { if len(players) < 2 { return nil, errors.New("need at least two players") } return &Game{board: board, dice: dice, finish: finish, players: players, positions: make([]int, len(players))}, nil}
func (g *Game) Current() Player { return g.players[g.turn] }
func (g *Game) PlayTurn() (TurnResult, error) { if g.Status == Over { return TurnResult{}, ErrGameOver } p := g.players[g.turn] roll := g.dice.Roll() from := g.positions[g.turn] landed := g.finish.Land(from, roll, g.board.Size) to := landed var jump *Jump if j, ok := g.board.JumpAt(landed); ok { // at most one, because the board has no chains jump, to = &j, j.To } g.positions[g.turn] = to
finished := to == g.board.Size // A six earns another roll, unless it just won the game. again := roll == 6 && !finished if finished { g.Status, g.Winner = Over, &p } else if !again { g.turn = (g.turn + 1) % len(g.players) } return TurnResult{p, roll, from, to, jump, finished, again}, nil}
func (g *Game) PositionOf(p Player) int { return g.positions[slices.Index(g.players, p)] }// 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. land(from: number, roll: number, last: number): number;}
// Must land exactly on the last square; a roll that overshoots is wasted.class ExactFinish implements FinishRule { land(from: number, roll: number, last: number): number { 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 { land(from: number, roll: number, last: number): number { const 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.interface TurnResult { readonly player: Player; readonly roll: number; readonly from: number; readonly to: number; readonly jump: Jump | null; readonly finished: boolean; readonly rollsAgain: boolean;}
// Whether the game is still being played.type Status = 'in-progress' | 'over';
class Game { private readonly positions: number[]; private turn = 0; status: Status = 'in-progress'; winner: Player | null = null;
constructor( private readonly board: Board, private readonly dice: Dice, private readonly finish: FinishRule, private readonly players: readonly Player[], ) { if (players.length < 2) throw new Error('need at least two players'); this.positions = players.map(() => 0); }
current(): Player { return this.players[this.turn]; }
playTurn(): TurnResult { // Locally, rolling after the end is a bug in the caller, not player input, so it throws. if (this.status === 'over') throw new Error('game is over'); const player = this.players[this.turn]; const roll = this.dice.roll(); const from = this.positions[this.turn]; const landed = this.finish.land(from, roll, this.board.size); const jump = this.board.jumpAt(landed) ?? null; // at most one, because the board has no chains const to = jump ? jump.to : landed; this.positions[this.turn] = to;
const finished = to === this.board.size; // A six earns another roll, unless it just won the game. const rollsAgain = roll === 6 && !finished; if (finished) { this.status = 'over'; this.winner = player; } else if (!rollsAgain) { this.turn = (this.turn + 1) % this.players.length; } return { player, roll, from, to, jump, finished, rollsAgain }; }
positionOf(p: Player): number { return this.positions[this.players.indexOf(p)]; }}The order of steps inside playTurn is the design:
- Roll the die.
- Apply the finishing rule.
- Apply at most one jump.
- Save the new position.
- Decide whether the player finished.
- 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.
Stage 4: Rankings and listeners
Section titled “Stage 4: Rankings and listeners”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?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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)]; }}from typing import Protocol
# 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.class GameListener(Protocol): def on_turn(self, turn: TurnResult) -> None: ... def on_game_over(self, ranking: list[Player]) -> None: ...
# 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: def __init__(self, board: Board, dice: Dice, finish: FinishRule, players: list[Player], play_to_the_end: bool): if len(players) < 2: raise ValueError("need at least two players") self._board = board self._dice = dice self._finish = finish self._players = list(players) # seating order, never changes self._positions = [0] * len(players) # by seat self._active = list(range(len(players))) # seats still playing, in turn order self._ranking: list[Player] = [] # finishing order self._listeners: list[GameListener] = [] self._play_to_the_end = play_to_the_end # False: the first to finish wins and the game stops self._next = 0 # index into _active of the seat that rolls next self.status = Status.IN_PROGRESS
def add_listener(self, listener: GameListener) -> None: self._listeners.append(listener)
def current(self) -> Player: return self._players[self._active[self._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. def play_turn(self) -> TurnResult: if self.status is Status.OVER: raise RuntimeError("game is over") seat = self._active[self._next] p = self._players[seat] roll = self._dice.roll() start = self._positions[seat] landed = self._finish.land(start, roll, self._board.size) jump = self._board.jump_at(landed) end = jump.end if jump else landed self._positions[seat] = end
finished = end == self._board.size again = roll == 6 and not finished if finished: self._ranking.append(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. del self._active[self._next] if self._next == len(self._active): self._next = 0 if not self._play_to_the_end or len(self._active) == 1: if self._play_to_the_end: self._ranking.append(self._players[self._active[0]]) # the last one left self.status = Status.OVER elif not again: self._next = (self._next + 1) % len(self._active)
result = TurnResult(p, roll, start, end, jump, finished, again) for listener in self._listeners: listener.on_turn(result) if self.status is Status.OVER: for listener in self._listeners: listener.on_game_over(list(self._ranking)) return result
# A copy, so callers can't change the finishing order. def ranking(self) -> list[Player]: return list(self._ranking)
def position_of(self, p: Player) -> int: return self._positions[self._players.index(p)]// 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.public interface IGameListener{ void OnTurn(TurnResult turn); void OnGameOver(IReadOnlyList<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.public sealed class Game{ private readonly Board _board; private readonly IDice _dice; private readonly IFinishRule _finish; private readonly List<Player> _players; // seating order, never changes private readonly int[] _positions; // by seat private readonly List<int> _active; // seats still playing, in turn order private readonly List<Player> _ranking = []; // finishing order private readonly List<IGameListener> _listeners = []; private readonly bool _playToTheEnd; // false: the first to finish wins and the game stops private int _next; // index into _active of the seat that rolls next
public Status Status { get; private set; } = Status.InProgress;
public Game(Board board, IDice dice, IFinishRule finish, IEnumerable<Player> players, bool playToTheEnd) { _players = players.ToList(); if (_players.Count < 2) throw new ArgumentException("need at least two players"); _board = board; _dice = dice; _finish = finish; _positions = new int[_players.Count]; _active = Enumerable.Range(0, _players.Count).ToList(); _playToTheEnd = playToTheEnd; }
public void AddListener(IGameListener l) => _listeners.Add(l);
public Player Current => _players[_active[_next]];
public IReadOnlyList<Player> Ranking => _ranking.ToList();
// 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. public TurnResult PlayTurn() { if (Status == Status.Over) throw new InvalidOperationException("game is over"); int seat = _active[_next]; var p = _players[seat]; int roll = _dice.Roll(); int from = _positions[seat]; int landed = _finish.Land(from, roll, _board.Size); var jump = _board.JumpAt(landed); int to = jump?.To ?? landed; _positions[seat] = to;
bool finished = to == _board.Size; bool again = roll == 6 && !finished; if (finished) { _ranking.Add(p); // Removing shifts everyone after this seat down by one, so _next already points // at the following player. Advancing it as well would skip them. _active.RemoveAt(_next); if (_next == _active.Count) _next = 0; if (!_playToTheEnd || _active.Count == 1) { if (_playToTheEnd) _ranking.Add(_players[_active[0]]); // the last one left Status = Status.Over; } } else if (!again) { _next = (_next + 1) % _active.Count; }
var result = new TurnResult(p, roll, from, to, jump, finished, again); foreach (var l in _listeners) l.OnTurn(result); if (Status == Status.Over) foreach (var l in _listeners) l.OnGameOver(Ranking); return result; }
public int PositionOf(Player p) => _positions[_players.IndexOf(p)];}// GameListener is 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.type GameListener interface { OnTurn(t TurnResult) OnGameOver(ranking []Player)}
// Game is 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 goroutine may use it at// a time; online play wraps it in one.type Game struct { board *Board dice Dice finish FinishRule players []Player // seating order, never changes positions []int // by seat active []int // seats still playing, in turn order ranking []Player // finishing order listeners []GameListener playToTheEnd bool // false: the first to finish wins and the game stops next int // index into active of the seat that rolls next Status Status}
func NewGame(board *Board, dice Dice, finish FinishRule, players []Player, playToTheEnd bool) (*Game, error) { if len(players) < 2 { return nil, errors.New("need at least two players") } g := &Game{board: board, dice: dice, finish: finish, players: players, positions: make([]int, len(players)), playToTheEnd: playToTheEnd} for seat := range players { g.active = append(g.active, seat) } return g, nil}
func (g *Game) AddListener(l GameListener) { g.listeners = append(g.listeners, l) }
func (g *Game) Current() Player { return g.players[g.active[g.next]] }
// PlayTurn is 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.func (g *Game) PlayTurn() (TurnResult, error) { if g.Status == Over { return TurnResult{}, ErrGameOver } seat := g.active[g.next] p := g.players[seat] roll := g.dice.Roll() from := g.positions[seat] landed := g.finish.Land(from, roll, g.board.Size) to := landed var jump *Jump if j, ok := g.board.JumpAt(landed); ok { jump, to = &j, j.To } g.positions[seat] = to
finished := to == g.board.Size again := roll == 6 && !finished if finished { g.ranking = append(g.ranking, p) // Deleting shifts everyone after this seat down by one, so next already points // at the following player. Advancing it as well would skip them. g.active = slices.Delete(g.active, g.next, g.next+1) if g.next == len(g.active) { g.next = 0 } if !g.playToTheEnd || len(g.active) == 1 { if g.playToTheEnd { g.ranking = append(g.ranking, g.players[g.active[0]]) // the last one left } g.Status = Over } } else if !again { g.next = (g.next + 1) % len(g.active) }
result := TurnResult{p, roll, from, to, jump, finished, again} for _, l := range g.listeners { l.OnTurn(result) } if g.Status == Over { for _, l := range g.listeners { l.OnGameOver(slices.Clone(g.ranking)) } } return result, nil}
// Ranking returns a copy, so callers can't change the finishing order.func (g *Game) Ranking() []Player { return slices.Clone(g.ranking) }
func (g *Game) PositionOf(p Player) int { return g.positions[slices.Index(g.players, p)] }// 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 { onTurn(turn: TurnResult): void; onGameOver?(ranking: readonly Player[]): void;}
// 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 readonly positions: number[]; // by seat private readonly active: number[]; // seats still playing, in turn order private readonly finishers: Player[] = []; // finishing order private readonly listeners: GameListener[] = []; private next = 0; // index into `active` of the seat that rolls next status: Status = 'in-progress';
constructor( private readonly board: Board, private readonly dice: Dice, private readonly finish: FinishRule, private readonly players: readonly Player[], // seating order, never changes private readonly playToTheEnd: boolean, // false: the first to finish wins and the game stops ) { if (players.length < 2) throw new Error('need at least two players'); this.positions = players.map(() => 0); this.active = players.map((_, seat) => seat); }
addListener(l: GameListener): void { this.listeners.push(l); }
current(): Player { return this.players[this.active[this.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. playTurn(): TurnResult { if (this.status === 'over') throw new Error('game is over'); const seat = this.active[this.next]; const player = this.players[seat]; const roll = this.dice.roll(); const from = this.positions[seat]; const landed = this.finish.land(from, roll, this.board.size); const jump = this.board.jumpAt(landed) ?? null; const to = jump ? jump.to : landed; this.positions[seat] = to;
const finished = to === this.board.size; const rollsAgain = roll === 6 && !finished; if (finished) { this.finishers.push(player); // Removing shifts everyone after this seat down by one, so `next` already points // at the following player. Advancing it as well would skip them. this.active.splice(this.next, 1); if (this.next === this.active.length) this.next = 0; if (!this.playToTheEnd || this.active.length === 1) { if (this.playToTheEnd) this.finishers.push(this.players[this.active[0]]); // the last one left this.status = 'over'; } } else if (!rollsAgain) { this.next = (this.next + 1) % this.active.length; }
const result: TurnResult = { player, roll, from, to, jump, finished, rollsAgain }; for (const l of this.listeners) l.onTurn(result); if (this.status === 'over') for (const l of this.listeners) l.onGameOver?.([...this.finishers]); return result; }
// A copy, so callers can't change the finishing order. ranking(): Player[] { return [...this.finishers]; }
positionOf(p: Player): number { return this.positions[this.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.
- 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.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it. OnlineGame
comes from the next section.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// 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); } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import randomimport threadingfrom collections import dequefrom dataclasses import dataclassfrom enum import Enumfrom typing import Protocol
# 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.@dataclass(frozen=True)class Player: name: str
# The squares and their jumps. Every rule is checked once, when the board is built, and the# board never changes after that.class Board: # Every rule about a legal board is checked once, here, so a Game can trust any Board it gets. def __init__(self, size: int, jumps: list[Jump]): if size < 2: raise ValueError("board needs at least 2 squares") self.size = size self._jumps: dict[int, Jump] = {} # keyed by start square for j in jumps: if not (1 <= j.start <= size and 1 <= j.end <= size): raise ValueError(f"{j} leaves the board") if j.start == j.end: raise ValueError(f"{j} goes nowhere") # A snake on the last square would make the game unwinnable. if j.start == size: raise ValueError(f"{j} starts on the last square") if j.start in self._jumps: raise ValueError(f"two jumps start on square {j.start}") self._jumps[j.start] = j # 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 j in self._jumps.values(): if j.end in self._jumps: raise ValueError(f"{j} ends on another jump")
# The jump that starts on this square, or None. def jump_at(self, square: int) -> Jump | None: return self._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: def __init__(self, board: Board, dice: Dice, finish: FinishRule, players: list[Player], play_to_the_end: bool): if len(players) < 2: raise ValueError("need at least two players") self._board = board self._dice = dice self._finish = finish self._players = list(players) # seating order, never changes self._positions = [0] * len(players) # by seat self._active = list(range(len(players))) # seats still playing, in turn order self._ranking: list[Player] = [] # finishing order self._listeners: list[GameListener] = [] self._play_to_the_end = play_to_the_end # False: the first to finish wins and the game stops self._next = 0 # index into _active of the seat that rolls next self.status = Status.IN_PROGRESS
def add_listener(self, listener: GameListener) -> None: self._listeners.append(listener)
def current(self) -> Player: return self._players[self._active[self._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. def play_turn(self) -> TurnResult: if self.status is Status.OVER: raise RuntimeError("game is over") seat = self._active[self._next] p = self._players[seat] roll = self._dice.roll() start = self._positions[seat] landed = self._finish.land(start, roll, self._board.size) jump = self._board.jump_at(landed) end = jump.end if jump else landed self._positions[seat] = end
finished = end == self._board.size again = roll == 6 and not finished if finished: self._ranking.append(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. del self._active[self._next] if self._next == len(self._active): self._next = 0 if not self._play_to_the_end or len(self._active) == 1: if self._play_to_the_end: self._ranking.append(self._players[self._active[0]]) # the last one left self.status = Status.OVER elif not again: self._next = (self._next + 1) % len(self._active)
result = TurnResult(p, roll, start, end, jump, finished, again) for listener in self._listeners: listener.on_turn(result) if self.status is Status.OVER: for listener in self._listeners: listener.on_game_over(list(self._ranking)) return result
# A copy, so callers can't change the finishing order. def ranking(self) -> list[Player]: return list(self._ranking)
def position_of(self, p: Player) -> int: return self._positions[self._players.index(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.@dataclass(frozen=True)class Jump: start: int # `from` is a keyword in Python end: int
@property def is_ladder(self) -> bool: return self.end > self.start
# The die is an interface so the game never decides randomness itself.# Tests script the rolls; replays and online games use a seeded source.class Dice(Protocol): def roll(self) -> int: ...
# Real dice: each roll is 1 to faces.class RandomDice: # The Random is passed in: give it a seed and the whole game can be replayed exactly. def __init__(self, rng: random.Random, faces: int = 6): self._rng = rng self._faces = faces
def roll(self) -> int: return self._rng.randint(1, self._faces)
# Rolls given up front. Tests use it to walk a player onto a chosen snake.class FixedDice: def __init__(self, *rolls: int): self._rolls = deque(rolls)
def roll(self) -> int: return self._rolls.popleft()
# 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.class FinishRule(Protocol): # The square a roll reaches before any jump. Returning `start` means the player stays put. def land(self, start: int, roll: int, last: int) -> int: ...
# Must land exactly on the last square; a roll that overshoots is wasted.class ExactFinish: def land(self, start: int, roll: int, last: int) -> int: return start if start + roll > last else start + 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.@dataclass(frozen=True)class TurnResult: player: Player roll: int start: int end: int jump: Jump | None finished: bool rolls_again: bool
# Whether the game is still being played.class Status(Enum): IN_PROGRESS = "in progress" OVER = "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.class GameListener(Protocol): def on_turn(self, turn: TurnResult) -> None: ... def on_game_over(self, ranking: list[Player]) -> None: ...
# 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.class Outcome(Enum): OK = "ok" STALE = "stale" GAME_OVER = "game over" NOT_YOUR_TURN = "not your turn"
# turn_number is always the game's current count, so a client that got STALE knows where it is.@dataclass(frozen=True)class RollResponse: outcome: Outcome turn: TurnResult | None turn_number: int
# 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: def __init__(self, game: Game): self._game = game self._lock = threading.Lock() self._turns_played = 0
# 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. def roll(self, p: Player, turn_number: int) -> RollResponse: # Checks and the turn itself run under one lock, so two requests can't both pass # "is it your turn?" before either has moved. The GIL does not make this atomic: # a thread can be switched out between the check and the write. with self._lock: if turn_number != self._turns_played: return RollResponse(Outcome.STALE, None, self._turns_played) if self._game.status is Status.OVER: return RollResponse(Outcome.GAME_OVER, None, self._turns_played) if self._game.current() != p: return RollResponse(Outcome.NOT_YOUR_TURN, None, self._turns_played) # The server rolls, never the client: a client that sends its own roll can send a # six. t = self._game.play_turn() self._turns_played += 1 return RollResponse(Outcome.OK, t, self._turns_played)
# Readers take the lock too, so they see a turn's writes all or none. def position_of(self, p: Player) -> int: with self._lock: return self._game.position_of(p)// The complete design, built from every stage on this page.
// 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.public sealed 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.public sealed class Board{ public int Size { get; } private readonly Dictionary<int, Jump> _jumps = new(); // keyed by start square
// Every rule about a legal board is checked once, here, so a Game can trust any Board it gets. public Board(int size, IEnumerable<Jump> jumps) { if (size < 2) throw new ArgumentException("board needs at least 2 squares"); Size = size; foreach (var j in jumps) { if (j.From < 1 || j.From > size || j.To < 1 || j.To > size) throw new ArgumentException($"{j} leaves the board"); if (j.From == j.To) throw new ArgumentException($"{j} goes nowhere"); // A snake on the last square would make the game unwinnable. if (j.From == size) throw new ArgumentException($"{j} starts on the last square"); if (!_jumps.TryAdd(j.From, j)) throw new ArgumentException($"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. foreach (var j in _jumps.Values) if (_jumps.ContainsKey(j.To)) throw new ArgumentException($"{j} ends on another jump"); }
// The jump that starts on this square, or null. public Jump? JumpAt(int square) => _jumps.GetValueOrDefault(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.public sealed class Game{ private readonly Board _board; private readonly IDice _dice; private readonly IFinishRule _finish; private readonly List<Player> _players; // seating order, never changes private readonly int[] _positions; // by seat private readonly List<int> _active; // seats still playing, in turn order private readonly List<Player> _ranking = []; // finishing order private readonly List<IGameListener> _listeners = []; private readonly bool _playToTheEnd; // false: the first to finish wins and the game stops private int _next; // index into _active of the seat that rolls next
public Status Status { get; private set; } = Status.InProgress;
public Game(Board board, IDice dice, IFinishRule finish, IEnumerable<Player> players, bool playToTheEnd) { _players = players.ToList(); if (_players.Count < 2) throw new ArgumentException("need at least two players"); _board = board; _dice = dice; _finish = finish; _positions = new int[_players.Count]; _active = Enumerable.Range(0, _players.Count).ToList(); _playToTheEnd = playToTheEnd; }
public void AddListener(IGameListener l) => _listeners.Add(l);
public Player Current => _players[_active[_next]];
public IReadOnlyList<Player> Ranking => _ranking.ToList();
// 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. public TurnResult PlayTurn() { if (Status == Status.Over) throw new InvalidOperationException("game is over"); int seat = _active[_next]; var p = _players[seat]; int roll = _dice.Roll(); int from = _positions[seat]; int landed = _finish.Land(from, roll, _board.Size); var jump = _board.JumpAt(landed); int to = jump?.To ?? landed; _positions[seat] = to;
bool finished = to == _board.Size; bool again = roll == 6 && !finished; if (finished) { _ranking.Add(p); // Removing shifts everyone after this seat down by one, so _next already points // at the following player. Advancing it as well would skip them. _active.RemoveAt(_next); if (_next == _active.Count) _next = 0; if (!_playToTheEnd || _active.Count == 1) { if (_playToTheEnd) _ranking.Add(_players[_active[0]]); // the last one left Status = Status.Over; } } else if (!again) { _next = (_next + 1) % _active.Count; }
var result = new TurnResult(p, roll, from, to, jump, finished, again); foreach (var l in _listeners) l.OnTurn(result); if (Status == Status.Over) foreach (var l in _listeners) l.OnGameOver(Ranking); return result; }
public int PositionOf(Player p) => _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.public sealed record Jump(int From, int To){ public bool IsLadder => 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.public interface IDice{ int Roll();}
// The Random is passed in: give it a seed and the whole game can be replayed exactly.public sealed class RandomDice(Random random, int faces = 6) : IDice{ public int Roll() => random.Next(1, faces + 1);}
// Rolls given up front. Tests use it to walk a player onto a chosen snake.public sealed class FixedDice(params int[] rolls) : IDice{ private readonly Queue<int> _rolls = new(rolls); public int Roll() => _rolls.Dequeue();}
// 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.public interface IFinishRule{ // 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.public sealed class ExactFinish : IFinishRule{ public int Land(int from, int roll, int last) => 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.public sealed record TurnResult(Player Player, int Roll, int From, int To, Jump? Jump, bool Finished, bool RollsAgain);
// Whether the game is still being played.public enum Status { InProgress, 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.public interface IGameListener{ void OnTurn(TurnResult turn); void OnGameOver(IReadOnlyList<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.public enum Outcome { Ok, Stale, GameOver, NotYourTurn }
// TurnNumber is always the game's current count, so a client that got Stale knows where it is.public sealed 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.public sealed class OnlineGame(Game game){ private readonly Lock _lock = new(); // private, so no outside code can lock on us and stall play private int _turnsPlayed;
// 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. public RollResponse Roll(Player p, int turnNumber) { // Checks and the turn itself run under one lock, so two requests can't both pass // "is it your turn?" before either has moved. lock (_lock) { if (turnNumber != _turnsPlayed) return new(Outcome.Stale, null, _turnsPlayed); if (game.Status == Status.Over) return new(Outcome.GameOver, null, _turnsPlayed); if (game.Current != p) return new(Outcome.NotYourTurn, null, _turnsPlayed); // The server rolls, never the client: a client that sends its own roll can send a // six. var t = game.PlayTurn(); _turnsPlayed++; return new(Outcome.Ok, t, _turnsPlayed); } }
// Readers take the lock too, so they see a turn's writes all or none. public int PositionOf(Player p) { lock (_lock) return game.PositionOf(p); }}// The complete design, built from every stage on this page.
package snakeandladder
import ( "errors" "fmt" "math/rand/v2" "slices" "sync")
// 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.type Player struct { Name string}
// Jump is a value. Whether it is a snake or a ladder follows from its direction,// so there is no Snake and Ladder type pair with nothing to tell them apart but a sign.type Jump struct { From, To int}
func (j Jump) IsLadder() bool { return j.To > j.From }
// Board is the squares and their jumps. Every rule is checked once, when the board is built,// and the board never changes after that.type Board struct { Size int jumps map[int]Jump // keyed by start square}
// NewBoard checks every rule about a legal board once, here, so a Game can trust any Board it gets.func NewBoard(size int, jumps []Jump) (*Board, error) { if size < 2 { return nil, errors.New("board needs at least 2 squares") } b := &Board{Size: size, jumps: map[int]Jump{}} for _, j := range jumps { switch { case j.From < 1 || j.From > size || j.To < 1 || j.To > size: return nil, fmt.Errorf("%v leaves the board", j) case j.From == j.To: return nil, fmt.Errorf("%v goes nowhere", j) case j.From == size: // a snake on the last square would make the game unwinnable return nil, fmt.Errorf("%v starts on the last square", j) } if _, dup := b.jumps[j.From]; dup { return nil, fmt.Errorf("two jumps start on square %d", j.From) } b.jumps[j.From] = j } // 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 _, j := range b.jumps { if _, chained := b.jumps[j.To]; chained { return nil, fmt.Errorf("%v ends on another jump", j) } } return b, nil}
// JumpAt returns the jump that starts on this square, if there is one.func (b *Board) JumpAt(square int) (Jump, bool) { j, ok := b.jumps[square] return j, ok}
// Dice is an interface so the game never decides randomness itself.// Tests script the rolls; replays and online games use a seeded source.type Dice interface { Roll() int}
// RandomDice is real dice: each roll is 1 to Faces.type RandomDice struct { rng *rand.Rand // passed in: give it a seed and the whole game can be replayed exactly Faces int}
func NewRandomDice(seed uint64, faces int) *RandomDice { return &RandomDice{rng: rand.New(rand.NewPCG(seed, seed)), Faces: faces}}
func (d *RandomDice) Roll() int { return d.rng.IntN(d.Faces) + 1 }
// FixedDice hands out rolls given up front. Tests use it to walk a player onto a chosen snake.type FixedDice struct { rolls []int}
func (d *FixedDice) Roll() int { r := d.rolls[0] d.rolls = d.rolls[1:] return r}
// FinishRule decides 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.type FinishRule interface { // Land returns the square a roll reaches before any jump. Returning from means the player stays put. Land(from, roll, last int) int}
// ExactFinish: must land exactly on the last square; a roll that overshoots is wasted.type ExactFinish struct{}
func (ExactFinish) Land(from, roll, last int) int { if from+roll > last { return from } return from + roll}
// TurnResult is 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.type TurnResult struct { Player Player Roll int From, To int Jump *Jump // nil when the square had no snake or ladder Finished bool RollsAgain bool}
// Status says whether the game is still being played.type Status int
const ( InProgress Status = iota Over)
var ErrGameOver = errors.New("game is over")
// GameListener is 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.type GameListener interface { OnTurn(t TurnResult) OnGameOver(ranking []Player)}
// Game is 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 goroutine may use it at// a time; online play wraps it in one.type Game struct { board *Board dice Dice finish FinishRule players []Player // seating order, never changes positions []int // by seat active []int // seats still playing, in turn order ranking []Player // finishing order listeners []GameListener playToTheEnd bool // false: the first to finish wins and the game stops next int // index into active of the seat that rolls next Status Status}
func NewGame(board *Board, dice Dice, finish FinishRule, players []Player, playToTheEnd bool) (*Game, error) { if len(players) < 2 { return nil, errors.New("need at least two players") } g := &Game{board: board, dice: dice, finish: finish, players: players, positions: make([]int, len(players)), playToTheEnd: playToTheEnd} for seat := range players { g.active = append(g.active, seat) } return g, nil}
func (g *Game) AddListener(l GameListener) { g.listeners = append(g.listeners, l) }
func (g *Game) Current() Player { return g.players[g.active[g.next]] }
// PlayTurn is 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.func (g *Game) PlayTurn() (TurnResult, error) { if g.Status == Over { return TurnResult{}, ErrGameOver } seat := g.active[g.next] p := g.players[seat] roll := g.dice.Roll() from := g.positions[seat] landed := g.finish.Land(from, roll, g.board.Size) to := landed var jump *Jump if j, ok := g.board.JumpAt(landed); ok { jump, to = &j, j.To } g.positions[seat] = to
finished := to == g.board.Size again := roll == 6 && !finished if finished { g.ranking = append(g.ranking, p) // Deleting shifts everyone after this seat down by one, so next already points // at the following player. Advancing it as well would skip them. g.active = slices.Delete(g.active, g.next, g.next+1) if g.next == len(g.active) { g.next = 0 } if !g.playToTheEnd || len(g.active) == 1 { if g.playToTheEnd { g.ranking = append(g.ranking, g.players[g.active[0]]) // the last one left } g.Status = Over } } else if !again { g.next = (g.next + 1) % len(g.active) }
result := TurnResult{p, roll, from, to, jump, finished, again} for _, l := range g.listeners { l.OnTurn(result) } if g.Status == Over { for _, l := range g.listeners { l.OnGameOver(slices.Clone(g.ranking)) } } return result, nil}
// Ranking returns a copy, so callers can't change the finishing order.func (g *Game) Ranking() []Player { return slices.Clone(g.ranking) }
func (g *Game) PositionOf(p Player) int { return g.positions[slices.Index(g.players, p)] }
// 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 each is a named error the client// handles.var ( ErrStale = errors.New("stale turn number") ErrNotYourTurn = errors.New("not your turn"))
// OnlineGame is the server's handle on one game. Game is a single-threaded rules engine; this// type adds the two things online play needs: one request at a time, and// turn numbers. Only OnlineGame holds the Game, so no caller can reach it without the lock.type OnlineGame struct { mu sync.Mutex game *Game turnsPlayed int}
func NewOnlineGame(g *Game) *OnlineGame { return &OnlineGame{game: g} }
// Roll returns the turn's result and the game's turn count, which a client that got// ErrStale uses to catch up.func (o *OnlineGame) Roll(p Player, turnNumber int) (TurnResult, int, error) { // Checks and the turn itself run under one lock, so two requests can't both pass // "is it your turn?" before either has moved. defer releases it on every return path. o.mu.Lock() defer o.mu.Unlock() switch { case turnNumber != o.turnsPlayed: return TurnResult{}, o.turnsPlayed, ErrStale case o.game.Status == Over: return TurnResult{}, o.turnsPlayed, ErrGameOver case o.game.Current() != p: return TurnResult{}, o.turnsPlayed, ErrNotYourTurn } // The server rolls, never the client: a client that sends its own roll can send a six. t, err := o.game.PlayTurn() if err != nil { return TurnResult{}, o.turnsPlayed, err } o.turnsPlayed++ return t, o.turnsPlayed, nil}
// PositionOf takes the lock too, so readers see a turn's writes all or none.func (o *OnlineGame) PositionOf(p Player) int { o.mu.Lock() defer o.mu.Unlock() return o.game.PositionOf(p)}// The complete design, built from every stage on this page.
// 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.interface Player { readonly name: string;}
// The squares and their jumps. Every rule is checked once, when the board is built, and the// board never changes after that.class Board { private readonly jumps = new Map<number, Jump>(); // keyed by start square
// Every rule about a legal board is checked once, here, so a Game can trust any Board it gets. constructor( readonly size: number, jumps: readonly Jump[], ) { if (size < 2) throw new Error('board needs at least 2 squares'); for (const j of jumps) { const name = `jump ${j.from}->${j.to}`; if (j.from < 1 || j.from > size || j.to < 1 || j.to > size) throw new Error(`${name} leaves the board`); if (j.from === j.to) throw new Error(`${name} goes nowhere`); // A snake on the last square would make the game unwinnable. if (j.from === size) throw new Error(`${name} starts on the last square`); if (this.jumps.has(j.from)) throw new Error(`two jumps start on square ${j.from}`); this.jumps.set(j.from, j); } // 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 (const j of this.jumps.values()) { if (this.jumps.has(j.to)) throw new Error(`jump ${j.from}->${j.to} ends on another jump`); } }
// The jump that starts on this square, or undefined. jumpAt(square: number): Jump | undefined { return this.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 readonly positions: number[]; // by seat private readonly active: number[]; // seats still playing, in turn order private readonly finishers: Player[] = []; // finishing order private readonly listeners: GameListener[] = []; private next = 0; // index into `active` of the seat that rolls next status: Status = 'in-progress';
constructor( private readonly board: Board, private readonly dice: Dice, private readonly finish: FinishRule, private readonly players: readonly Player[], // seating order, never changes private readonly playToTheEnd: boolean, // false: the first to finish wins and the game stops ) { if (players.length < 2) throw new Error('need at least two players'); this.positions = players.map(() => 0); this.active = players.map((_, seat) => seat); }
addListener(l: GameListener): void { this.listeners.push(l); }
current(): Player { return this.players[this.active[this.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. playTurn(): TurnResult { if (this.status === 'over') throw new Error('game is over'); const seat = this.active[this.next]; const player = this.players[seat]; const roll = this.dice.roll(); const from = this.positions[seat]; const landed = this.finish.land(from, roll, this.board.size); const jump = this.board.jumpAt(landed) ?? null; const to = jump ? jump.to : landed; this.positions[seat] = to;
const finished = to === this.board.size; const rollsAgain = roll === 6 && !finished; if (finished) { this.finishers.push(player); // Removing shifts everyone after this seat down by one, so `next` already points // at the following player. Advancing it as well would skip them. this.active.splice(this.next, 1); if (this.next === this.active.length) this.next = 0; if (!this.playToTheEnd || this.active.length === 1) { if (this.playToTheEnd) this.finishers.push(this.players[this.active[0]]); // the last one left this.status = 'over'; } } else if (!rollsAgain) { this.next = (this.next + 1) % this.active.length; }
const result: TurnResult = { player, roll, from, to, jump, finished, rollsAgain }; for (const l of this.listeners) l.onTurn(result); if (this.status === 'over') for (const l of this.listeners) l.onGameOver?.([...this.finishers]); return result; }
// A copy, so callers can't change the finishing order. ranking(): Player[] { return [...this.finishers]; }
positionOf(p: Player): number { return this.positions[this.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.interface Jump { readonly from: number; readonly to: number;}
const isLadder = (j: Jump): boolean => j.to > j.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 { roll(): number;}
// Real dice: each roll is 1 to faces.class RandomDice implements Dice { // The random source is passed in: give it a seed and the whole game can be replayed exactly. // Math.random can't be seeded, so a seedable generator is part of the design, not a detail. constructor( private readonly random: () => number, private readonly faces = 6, ) {}
roll(): number { return Math.floor(this.random() * this.faces) + 1; }}
// mulberry32: a tiny seedable generator, good enough for games (not for anything with money).function seeded(seed: number): () => number { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
// Rolls given up front. Tests use it to walk a player onto a chosen snake.class FixedDice implements Dice { private readonly rolls: number[];
constructor(...rolls: number[]) { this.rolls = rolls; }
roll(): number { const r = this.rolls.shift(); if (r === undefined) throw new Error('out of scripted rolls'); return r; }}
// 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. land(from: number, roll: number, last: number): number;}
// Must land exactly on the last square; a roll that overshoots is wasted.class ExactFinish implements FinishRule { land(from: number, roll: number, last: number): number { 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.interface TurnResult { readonly player: Player; readonly roll: number; readonly from: number; readonly to: number; readonly jump: Jump | null; readonly finished: boolean; readonly rollsAgain: boolean;}
// Whether the game is still being played.type 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 { onTurn(turn: TurnResult): void; onGameOver?(ranking: readonly Player[]): void;}
// 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.type RollResponse = | { outcome: 'ok'; turn: TurnResult; turnNumber: number } | { outcome: 'stale' | 'game-over' | 'not-your-turn'; turnNumber: number };
// The server's handle on one game. Game is the rules engine; this class adds// the turn number online play needs. Only this class holds the Game, so every request goes// through these checks.class OnlineGame { private turnsPlayed = 0;
constructor(private readonly game: Game) {}
// No lock: in one Node process nothing can interrupt roll() while it runs, so two requests // can't both pass "is it your turn?". The turn number is still needed: a double tap or a // retry sends the same number twice, and the second copy must be turned away. roll(p: Player, turnNumber: number): RollResponse { if (turnNumber !== this.turnsPlayed) return { outcome: 'stale', turnNumber: this.turnsPlayed }; if (this.game.status === 'over') return { outcome: 'game-over', turnNumber: this.turnsPlayed }; if (this.game.current().name !== p.name) return { outcome: 'not-your-turn', turnNumber: this.turnsPlayed }; // The server rolls, never the client: a client that sends its own roll can send a six. const turn = this.game.playTurn(); this.turnsPlayed++; // If an `await` crept in above this line (say, saving to a database first), another request // could run in the gap and the race would be back. Keep roll() synchronous. return { outcome: 'ok', turn, turnNumber: this.turnsPlayed }; }
positionOf(p: Player): number { return this.game.positionOf(p); }}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
Gamenever 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,
Gameis a simple front door with one method,playTurn(a facade).
Stress it
Section titled “Stress it”So far, one caller plays the game. Now it’s online: one game on a server, and each player’s phone sends requests.
A double tap after a six
Section titled “A double tap after a six”It’s online now. A player taps “roll” twice quickly, and both requests reach the server. What happens?
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.
- Color key
- Facade
// 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); } }}import threadingfrom dataclasses import dataclassfrom enum import Enum
# 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.class Outcome(Enum): OK = "ok" STALE = "stale" GAME_OVER = "game over" NOT_YOUR_TURN = "not your turn"
# turn_number is always the game's current count, so a client that got STALE knows where it is.@dataclass(frozen=True)class RollResponse: outcome: Outcome turn: TurnResult | None turn_number: int
# 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: def __init__(self, game: Game): self._game = game self._lock = threading.Lock() self._turns_played = 0
# 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. def roll(self, p: Player, turn_number: int) -> RollResponse: # Checks and the turn itself run under one lock, so two requests can't both pass # "is it your turn?" before either has moved. The GIL does not make this atomic: # a thread can be switched out between the check and the write. with self._lock: if turn_number != self._turns_played: return RollResponse(Outcome.STALE, None, self._turns_played) if self._game.status is Status.OVER: return RollResponse(Outcome.GAME_OVER, None, self._turns_played) if self._game.current() != p: return RollResponse(Outcome.NOT_YOUR_TURN, None, self._turns_played) # The server rolls, never the client: a client that sends its own roll can send a # six. t = self._game.play_turn() self._turns_played += 1 return RollResponse(Outcome.OK, t, self._turns_played)
# Readers take the lock too, so they see a turn's writes all or none. def position_of(self, p: Player) -> int: with self._lock: return self._game.position_of(p)// 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.public enum Outcome { Ok, Stale, GameOver, NotYourTurn }
// TurnNumber is always the game's current count, so a client that got Stale knows where it is.public sealed 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.public sealed class OnlineGame(Game game){ private readonly Lock _lock = new(); // private, so no outside code can lock on us and stall play private int _turnsPlayed;
// 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. public RollResponse Roll(Player p, int turnNumber) { // Checks and the turn itself run under one lock, so two requests can't both pass // "is it your turn?" before either has moved. lock (_lock) { if (turnNumber != _turnsPlayed) return new(Outcome.Stale, null, _turnsPlayed); if (game.Status == Status.Over) return new(Outcome.GameOver, null, _turnsPlayed); if (game.Current != p) return new(Outcome.NotYourTurn, null, _turnsPlayed); // The server rolls, never the client: a client that sends its own roll can send a // six. var t = game.PlayTurn(); _turnsPlayed++; return new(Outcome.Ok, t, _turnsPlayed); } }
// Readers take the lock too, so they see a turn's writes all or none. public int PositionOf(Player p) { lock (_lock) return game.PositionOf(p); }}// 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 each is a named error the client// handles.var ( ErrStale = errors.New("stale turn number") ErrNotYourTurn = errors.New("not your turn"))
// OnlineGame is the server's handle on one game. Game is a single-threaded rules engine; this// type adds the two things online play needs: one request at a time, and// turn numbers. Only OnlineGame holds the Game, so no caller can reach it without the lock.type OnlineGame struct { mu sync.Mutex game *Game turnsPlayed int}
func NewOnlineGame(g *Game) *OnlineGame { return &OnlineGame{game: g} }
// Roll returns the turn's result and the game's turn count, which a client that got// ErrStale uses to catch up.func (o *OnlineGame) Roll(p Player, turnNumber int) (TurnResult, int, error) { // Checks and the turn itself run under one lock, so two requests can't both pass // "is it your turn?" before either has moved. defer releases it on every return path. o.mu.Lock() defer o.mu.Unlock() switch { case turnNumber != o.turnsPlayed: return TurnResult{}, o.turnsPlayed, ErrStale case o.game.Status == Over: return TurnResult{}, o.turnsPlayed, ErrGameOver case o.game.Current() != p: return TurnResult{}, o.turnsPlayed, ErrNotYourTurn } // The server rolls, never the client: a client that sends its own roll can send a six. t, err := o.game.PlayTurn() if err != nil { return TurnResult{}, o.turnsPlayed, err } o.turnsPlayed++ return t, o.turnsPlayed, nil}
// PositionOf takes the lock too, so readers see a turn's writes all or none.func (o *OnlineGame) PositionOf(p Player) int { o.mu.Lock() defer o.mu.Unlock() return o.game.PositionOf(p)}// 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.type RollResponse = | { outcome: 'ok'; turn: TurnResult; turnNumber: number } | { outcome: 'stale' | 'game-over' | 'not-your-turn'; turnNumber: number };
// The server's handle on one game. Game is the rules engine; this class adds// the turn number online play needs. Only this class holds the Game, so every request goes// through these checks.class OnlineGame { private turnsPlayed = 0;
constructor(private readonly game: Game) {}
// No lock: in one Node process nothing can interrupt roll() while it runs, so two requests // can't both pass "is it your turn?". The turn number is still needed: a double tap or a // retry sends the same number twice, and the second copy must be turned away. roll(p: Player, turnNumber: number): RollResponse { if (turnNumber !== this.turnsPlayed) return { outcome: 'stale', turnNumber: this.turnsPlayed }; if (this.game.status === 'over') return { outcome: 'game-over', turnNumber: this.turnsPlayed }; if (this.game.current().name !== p.name) return { outcome: 'not-your-turn', turnNumber: this.turnsPlayed }; // The server rolls, never the client: a client that sends its own roll can send a six. const turn = this.game.playTurn(); this.turnsPlayed++; // If an `await` crept in above this line (say, saving to a database first), another request // could run in the gap and the race would be back. Keep roll() synchronous. return { outcome: 'ok', turn, turnNumber: this.turnsPlayed }; }
positionOf(p: Player): number { return this.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.
How you’d prove it works
Section titled “How you’d prove it works”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.
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);}
@Testvoid 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}
@Testvoid 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}
@Testvoid 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}
@Testvoid 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());}
@Testvoid 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}
@Testvoid 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);}
@Testvoid 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}import randomimport threadingfrom concurrent.futures import ThreadPoolExecutor
import pytest
ANA, BO, CY = Player("Ana"), Player("Bo"), Player("Cy")
# Ten squares, a ladder from 2 to 7 and a snake from 9 to 3. Small enough to reason about.def board() -> Board: return Board(10, [Jump(2, 7), Jump(9, 3)])
def game(dice: Dice, to_the_end: bool, *players: Player) -> Game: return Game(board(), dice, ExactFinish(), list(players), to_the_end)
def test_a_ladder_climbs_and_a_snake_bites(): g = game(FixedDice(2, 4, 2), False, ANA, BO) up = g.play_turn() # Ana rolls 2: lands on the ladder assert up.end == 7 and up.jump.is_ladder g.play_turn() # Bo rolls 4 assert g.play_turn().end == 3 # Ana rolls 2: 7 + 2 = 9, the snake
def test_an_overshoot_is_wasted_under_exact_finish(): g = game(FixedDice(2, 1, 5), False, ANA, BO) g.play_turn(), g.play_turn() # Ana on 7, Bo on 1 assert g.play_turn().end == 7 # Ana needs 3, rolls 5 assert g.current() == BO # and the turn still passes
def test_a_six_rolls_again_but_not_after_a_win(): g = game(FixedDice(6), False, ANA, BO) assert g.play_turn().rolls_again assert g.current() == ANA # Ana again w = game(FixedDice(4, 1, 6), False, ANA, BO) w.play_turn(), w.play_turn() # Ana on 4, Bo on 1 win = w.play_turn() # 4 + 6 = 10: a winning six assert win.finished and not win.rolls_again # the game is over, so no extra roll
def test_when_a_player_finishes_the_next_seat_still_moves(): # Ana: 2 (ladder to 7), Bo: 1, Cy: 1, Ana: 3 finishes. The next roll must be Bo's, not Cy's. g = game(FixedDice(2, 1, 1, 3, 4), True, ANA, BO, CY) for _ in range(4): g.play_turn() assert g.ranking() == [ANA] assert g.current() == BO
def test_the_board_rejects_chains_and_a_snake_on_the_last_square(): with pytest.raises(ValueError): Board(10, [Jump(2, 5), Jump(5, 8)]) # 2 -> 5 -> 8 chains with pytest.raises(ValueError): Board(10, [Jump(10, 1)]) # unwinnable
def test_the_same_seed_replays_the_same_game(): runs = [] for _ in range(2): g = game(RandomDice(random.Random(42)), False, ANA, BO) ends = [] while g.status is not Status.OVER: ends.append(g.play_turn().end) runs.append(ends) assert runs[0] == runs[1]
def test_racing_rolls_for_one_turn_play_exactly_once(): g = OnlineGame(game(RandomDice(random.Random(1)), False, ANA, BO)) start = threading.Barrier(16) # releases once all sixteen threads are waiting
def attempt(_: int) -> RollResponse: start.wait() return g.roll(ANA, 0)
with ThreadPoolExecutor(max_workers=16) as pool: results = list(pool.map(attempt, range(16))) assert sum(r.outcome is Outcome.OK for r in results) == 1 # every other copy came back STALEprivate static readonly Player Ana = new("Ana"), Bo = new("Bo"), Cy = new("Cy");
// Ten squares, a ladder from 2 to 7 and a snake from 9 to 3. Small enough to reason about.private static Board TestBoard() => new(10, [new Jump(2, 7), new Jump(9, 3)]);
private static Game NewGame(IDice dice, bool toTheEnd, params Player[] ps) => new(TestBoard(), dice, new ExactFinish(), ps, toTheEnd);
[Fact]public void ALadderClimbsAndASnakeBites(){ var g = NewGame(new FixedDice(2, 4, 2), false, Ana, Bo); var up = g.PlayTurn(); // Ana rolls 2: lands on the ladder Assert.Equal(7, up.To); Assert.True(up.Jump!.IsLadder); g.PlayTurn(); // Bo rolls 4 Assert.Equal(3, g.PlayTurn().To); // Ana rolls 2: 7 + 2 = 9, the snake}
[Fact]public void AnOvershootIsWastedUnderExactFinish(){ var g = NewGame(new FixedDice(2, 1, 5), false, Ana, Bo); g.PlayTurn(); g.PlayTurn(); // Ana on 7, Bo on 1 Assert.Equal(7, g.PlayTurn().To); // Ana needs 3, rolls 5 Assert.Equal(Bo, g.Current); // and the turn still passes}
[Fact]public void ASixRollsAgainButNotAfterAWin(){ var g = NewGame(new FixedDice(6), false, Ana, Bo); Assert.True(g.PlayTurn().RollsAgain); Assert.Equal(Ana, g.Current); // Ana again var w = NewGame(new FixedDice(4, 1, 6), false, Ana, Bo); w.PlayTurn(); w.PlayTurn(); // Ana on 4, Bo on 1 var win = w.PlayTurn(); // 4 + 6 = 10: a winning six Assert.True(win.Finished); Assert.False(win.RollsAgain); // the game is over, so no extra roll}
[Fact]public void WhenAPlayerFinishesTheNextSeatStillMoves(){ // Ana: 2 (ladder to 7), Bo: 1, Cy: 1, Ana: 3 finishes. The next roll must be Bo's, not Cy's. var g = NewGame(new FixedDice(2, 1, 1, 3, 4), true, Ana, Bo, Cy); for (int i = 0; i < 4; i++) g.PlayTurn(); Assert.Equal(new[] { Ana }, g.Ranking); Assert.Equal(Bo, g.Current);}
[Fact]public void TheBoardRejectsChainsAndASnakeOnTheLastSquare(){ Assert.Throws<ArgumentException>(() => new Board(10, [new Jump(2, 5), new Jump(5, 8)])); // chains Assert.Throws<ArgumentException>(() => new Board(10, [new Jump(10, 1)])); // unwinnable}
[Fact]public void TheSameSeedReplaysTheSameGame(){ List<int> Run() { var g = NewGame(new RandomDice(new Random(42)), false, Ana, Bo); var ends = new List<int>(); while (g.Status != Status.Over) ends.Add(g.PlayTurn().To); return ends; } Assert.Equal(Run(), Run());}
[Fact]public async Task RacingRollsForOneTurnPlayExactlyOnce(){ var g = new OnlineGame(NewGame(new RandomDice(new Random(1)), false, Ana, Bo)); var start = new TaskCompletionSource(); var tries = Enumerable.Range(0, 16) .Select(_ => Task.Run(async () => { await start.Task; return g.Roll(Ana, 0); })) .ToArray(); start.SetResult(); // release all sixteen at once var results = await Task.WhenAll(tries); Assert.Single(results, r => r.Outcome == Outcome.Ok); // every other copy came back Stale}var ana, bo, cy = Player{"Ana"}, Player{"Bo"}, Player{"Cy"}
// newTestGame uses ten squares, a ladder from 2 to 7 and a snake from 9 to 3.// Small enough to reason about.func newTestGame(t *testing.T, dice Dice, toTheEnd bool, ps ...Player) *Game { t.Helper() b, err := NewBoard(10, []Jump{{2, 7}, {9, 3}}) if err != nil { t.Fatal(err) } g, err := NewGame(b, dice, ExactFinish{}, ps, toTheEnd) if err != nil { t.Fatal(err) } return g}
func turn(t *testing.T, g *Game) TurnResult { t.Helper() r, err := g.PlayTurn() if err != nil { t.Fatal(err) } return r}
func TestALadderClimbsAndASnakeBites(t *testing.T) { g := newTestGame(t, &FixedDice{[]int{2, 4, 2}}, false, ana, bo) if up := turn(t, g); up.To != 7 || !up.Jump.IsLadder() { // Ana rolls 2: lands on the ladder t.Fatalf("got %+v", up) } turn(t, g) // Bo rolls 4 if r := turn(t, g); r.To != 3 { // Ana rolls 2: 7 + 2 = 9, the snake t.Fatalf("got %+v", r) }}
func TestAnOvershootIsWastedUnderExactFinish(t *testing.T) { g := newTestGame(t, &FixedDice{[]int{2, 1, 5}}, false, ana, bo) turn(t, g) turn(t, g) // Ana on 7, Bo on 1 if r := turn(t, g); r.To != 7 || g.Current() != bo { // Ana needs 3, rolls 5; the turn still passes t.Fatalf("got %+v, current %v", r, g.Current()) }}
func TestASixRollsAgainButNotAfterAWin(t *testing.T) { g := newTestGame(t, &FixedDice{[]int{6}}, false, ana, bo) if r := turn(t, g); !r.RollsAgain || g.Current() != ana { t.Fatalf("got %+v", r) } w := newTestGame(t, &FixedDice{[]int{4, 1, 6}}, false, ana, bo) turn(t, w) turn(t, w) // Ana on 4, Bo on 1 if win := turn(t, w); !win.Finished || win.RollsAgain { // a winning six ends the game, no extra roll t.Fatalf("got %+v", win) }}
func TestWhenAPlayerFinishesTheNextSeatStillMoves(t *testing.T) { // Ana: 2 (ladder to 7), Bo: 1, Cy: 1, Ana: 3 finishes. The next roll must be Bo's, not Cy's. g := newTestGame(t, &FixedDice{[]int{2, 1, 1, 3, 4}}, true, ana, bo, cy) for range 4 { turn(t, g) } if r := g.Ranking(); len(r) != 1 || r[0] != ana || g.Current() != bo { t.Fatalf("ranking %v, current %v", r, g.Current()) }}
func TestTheBoardRejectsChainsAndASnakeOnTheLastSquare(t *testing.T) { if _, err := NewBoard(10, []Jump{{2, 5}, {5, 8}}); err == nil { // 2 -> 5 -> 8 chains t.Fatal("chain accepted") } if _, err := NewBoard(10, []Jump{{10, 1}}); err == nil { // unwinnable t.Fatal("snake on the last square accepted") }}
func TestTheSameSeedReplaysTheSameGame(t *testing.T) { var runs [2][]int for i := range runs { g := newTestGame(t, NewRandomDice(42, 6), false, ana, bo) for g.Status != Over { runs[i] = append(runs[i], turn(t, g).To) } } if !slices.Equal(runs[0], runs[1]) { t.Fatal("same seed, different games") }}
// Run with: go test -race ./... The race detector flags any state touched outside the lock.func TestRacingRollsForOneTurnPlayExactlyOnce(t *testing.T) { o := NewOnlineGame(newTestGame(t, NewRandomDice(1, 6), false, ana, bo)) var ok atomic.Int32 var wg sync.WaitGroup start := make(chan struct{}) for range 16 { wg.Add(1) go func() { defer wg.Done() <-start if _, _, err := o.Roll(ana, 0); err == nil { ok.Add(1) } }() } close(start) // release all sixteen at once wg.Wait() if ok.Load() != 1 { // every other copy came back ErrStale t.Fatalf("%d rolls landed for one turn", ok.Load()) }}import { test } from 'node:test';import assert from 'node:assert/strict';
const ANA: Player = { name: 'Ana' };const BO: Player = { name: 'Bo' };const CY: Player = { name: 'Cy' };
// Ten squares, a ladder from 2 to 7 and a snake from 9 to 3. Small enough to reason about.const board = () => new Board(10, [ { from: 2, to: 7 }, { from: 9, to: 3 }, ]);const game = (dice: Dice, toTheEnd: boolean, ...ps: Player[]) => new Game(board(), dice, new ExactFinish(), ps, toTheEnd);
test('a ladder climbs and a snake bites', () => { const g = game(new FixedDice(2, 4, 2), false, ANA, BO); const up = g.playTurn(); // Ana rolls 2: lands on the ladder assert.equal(up.to, 7); assert.ok(up.jump && isLadder(up.jump)); g.playTurn(); // Bo rolls 4 assert.equal(g.playTurn().to, 3); // Ana rolls 2: 7 + 2 = 9, the snake});
test('an overshoot is wasted under exact finish', () => { const g = game(new FixedDice(2, 1, 5), false, ANA, BO); g.playTurn(); g.playTurn(); // Ana on 7, Bo on 1 assert.equal(g.playTurn().to, 7); // Ana needs 3, rolls 5 assert.equal(g.current(), BO); // and the turn still passes});
test('a six rolls again, but not after a win', () => { const g = game(new FixedDice(6), false, ANA, BO); assert.ok(g.playTurn().rollsAgain); assert.equal(g.current(), ANA); // Ana again const w = game(new FixedDice(4, 1, 6), false, ANA, BO); w.playTurn(); w.playTurn(); // Ana on 4, Bo on 1 const win = w.playTurn(); // 4 + 6 = 10: a winning six assert.ok(win.finished && !win.rollsAgain); // the game is over, so no extra roll});
test('when a player finishes, the next seat still moves', () => { // Ana: 2 (ladder to 7), Bo: 1, Cy: 1, Ana: 3 finishes. The next roll must be Bo's, not Cy's. const g = game(new FixedDice(2, 1, 1, 3, 4), true, ANA, BO, CY); for (let i = 0; i < 4; i++) g.playTurn(); assert.deepEqual(g.ranking(), [ANA]); assert.equal(g.current(), BO);});
test('the board rejects chains and a snake on the last square', () => { assert.throws(() => new Board(10, [ { from: 2, to: 5 }, { from: 5, to: 8 }, ]), ); // 2 -> 5 -> 8 chains assert.throws(() => new Board(10, [{ from: 10, to: 1 }])); // unwinnable});
test('the same seed replays the same game', () => { const run = () => { const g = game(new RandomDice(seeded(42)), false, ANA, BO); const ends: number[] = []; while (g.status !== 'over') ends.push(g.playTurn().to); return ends; }; assert.deepEqual(run(), run());});
test('a repeated roll request for one turn plays exactly once', () => { // One process has no thread race, but a double tap still sends turn 0 twice. const g = new OnlineGame(game(new RandomDice(seeded(1)), false, ANA, BO)); assert.equal(g.roll(ANA, 0).outcome, 'ok'); assert.equal(g.roll(ANA, 0).outcome, '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 |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| 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.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 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
playTurnand 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.
Follow-up questions
Section titled “Follow-up questions”Some squares should give a power-up instead of a jump: roll again, or skip the next player. Where does that go?
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.
- A
SquareEffectinterface. It has one method,apply(turn), that changes what happens on this turn.Jumpbecomes one kind of effect: it changes where the player ends up. - Two new effects.
RollAgainmarks the turn as “roll again”, the same flag a six sets.SkipNextmarks the next player to be skipped once. - The board maps squares to effects.
jumpAt(square)becomeseffectAt(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. playTurnapplies 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.
Further reading
Section titled “Further reading”- Strategy pattern, Observer pattern
- Object modeling, Thread safety basics
- Design tic-tac-toe, the same turn and concurrency problems with real choices in each move
- Wikipedia: Snakes and ladders, including the Markov chain analysis of game length
- Martin Fowler: Event Sourcing, the roll log taken to its end
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.