Low Level DesignWorked problems
Design tic-tac-toe
Design tic-tac-toe step by step for a low level design interview. Grow from a 3x3 board to O(1) win checks on any size, undo, bots, and safe online play, with code in Java, Python, C#, Go and TypeScript.
- Read first
- Object modelingStrategy
- Related
- ChessSnake and ladder
“Design tic-tac-toe.” Sometimes it’s “for two players on one screen”, and sometimes it’s “that people can play online”. Which one you get changes the last part of the answer.
Tic-tac-toe looks like a warm-up question, but it has a trap in it. The game is so small that most people write one class with a 3x3 grid and a loop that checks the whole board for a winner. They finish in ten minutes. That answer works, but it shows the interviewer nothing.
The interesting part is everything around the grid. Where do the rules live? How do we track whose turn it is, so it can never be wrong? How do we spot a win without checking the whole board again? And when the game is online, what happens if the same move arrives twice?
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.
- The board and the game are separate classes. The
Boardonly holds marks. TheGameholds the rules: whose turn it is, which moves are allowed, and who won. - Whose turn it is comes from the move count. We never store it in its own field. After an even number of moves it’s X’s turn, after an odd number it’s O’s. So it can never get out of step, even after an undo.
- A win is found by reading four numbers, not by scanning the board. We keep one counter for each row, each column and each diagonal. X adds 1 and O subtracts 1. A counter that reaches the board size means a full line.
- Undo and bots don’t change the game’s rules. Undo reverses the last move from a history list. A bot is just another caller that looks at the board and plays a move like a person.
- Online, each move can only happen once. One lock per game stops two requests from overlapping. Each request also says which move number it is, so a repeated request is refused.
We’ll build this in four stages, then put the game online.
Scope it
Section titled “Scope it”The game’s rules are fixed, but the system around it isn’t. A few questions change the design a lot. Anything else, we assume and move on.
Design tic-tac-toe.
Whether you find the two or three questions that change the design (board size, local or online, undo) in under three minutes. Asking about colors or sounds looks like stalling. Asking nothing looks like a memorized 3x3 answer.
Here are the questions that matter:
- Is the board always 3x3? We’ll support any size, n by n, where n in a row wins. With a 3x3 board only, checking the whole board for a winner is fine. On a big board it isn’t. So we write the size as a setting from the start. It costs nothing.
- Is it played on one screen, or online? Online, with two devices and one game on a server. If it were only on one screen, the last part of this page (two requests at once) would go away.
- Do we need undo? Yes, any number of times, even after a winning move.
- Can a computer play? Yes. A bot can play either side, and we want to be able to swap in a smarter bot later.
These answers give us one promise that matters more than the others: each turn results in
exactly one move, even when two requests for it arrive at once. Three smaller promises come with
it. A win is found in the same short time on any board size. Whose turn it is can never be wrong,
even after an undo. And a new win rule, or a new kind of bot, doesn’t mean editing Game.
We leave out matchmaking, accounts and ratings, saving games, and the screen itself. The screen only reads the board and sends moves.
Go deeperFour more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Is the win rule always n in a row? | For now; Gomoku (5 in a row on 15x15) later | A fixed rule could live inside Game; we put it behind an interface |
| Who moves first? | The first player passed in, usually X | A coin toss is a choice made when the game is created, not in play |
| More than two players? | No | Turn becomes moves % players.length; the counters need one per mark |
| Time limits per move? | No | A clock and a timeout that forfeits; the same injected clock as any game |
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 that knows who won
Section titled “Stage 1: A board that knows who won”Let’s start with two people on one screen. What are your classes?
Whether you get a working game in two or three minutes and keep the grid apart from the game. A
single TicTacToe class with a char[3][3] and everything else inside it is the answer they’ve
seen a hundred times.
Start with the smallest thing that works. We need three things: the marks (X and O), the board that holds them, and the game that owns the board and knows whose turn it is.
The board only knows about cells and lines. It doesn’t know whose turn it is. That keeps it simple, and a different game could reuse the same grid.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// The two marks a cell can hold. A closed set of names, so an enum.enum Mark { X, O }
// Board owns the grid and nothing else. It doesn't know whose turn it is,// so a different game on the same grid (Gomoku, Connect Four) can reuse it.class Board { final int size; private final Mark[][] cells; // null means the cell is empty
Board(int size) { this.size = size; this.cells = new Mark[size][size]; }
Mark at(int row, int col) { return cells[row][col]; }
void place(int row, int col, Mark m) { cells[row][col] = m; }
// flaw 2: rescans every row, column and diagonal after every move. Fine for 3x3, // O(n^2) per move on an n x n board. Stage 3 replaces it with O(1) counters. boolean hasLine(Mark m) { boolean diag = true, anti = true; for (int i = 0; i < size; i++) { boolean row = true, col = true; for (int j = 0; j < size; j++) { row &= cells[i][j] == m; col &= cells[j][i] == m; } if (row || col) return true; diag &= cells[i][i] == m; anti &= cells[i][size - 1 - i] == m; } return diag || anti; }}
// Game is the one class callers talk to. It owns the board and the turn.class Game { private final Board board = new Board(3); private Mark turn = Mark.X; // X always opens private Mark winner; // null while nobody has won
void play(int row, int col) { // flaw 1: no checks. A taken cell, a cell off the board, a move after // the game is over: all accepted. Stage 2 adds the rules. board.place(row, col, turn); if (board.hasLine(turn)) winner = turn; turn = (turn == Mark.X) ? Mark.O : Mark.X; }
Mark winner() { return winner; }}from enum import Enum
# The two marks a cell can hold. A closed set of names, so an enum.class Mark(Enum): X = "X" O = "O"
# Board owns the grid and nothing else. It doesn't know whose turn it is,# so a different game on the same grid (Gomoku, Connect Four) can reuse it.class Board: def __init__(self, size: int): self.size = size self._cells: list[list[Mark | None]] = [[None] * size for _ in range(size)] # None: empty
def at(self, row: int, col: int) -> Mark | None: return self._cells[row][col]
def place(self, row: int, col: int, m: Mark) -> None: self._cells[row][col] = m
# flaw 2: rescans every row, column and diagonal after every move. Fine for 3x3, # O(n^2) per move on an n x n board. Stage 3 replaces it with O(1) counters. def has_line(self, m: Mark) -> bool: n, c = self.size, self._cells rows = any(all(c[i][j] is m for j in range(n)) for i in range(n)) cols = any(all(c[j][i] is m for j in range(n)) for i in range(n)) diag = all(c[i][i] is m for i in range(n)) anti = all(c[i][n - 1 - i] is m for i in range(n)) return rows or cols or diag or anti
# Game is the one class callers talk to. It owns the board and the turn.class Game: def __init__(self): self._board = Board(3) self._turn = Mark.X # X always opens self.winner: Mark | None = None # None while nobody has won
def play(self, row: int, col: int) -> None: # flaw 1: no checks. A taken cell, a cell off the board, a move after # the game is over: all accepted. Stage 2 adds the rules. self._board.place(row, col, self._turn) if self._board.has_line(self._turn): self.winner = self._turn self._turn = Mark.O if self._turn is Mark.X else Mark.X// The two marks a cell can hold. A closed set of names, so an enum.public enum Mark { X, O }
// Board owns the grid and nothing else. It doesn't know whose turn it is,// so a different game on the same grid (Gomoku, Connect Four) can reuse it.public sealed class Board(int size){ public int Size { get; } = size; private readonly Mark?[,] _cells = new Mark?[size, size]; // null means the cell is empty
public Mark? At(int row, int col) => _cells[row, col];
internal void Place(int row, int col, Mark m) => _cells[row, col] = m;
// flaw 2: rescans every row, column and diagonal after every move. Fine for 3x3, // O(n^2) per move on an n x n board. Stage 3 replaces it with O(1) counters. public bool HasLine(Mark m) { bool diag = true, anti = true; for (int i = 0; i < Size; i++) { bool row = true, col = true; for (int j = 0; j < Size; j++) { row &= _cells[i, j] == m; col &= _cells[j, i] == m; } if (row || col) return true; diag &= _cells[i, i] == m; anti &= _cells[i, Size - 1 - i] == m; } return diag || anti; }}
// Game is the one class callers talk to. It owns the board and the turn.public sealed class Game{ private readonly Board _board = new(3); private Mark _turn = Mark.X; // X always opens public Mark? Winner { get; private set; } // null while nobody has won
public void Play(int row, int col) { // flaw 1: no checks. A taken cell, a cell off the board, a move after // the game is over: all accepted. Stage 2 adds the rules. _board.Place(row, col, _turn); if (_board.HasLine(_turn)) Winner = _turn; _turn = _turn == Mark.X ? Mark.O : Mark.X; }}// Mark is what a cell holds. The zero value, Empty, means nobody has played there,// so a fresh board needs no initialisation.type Mark int
const ( Empty Mark = iota X O)
// Board owns the grid and nothing else. It doesn't know whose turn it is,// so a different game on the same grid (Gomoku, Connect Four) can reuse it.type Board struct { size int cells [][]Mark}
func NewBoard(size int) *Board { cells := make([][]Mark, size) for i := range cells { cells[i] = make([]Mark, size) } return &Board{size: size, cells: cells}}
func (b *Board) Size() int { return b.size }
func (b *Board) At(row, col int) Mark { return b.cells[row][col] }
func (b *Board) place(row, col int, m Mark) { b.cells[row][col] = m }
// HasLine rescans every row, column and diagonal after every move (flaw 2). Fine for 3x3,// O(n^2) per move on an n x n board. Stage 3 replaces it with O(1) counters.func (b *Board) HasLine(m Mark) bool { diag, anti := true, true for i := 0; i < b.size; i++ { row, col := true, true for j := 0; j < b.size; j++ { row = row && b.cells[i][j] == m col = col && b.cells[j][i] == m } if row || col { return true } diag = diag && b.cells[i][i] == m anti = anti && b.cells[i][b.size-1-i] == m } return diag || anti}
// Game is the one type callers talk to. It owns the board and the turn.type Game struct { board *Board turn Mark Winner Mark // Empty while nobody has won}
func NewGame() *Game { return &Game{board: NewBoard(3), turn: X} } // X always opens
func (g *Game) Play(row, col int) { // flaw 1: no checks. A taken cell, a cell off the board, a move after // the game is over: all accepted. Stage 2 adds the rules. g.board.place(row, col, g.turn) if g.board.HasLine(g.turn) { g.Winner = g.turn } if g.turn == X { g.turn = O } else { g.turn = X }}// The two marks a cell can hold. A string union is TypeScript's lightweight enum.type Mark = 'X' | 'O';
// Board owns the grid and nothing else. It doesn't know whose turn it is,// so a different game on the same grid (Gomoku, Connect Four) can reuse it.class Board { private readonly cells: (Mark | null)[][]; // null means the cell is empty
constructor(readonly size: number) { this.cells = Array.from({ length: size }, () => Array<Mark | null>(size).fill(null)); }
at(row: number, col: number): Mark | null { return this.cells[row][col]; }
place(row: number, col: number, m: Mark): void { this.cells[row][col] = m; }
// flaw 2: rescans every row, column and diagonal after every move. Fine for 3x3, // O(n^2) per move on an n x n board. Stage 3 replaces it with O(1) counters. hasLine(m: Mark): boolean { const idx = [...Array(this.size).keys()]; const is = (r: number, c: number) => this.cells[r][c] === m; return ( idx.some((i) => idx.every((j) => is(i, j))) || // a row idx.some((i) => idx.every((j) => is(j, i))) || // a column idx.every((i) => is(i, i)) || // the diagonal idx.every((i) => is(i, this.size - 1 - i)) // the anti-diagonal ); }}
// Game is the one class callers talk to. It owns the board and the turn.class Game { private readonly board = new Board(3); private turn: Mark = 'X'; // X always opens winner: Mark | null = null; // null while nobody has won
play(row: number, col: number): void { // flaw 1: no checks. A taken cell, a cell off the board, a move after // the game is over: all accepted. Stage 2 adds the rules. this.board.place(row, col, this.turn); if (this.board.hasLine(this.turn)) this.winner = this.turn; this.turn = this.turn === 'X' ? 'O' : 'X'; }}This version has two problems, and we leave them in on purpose. First, play accepts anything: a
cell that’s already taken, a cell off the board, or a move after someone has won. Second,
hasLine checks every row, column and diagonal after every move. On an n by n board, that’s about
n² cells each time. Stage 2 fixes the first problem, and stage 3 fixes the second.
Stage 2: Turns, rules and results
Section titled “Stage 2: Turns, rules and results”What stops a player moving twice in a row, or playing on a taken cell?
Whether every rule is checked in one place, in a fixed order, and whether an illegal move is a
normal outcome rather than a crash. A turn field updated separately from the move count is the
bug they’re waiting to ask about when you get to undo.
Now we add the rules. All the checks go at the top of play, always in the same order. So the
same bad move always gets the same answer.
A few small types come with this. A player is a name and a mark. The game gets a status: in progress, won, or a draw. And each kind of refused move gets a name, like “not your turn” or “cell taken”.
The most important change is how we track the turn. We don’t keep a turn field anymore. Instead,
the turn comes from how many moves have been played: moves % 2. Zero, two, four moves means it’s
X’s turn. One, three, five means it’s O’s. There’s only one number to keep right, so the turn can’t
drift.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A player is a value: a name and the mark they play. Two equal records are the same// player, which is exactly what the turn check compares.record Player(String name, Mark mark) {}
// Where the game stands. Callers read it after a move instead of working it out.enum GameStatus { IN_PROGRESS, WON, DRAW }
// Every way a move can be refused, plus OK. In a game people play, a bad move is// expected input, so it is a result the UI must show, not an exception.enum MoveResult { OK, GAME_OVER, NOT_YOUR_TURN, OUT_OF_BOUNDS, CELL_TAKEN }
class Game { private final Board board; private final Player[] players; // index 0 moves first private int moves; // moves so far; also the only record of whose turn it is private GameStatus status = GameStatus.IN_PROGRESS; private Player winner; // set only when status is WON
Game(int size, Player first, Player second) { // Reject a broken setup here, so play() never has to think about it. if (first.mark() == second.mark()) throw new IllegalArgumentException("players need different marks"); this.board = new Board(size); this.players = new Player[] { first, second }; }
// The turn is derived from the move count, so there is no second field to drift out of step. Player current() { return players[moves % 2]; }
GameStatus status() { return status; }
Optional<Player> winner() { return Optional.ofNullable(winner); }
MoveResult play(Player p, int row, int col) { // Checks run in a fixed order, so the same bad move always gets the same answer. if (status != GameStatus.IN_PROGRESS) return MoveResult.GAME_OVER; if (!p.equals(current())) return MoveResult.NOT_YOUR_TURN; if (row < 0 || col < 0 || row >= board.size || col >= board.size) return MoveResult.OUT_OF_BOUNDS; if (board.at(row, col) != null) return MoveResult.CELL_TAKEN;
board.place(row, col, p.mark()); moves++; // Win before draw: the last free cell can complete a line. if (board.hasLine(p.mark())) { status = GameStatus.WON; winner = p; } else if (moves == board.size * board.size) { status = GameStatus.DRAW; } return MoveResult.OK; }}from dataclasses import dataclass
# A player is a value: a name and the mark they play. Two equal players are the same# player, which is exactly what the turn check compares.@dataclass(frozen=True)class Player: name: str mark: Mark
# Where the game stands. Callers read it after a move instead of working it out.class GameStatus(Enum): IN_PROGRESS = "in progress" WON = "won" DRAW = "draw"
# Every way a move can be refused, plus OK. In a game people play, a bad move is# expected input, so it is a result the UI must show, not an exception.class MoveResult(Enum): OK = "ok" GAME_OVER = "game over" NOT_YOUR_TURN = "not your turn" OUT_OF_BOUNDS = "out of bounds" CELL_TAKEN = "cell taken"
class Game: def __init__(self, size: int, first: Player, second: Player): # Reject a broken setup here, so play() never has to think about it. if first.mark is second.mark: raise ValueError("players need different marks") self._board = Board(size) self._players = (first, second) # index 0 moves first self._moves = 0 # moves so far; also the only record of whose turn it is self.status = GameStatus.IN_PROGRESS self.winner: Player | None = None # set only when status is WON
# The turn is derived from the move count, so there is no second field to drift out of step. def current(self) -> Player: return self._players[self._moves % 2]
def play(self, p: Player, row: int, col: int) -> MoveResult: # Checks run in a fixed order, so the same bad move always gets the same answer. n = self._board.size if self.status is not GameStatus.IN_PROGRESS: return MoveResult.GAME_OVER if p != self.current(): return MoveResult.NOT_YOUR_TURN if not (0 <= row < n and 0 <= col < n): return MoveResult.OUT_OF_BOUNDS if self._board.at(row, col) is not None: return MoveResult.CELL_TAKEN
self._board.place(row, col, p.mark) self._moves += 1 # Win before draw: the last free cell can complete a line. if self._board.has_line(p.mark): self.status, self.winner = GameStatus.WON, p elif self._moves == n * n: self.status = GameStatus.DRAW return MoveResult.OK// A player is a value: a name and the mark they play. Two equal records are the same// player, which is exactly what the turn check compares.public sealed record Player(string Name, Mark Mark);
// Where the game stands. Callers read it after a move instead of working it out.public enum GameStatus { InProgress, Won, Draw }
// Every way a move can be refused, plus Ok. In a game people play, a bad move is// expected input, so it is a result the UI must show, not an exception.public enum MoveResult { Ok, GameOver, NotYourTurn, OutOfBounds, CellTaken }
public sealed class Game{ private readonly Board _board; private readonly Player[] _players; // index 0 moves first private int _moves; // moves so far; also the only record of whose turn it is public GameStatus Status { get; private set; } = GameStatus.InProgress; public Player? Winner { get; private set; } // set only when Status is Won
public Game(int size, Player first, Player second) { // Reject a broken setup here, so Play never has to think about it. if (first.Mark == second.Mark) throw new ArgumentException("players need different marks"); _board = new Board(size); _players = [first, second]; }
// The turn is derived from the move count, so there is no second field to drift out of step. public Player Current => _players[_moves % 2];
public MoveResult Play(Player p, int row, int col) { // Checks run in a fixed order, so the same bad move always gets the same answer. int n = _board.Size; if (Status != GameStatus.InProgress) return MoveResult.GameOver; if (p != Current) return MoveResult.NotYourTurn; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OutOfBounds; if (_board.At(row, col) is not null) return MoveResult.CellTaken;
_board.Place(row, col, p.Mark); _moves++; // Win before draw: the last free cell can complete a line. if (_board.HasLine(p.Mark)) { Status = GameStatus.Won; Winner = p; } else if (_moves == n * n) Status = GameStatus.Draw; return MoveResult.Ok; }}// Player is a value: a name and the mark they play. Structs compare by value,// so p == g.Current() is exactly the turn check we want.type Player struct { Name string Mark Mark}
// GameStatus is where the game stands. Callers read it after a move instead of working it out.type GameStatus int
const ( InProgress GameStatus = iota Won Draw)
// Every way a move can be refused. In a game people play, a bad move is expected// input, so each is a named error the UI can show.var ( ErrGameOver = errors.New("game over") ErrNotYourTurn = errors.New("not your turn") ErrOutOfBounds = errors.New("out of bounds") ErrCellTaken = errors.New("cell taken"))
type Game struct { board *Board players [2]Player // index 0 moves first moves int // moves so far; also the only record of whose turn it is Status GameStatus Winner *Player // set only when Status is Won}
func NewGame(size int, first, second Player) (*Game, error) { // Reject a broken setup here, so Play never has to think about it. if first.Mark == second.Mark { return nil, errors.New("players need different marks") } return &Game{board: NewBoard(size), players: [2]Player{first, second}}, nil}
// Current is derived from the move count, so there is no second field to drift out of step.func (g *Game) Current() Player { return g.players[g.moves%2] }
func (g *Game) Play(p Player, row, col int) error { // Checks run in a fixed order, so the same bad move always gets the same answer. n := g.board.Size() switch { case g.Status != InProgress: return ErrGameOver case p != g.Current(): return ErrNotYourTurn case row < 0 || col < 0 || row >= n || col >= n: return ErrOutOfBounds case g.board.At(row, col) != Empty: return ErrCellTaken } g.board.place(row, col, p.Mark) g.moves++ // Win before draw: the last free cell can complete a line. if g.board.HasLine(p.Mark) { g.Status, g.Winner = Won, &p } else if g.moves == n*n { g.Status = Draw } return nil}// A player is a value: a name and the mark they play.interface Player { readonly name: string; readonly mark: Mark;}
// Where the game stands. Callers read it after a move instead of working it out.type GameStatus = 'in-progress' | 'won' | 'draw';
// Every way a move can be refused, plus ok. In a game people play, a bad move is// expected input, so it is a result the UI must show, not an exception.type MoveResult = 'ok' | 'game-over' | 'not-your-turn' | 'out-of-bounds' | 'cell-taken';
class Game { private readonly board: Board; private readonly players: readonly [Player, Player]; // index 0 moves first private moves = 0; // moves so far; also the only record of whose turn it is status: GameStatus = 'in-progress'; winner: Player | null = null; // set only when status is 'won'
constructor(size: number, first: Player, second: Player) { // Reject a broken setup here, so play() never has to think about it. if (first.mark === second.mark) throw new Error('players need different marks'); this.board = new Board(size); this.players = [first, second]; }
// The turn is derived from the move count, so there is no second field to drift out of step. get current(): Player { return this.players[this.moves % 2]; }
play(p: Player, row: number, col: number): MoveResult { // Checks run in a fixed order, so the same bad move always gets the same answer. const n = this.board.size; if (this.status !== 'in-progress') return 'game-over'; if (p.name !== this.current.name || p.mark !== this.current.mark) return 'not-your-turn'; if (row < 0 || col < 0 || row >= n || col >= n) return 'out-of-bounds'; if (this.board.at(row, col) !== null) return 'cell-taken';
this.board.place(row, col, p.mark); this.moves++; // Win before draw: the last free cell can complete a line. if (this.board.hasLine(p.mark)) { this.status = 'won'; this.winner = p; } else if (this.moves === n * n) { this.status = 'draw'; } return 'ok'; }}Notice that we check for a win before we check for a draw. The last free cell can also complete a line. If we checked the draw first, a game won on the ninth move would be reported as a draw.
Why return a result like “cell taken” instead of throwing an exception? In a game, a bad move is normal: a mis-tap, or a screen that’s a second out of date. Real bugs, like a game with two X players, still throw an exception when the game is created.
Stage 3: A win check that doesn’t scan
Section titled “Stage 3: A win check that doesn’t scan”Your win check scans the whole board. Make it work for a 1,000 by 1,000 board.
Whether you see that a move can only complete the lines through its own cell, and get from there to counters. Checking only that move’s row, column and diagonals is a good answer. Counters that give the answer in one step are the strong one.
Checking the whole board after every move is wasteful. Think about what one move can change. A mark at a given cell can only complete four lines: its row, its column, and the two diagonals (if the cell is on them). No other line changed.
So we keep a counter for each row, each column and each of the two diagonals. When X plays, we add 1 to the counters of the lines through that cell. When O plays, we subtract 1. A row’s counter can only reach +n if all n cells in that row are X. It can only reach −n if all of them are O. So to check for a win, we read four numbers. The board size doesn’t matter.
There’s one condition for this to be correct: each cell must add to each counter only once. Stage 2 already guarantees that, because a taken cell can never be played again.
We also move this logic out of Game and behind an interface, WinRule. That way, a different
rule (like Gomoku’s five in a row) can be swapped in later without touching Game. This is the
Strategy pattern.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// The rule that decides a win. An interface, because "n in a row on an n x n board" is one// rule among several (five in a row on 15 x 15 is Gomoku), and Game shouldn't care which.interface WinRule { /** Records an accepted move and says whether it completed a win. */ boolean record(int row, int col, Mark m);}
// n in a row on an n x n board, in O(1) per move. One counter per row, per column and per// diagonal: X adds 1, O subtracts 1. A counter reaches +n or -n only when all n cells of its// line hold the same mark. That relies on the game never letting a taken cell be played again.class LineCounterRule implements WinRule { private final int n; private final int[] rows, cols; private int diag, anti;
LineCounterRule(int n) { this.n = n; this.rows = new int[n]; this.cols = new int[n]; }
public boolean record(int row, int col, Mark m) { int d = (m == Mark.X) ? 1 : -1; rows[row] += d; cols[col] += d; if (row == col) diag += d; // on the main diagonal if (row + col == n - 1) anti += d; // on the anti-diagonal (the centre is on both) // Only lines through this cell changed. The diagonals can't already be at +-n, // or the game would have ended, so checking them unconditionally is safe. return Math.abs(rows[row]) == n || Math.abs(cols[col]) == n || Math.abs(diag) == n || Math.abs(anti) == n; }}
class Game { private final Board board; private final WinRule rule; // injected, so Gomoku is a different rule, not a different Game private final Player[] players; private int moves; private GameStatus status = GameStatus.IN_PROGRESS; private Player winner;
Game(int size, Player first, Player second) { this(size, new LineCounterRule(size), first, second); }
Game(int size, WinRule rule, Player first, Player second) { if (first.mark() == second.mark()) throw new IllegalArgumentException("players need different marks"); this.board = new Board(size); this.rule = rule; this.players = new Player[] { first, second }; }
Player current() { return players[moves % 2]; }
GameStatus status() { return status; }
Optional<Player> winner() { return Optional.ofNullable(winner); }
MoveResult play(Player p, int row, int col) { if (status != GameStatus.IN_PROGRESS) return MoveResult.GAME_OVER; if (!p.equals(current())) return MoveResult.NOT_YOUR_TURN; if (row < 0 || col < 0 || row >= board.size || col >= board.size) return MoveResult.OUT_OF_BOUNDS; if (board.at(row, col) != null) return MoveResult.CELL_TAKEN;
board.place(row, col, p.mark()); moves++; if (rule.record(row, col, p.mark())) { // O(1) instead of rescanning the board status = GameStatus.WON; winner = p; } else if (moves == board.size * board.size) { status = GameStatus.DRAW; } return MoveResult.OK; }}from typing import Protocol
# The rule that decides a win. A protocol, because "n in a row on an n x n board" is one# rule among several (five in a row on 15 x 15 is Gomoku), and Game shouldn't care which.class WinRule(Protocol): # Records an accepted move and says whether it completed a win. def record(self, row: int, col: int, m: Mark) -> bool: ...
# n in a row on an n x n board, in O(1) per move. One counter per row, per column and per# diagonal: X adds 1, O subtracts 1. A counter reaches +n or -n only when all n cells of its# line hold the same mark. That relies on the game never letting a taken cell be played again.class LineCounterRule: def __init__(self, n: int): self._n = n self._rows = [0] * n self._cols = [0] * n self._diag = 0 self._anti = 0
def record(self, row: int, col: int, m: Mark) -> bool: d = 1 if m is Mark.X else -1 self._rows[row] += d self._cols[col] += d if row == col: self._diag += d # on the main diagonal if row + col == self._n - 1: self._anti += d # on the anti-diagonal (the centre is on both) # Only lines through this cell changed. The diagonals can't already be at +-n, # or the game would have ended, so checking them unconditionally is safe. n = self._n return n in (abs(self._rows[row]), abs(self._cols[col]), abs(self._diag), abs(self._anti))
class Game: def __init__(self, size: int, first: Player, second: Player, rule: WinRule | None = None): if first.mark is second.mark: raise ValueError("players need different marks") self._board = Board(size) # Injected, so Gomoku is a different rule, not a different Game. self._rule = rule or LineCounterRule(size) self._players = (first, second) self._moves = 0 self.status = GameStatus.IN_PROGRESS self.winner: Player | None = None
def current(self) -> Player: return self._players[self._moves % 2]
def play(self, p: Player, row: int, col: int) -> MoveResult: n = self._board.size if self.status is not GameStatus.IN_PROGRESS: return MoveResult.GAME_OVER if p != self.current(): return MoveResult.NOT_YOUR_TURN if not (0 <= row < n and 0 <= col < n): return MoveResult.OUT_OF_BOUNDS if self._board.at(row, col) is not None: return MoveResult.CELL_TAKEN
self._board.place(row, col, p.mark) self._moves += 1 if self._rule.record(row, col, p.mark): # O(1) instead of rescanning the board self.status, self.winner = GameStatus.WON, p elif self._moves == n * n: self.status = GameStatus.DRAW return MoveResult.OK// The rule that decides a win. An interface, because "n in a row on an n x n board" is one// rule among several (five in a row on 15 x 15 is Gomoku), and Game shouldn't care which.public interface IWinRule{ /// <summary>Records an accepted move and says whether it completed a win.</summary> bool Record(int row, int col, Mark m);}
// n in a row on an n x n board, in O(1) per move. One counter per row, per column and per// diagonal: X adds 1, O subtracts 1. A counter reaches +n or -n only when all n cells of its// line hold the same mark. That relies on the game never letting a taken cell be played again.public sealed class LineCounterRule(int n) : IWinRule{ private readonly int[] _rows = new int[n], _cols = new int[n]; private int _diag, _anti;
public bool Record(int row, int col, Mark m) { int d = m == Mark.X ? 1 : -1; _rows[row] += d; _cols[col] += d; if (row == col) _diag += d; // on the main diagonal if (row + col == n - 1) _anti += d; // on the anti-diagonal (the centre is on both) // Only lines through this cell changed. The diagonals can't already be at +-n, // or the game would have ended, so checking them unconditionally is safe. return Math.Abs(_rows[row]) == n || Math.Abs(_cols[col]) == n || Math.Abs(_diag) == n || Math.Abs(_anti) == n; }}
public sealed class Game{ private readonly Board _board; private readonly IWinRule _rule; // injected, so Gomoku is a different rule, not a different Game private readonly Player[] _players; private int _moves; public GameStatus Status { get; private set; } = GameStatus.InProgress; public Player? Winner { get; private set; }
public Game(int size, Player first, Player second, IWinRule? rule = null) { if (first.Mark == second.Mark) throw new ArgumentException("players need different marks"); _board = new Board(size); _rule = rule ?? new LineCounterRule(size); _players = [first, second]; }
public Player Current => _players[_moves % 2];
public MoveResult Play(Player p, int row, int col) { int n = _board.Size; if (Status != GameStatus.InProgress) return MoveResult.GameOver; if (p != Current) return MoveResult.NotYourTurn; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OutOfBounds; if (_board.At(row, col) is not null) return MoveResult.CellTaken;
_board.Place(row, col, p.Mark); _moves++; if (_rule.Record(row, col, p.Mark)) { Status = GameStatus.Won; Winner = p; } // O(1), no rescan else if (_moves == n * n) Status = GameStatus.Draw; return MoveResult.Ok; }}// WinRule decides a win. An interface, because "n in a row on an n x n board" is one// rule among several (five in a row on 15 x 15 is Gomoku), and Game shouldn't care which.type WinRule interface { // Record notes an accepted move and reports whether it completed a win. Record(row, col int, m Mark) bool}
// LineCounterRule is n in a row on an n x n board, in O(1) per move. One counter per row, per// column and per diagonal: X adds 1, O subtracts 1. A counter reaches +n or -n only when all n// cells of its line hold the same mark. That relies on the game never letting a taken cell be// played again.type LineCounterRule struct { n int rows, cols []int diag, anti int}
func NewLineCounterRule(n int) *LineCounterRule { return &LineCounterRule{n: n, rows: make([]int, n), cols: make([]int, n)}}
func (r *LineCounterRule) Record(row, col int, m Mark) bool { d := 1 if m == O { d = -1 } r.rows[row] += d r.cols[col] += d if row == col { r.diag += d // on the main diagonal } if row+col == r.n-1 { r.anti += d // on the anti-diagonal (the centre is on both) } // Only lines through this cell changed. The diagonals can't already be at +-n, // or the game would have ended, so checking them unconditionally is safe. full := func(v int) bool { return v == r.n || v == -r.n } return full(r.rows[row]) || full(r.cols[col]) || full(r.diag) || full(r.anti)}
type Game struct { board *Board rule WinRule // injected, so Gomoku is a different rule, not a different Game players [2]Player moves int Status GameStatus Winner *Player}
// NewGame takes the rule as a parameter; pass nil for the classic n in a row.func NewGame(size int, rule WinRule, first, second Player) (*Game, error) { if first.Mark == second.Mark { return nil, errors.New("players need different marks") } if rule == nil { rule = NewLineCounterRule(size) } return &Game{board: NewBoard(size), rule: rule, players: [2]Player{first, second}}, nil}
func (g *Game) Play(p Player, row, col int) error { n := g.board.Size() switch { case g.Status != InProgress: return ErrGameOver case p != g.Current(): return ErrNotYourTurn case row < 0 || col < 0 || row >= n || col >= n: return ErrOutOfBounds case g.board.At(row, col) != Empty: return ErrCellTaken } g.board.place(row, col, p.Mark) g.moves++ if g.rule.Record(row, col, p.Mark) { // O(1) instead of rescanning the board g.Status, g.Winner = Won, &p } else if g.moves == n*n { g.Status = Draw } return nil}// The rule that decides a win. An interface, because "n in a row on an n x n board" is one// rule among several (five in a row on 15 x 15 is Gomoku), and Game shouldn't care which.interface WinRule { /** Records an accepted move and says whether it completed a win. */ record(row: number, col: number, m: Mark): boolean;}
// n in a row on an n x n board, in O(1) per move. One counter per row, per column and per// diagonal: X adds 1, O subtracts 1. A counter reaches +n or -n only when all n cells of its// line hold the same mark. That relies on the game never letting a taken cell be played again.class LineCounterRule implements WinRule { private readonly rows: number[]; private readonly cols: number[]; private diag = 0; private anti = 0;
constructor(private readonly n: number) { this.rows = Array(n).fill(0); this.cols = Array(n).fill(0); }
record(row: number, col: number, m: Mark): boolean { const d = m === 'X' ? 1 : -1; this.rows[row] += d; this.cols[col] += d; if (row === col) this.diag += d; // on the main diagonal if (row + col === this.n - 1) this.anti += d; // on the anti-diagonal (the centre is on both) // Only lines through this cell changed. The diagonals can't already be at +-n, // or the game would have ended, so checking them unconditionally is safe. const full = (v: number) => Math.abs(v) === this.n; return full(this.rows[row]) || full(this.cols[col]) || full(this.diag) || full(this.anti); }}
class Game { private readonly board: Board; private readonly players: readonly [Player, Player]; private moves = 0; status: GameStatus = 'in-progress'; winner: Player | null = null;
constructor( size: number, first: Player, second: Player, // Injected, so Gomoku is a different rule, not a different Game. private readonly rule: WinRule = new LineCounterRule(size), ) { if (first.mark === second.mark) throw new Error('players need different marks'); this.board = new Board(size); this.players = [first, second]; }
get current(): Player { return this.players[this.moves % 2]; }
play(p: Player, row: number, col: number): MoveResult { const n = this.board.size; if (this.status !== 'in-progress') return 'game-over'; if (p.name !== this.current.name || p.mark !== this.current.mark) return 'not-your-turn'; if (row < 0 || col < 0 || row >= n || col >= n) return 'out-of-bounds'; if (this.board.at(row, col) !== null) return 'cell-taken';
this.board.place(row, col, p.mark); this.moves++; // O(1) instead of rescanning the board. if (this.rule.record(row, col, p.mark)) { this.status = 'won'; this.winner = p; } else if (this.moves === n * n) { this.status = 'draw'; } return 'ok'; }}The memory cost is small: 2n + 2 numbers. A 1,000 by 1,000 board needs about 2,000 counters, next to the million cells it already stores. The board goes back to just holding cells.
There is one risk. The counters are a second copy of what’s on the board. If anything changes a cell without telling the counters, they’ll be wrong. Undo, in the next stage, is exactly that kind of change, so we’ll have to be careful.
Stage 4: Undo and bots
Section titled “Stage 4: Undo and bots”Players want undo. And we want a computer opponent. Where do those go?
Whether undo reverses every piece of state a move changed (the cell, the counters, the status,
the turn), and whether the bot lives outside the game. A Player.makeMove() with a Scanner
inside, or a game loop that asks bots for moves, ties the model to one screen.
Undo needs a record of what happened. So every accepted move goes onto a history list.
To undo, we take the last move off the list and reverse everything play changed: we clear the
cell, take the move back out of the counters, and reset the status.
What about the turn? It now comes from the history: history.size() % 2. So removing a move from
the list hands the turn back automatically. This is the payoff of not storing the turn in stage 2.
The WinRule interface gets an undo method too. With counters, undo is easy: subtract what
record added.
// One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.record Move(Player player, int row, int col) {}
// Decides when a move wins. Whoever records a move must be able to take it back.interface WinRule { boolean record(int row, int col, Mark m); void undo(int row, int col, Mark m);}
// Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and// diagonal, where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract// what record added. A rule that rescanned the board would get undo for free; one that cached// "lines nearly won" would have to rebuild them.class LineCounterRule implements WinRule { private final int n; private final int[] rows, cols; private int diag, anti;
LineCounterRule(int n) { this.n = n; this.rows = new int[n]; this.cols = new int[n]; }
// Adds this move to its row, column and diagonals, and says whether any of them is now full // of one mark. public boolean record(int row, int col, Mark m) { add(row, col, (m == Mark.X) ? 1 : -1); return Math.abs(rows[row]) == n || Math.abs(cols[col]) == n || Math.abs(diag) == n || Math.abs(anti) == n; }
// Subtracts exactly what record added. public void undo(int row, int col, Mark m) { add(row, col, (m == Mark.X) ? -1 : 1); }
// One place that knows which counters a cell touches, shared by record and undo. private void add(int row, int col, int d) { rows[row] += d; cols[col] += d; if (row == col) diag += d; if (row + col == n - 1) anti += d; }}
class Game { private final Board board; private final WinRule rule; private final Player[] players; private final Deque<Move> history = new ArrayDeque<>(); // newest last; its size replaces the move count private GameStatus status = GameStatus.IN_PROGRESS; private Player winner;
Game(int size, WinRule rule, Player first, Player second) { if (first.mark() == second.mark()) throw new IllegalArgumentException("players need different marks"); this.board = new Board(size); this.rule = rule; this.players = new Player[] { first, second }; }
Player current() { return players[history.size() % 2]; }
GameStatus status() { return status; }
Optional<Player> winner() { return Optional.ofNullable(winner); }
// Read-only, so a bot or a UI can look at the board but only play() can change it. BoardView board() { return board; }
MoveResult play(Player p, int row, int col) { int n = board.size(); if (status != GameStatus.IN_PROGRESS) return MoveResult.GAME_OVER; if (!p.equals(current())) return MoveResult.NOT_YOUR_TURN; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OUT_OF_BOUNDS; if (board.at(row, col) != null) return MoveResult.CELL_TAKEN;
board.place(row, col, p.mark()); history.addLast(new Move(p, row, col)); if (rule.record(row, col, p.mark())) { status = GameStatus.WON; winner = p; } else if (history.size() == n * n) { status = GameStatus.DRAW; } return MoveResult.OK; }
// Takes back the last move, a winning one included. Everything play() changed is // reversed here, so the game is exactly as it was one move earlier. boolean undo() { Move last = history.pollLast(); if (last == null) return false; // nothing to undo rule.undo(last.row(), last.col(), last.player().mark()); board.clear(last.row(), last.col()); status = GameStatus.IN_PROGRESS; // a game one move earlier was still open winner = null; return true; }}# One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.@dataclass(frozen=True)class Move: player: Player row: int col: int
# Decides when a move wins. Whoever records a move must be able to take it back.class WinRule(Protocol): def record(self, row: int, col: int, m: Mark) -> bool: ...
def undo(self, row: int, col: int, m: Mark) -> None: ...
# Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and diagonal,# where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract what record# added. A rule that rescanned the board would get undo for free; one that cached "lines nearly# won" would have to rebuild them.class LineCounterRule: def __init__(self, n: int): self._n = n self._rows, self._cols = [0] * n, [0] * n self._diag = self._anti = 0
# Adds this move to its row, column and diagonals, and says whether any of them is now full # of one mark. def record(self, row: int, col: int, m: Mark) -> bool: self._add(row, col, 1 if m is Mark.X else -1) lines = (self._rows[row], self._cols[col], self._diag, self._anti) return any(abs(v) == self._n for v in lines)
# Subtracts exactly what record added. def undo(self, row: int, col: int, m: Mark) -> None: self._add(row, col, -1 if m is Mark.X else 1)
# One place that knows which counters a cell touches, shared by record and undo. def _add(self, row: int, col: int, d: int) -> None: self._rows[row] += d self._cols[col] += d if row == col: self._diag += d if row + col == self._n - 1: self._anti += d
class Game: def __init__(self, size: int, first: Player, second: Player, rule: WinRule | None = None): if first.mark is second.mark: raise ValueError("players need different marks") self._board = Board(size) self._rule = rule or LineCounterRule(size) self._players = (first, second) self._history: list[Move] = [] # newest last; its length replaces the move count self.status = GameStatus.IN_PROGRESS self.winner: Player | None = None
def current(self) -> Player: return self._players[len(self._history) % 2]
# Read-only by convention: bots and UIs look, only play() changes it. @property def board(self) -> BoardView: return self._board
def play(self, p: Player, row: int, col: int) -> MoveResult: n = self._board.size if self.status is not GameStatus.IN_PROGRESS: return MoveResult.GAME_OVER if p != self.current(): return MoveResult.NOT_YOUR_TURN if not (0 <= row < n and 0 <= col < n): return MoveResult.OUT_OF_BOUNDS if self._board.at(row, col) is not None: return MoveResult.CELL_TAKEN
self._board.place(row, col, p.mark) self._history.append(Move(p, row, col)) if self._rule.record(row, col, p.mark): self.status, self.winner = GameStatus.WON, p elif len(self._history) == n * n: self.status = GameStatus.DRAW return MoveResult.OK
# Takes back the last move, a winning one included. Everything play() changed is # reversed here, so the game is exactly as it was one move earlier. def undo(self) -> bool: if not self._history: return False # nothing to undo last = self._history.pop() self._rule.undo(last.row, last.col, last.player.mark) self._board.clear(last.row, last.col) self.status, self.winner = GameStatus.IN_PROGRESS, None # one move earlier it was open return True// One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.public sealed record Move(Player Player, int Row, int Col);
// Decides when a move wins. Whoever records a move must be able to take it back.public interface IWinRule{ bool Record(int row, int col, Mark m); void Undo(int row, int col, Mark m);}
// Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and// diagonal, where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract// what Record added. A rule that rescanned the board would get undo for free; one that cached// "lines nearly won" would have to rebuild them.public sealed class LineCounterRule(int n) : IWinRule{ private readonly int[] _rows = new int[n], _cols = new int[n]; private int _diag, _anti;
// Adds this move to its row, column and diagonals, and says whether any of them is now full // of one mark. public bool Record(int row, int col, Mark m) { Add(row, col, m == Mark.X ? 1 : -1); return Math.Abs(_rows[row]) == n || Math.Abs(_cols[col]) == n || Math.Abs(_diag) == n || Math.Abs(_anti) == n; }
// Subtracts exactly what Record added. public void Undo(int row, int col, Mark m) => Add(row, col, m == Mark.X ? -1 : 1);
// One place that knows which counters a cell touches, shared by Record and Undo. private void Add(int row, int col, int d) { _rows[row] += d; _cols[col] += d; if (row == col) _diag += d; if (row + col == n - 1) _anti += d; }}
public sealed class Game{ private readonly Board _board; private readonly IWinRule _rule; private readonly Player[] _players; private readonly Stack<Move> _history = new(); // its count replaces the move counter public GameStatus Status { get; private set; } = GameStatus.InProgress; public Player? Winner { get; private set; }
public Game(int size, Player first, Player second, IWinRule? rule = null) { if (first.Mark == second.Mark) throw new ArgumentException("players need different marks"); _board = new Board(size); _rule = rule ?? new LineCounterRule(size); _players = [first, second]; }
public Player Current => _players[_history.Count % 2];
// Read-only, so a bot or a UI can look at the board but only Play can change it. public IBoardView Board => _board;
public MoveResult Play(Player p, int row, int col) { int n = _board.Size; if (Status != GameStatus.InProgress) return MoveResult.GameOver; if (p != Current) return MoveResult.NotYourTurn; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OutOfBounds; if (_board.At(row, col) is not null) return MoveResult.CellTaken;
_board.Place(row, col, p.Mark); _history.Push(new Move(p, row, col)); if (_rule.Record(row, col, p.Mark)) { Status = GameStatus.Won; Winner = p; } else if (_history.Count == n * n) Status = GameStatus.Draw; return MoveResult.Ok; }
// Takes back the last move, a winning one included. Everything Play changed is // reversed here, so the game is exactly as it was one move earlier. public bool Undo() { if (!_history.TryPop(out var last)) return false; // nothing to undo _rule.Undo(last.Row, last.Col, last.Player.Mark); _board.Clear(last.Row, last.Col); Status = GameStatus.InProgress; // a game one move earlier was still open Winner = null; return true; }}// Move is one accepted move. Kept in a history so it can be undone, replayed, or sent// to the other player.type Move struct { Player Player Row, Col int}
// WinRule decides when a move wins. Whoever records a move must be able to take it back.type WinRule interface { Record(row, col int, m Mark) bool Undo(row, col int, m Mark)}
// Record and Undo share add, the one place that knows which counters a cell touches.// Counters make undo trivial: subtract what Record added.func (r *LineCounterRule) Record(row, col int, m Mark) bool { r.add(row, col, sign(m)) full := func(v int) bool { return v == r.n || v == -r.n } return full(r.rows[row]) || full(r.cols[col]) || full(r.diag) || full(r.anti)}
func (r *LineCounterRule) Undo(row, col int, m Mark) { r.add(row, col, -sign(m)) }
func (r *LineCounterRule) add(row, col, d int) { r.rows[row] += d r.cols[col] += d if row == col { r.diag += d } if row+col == r.n-1 { r.anti += d }}
func sign(m Mark) int { if m == X { return 1 } return -1}
type Game struct { board *Board rule WinRule players [2]Player history []Move // newest last; its length replaces the move count Status GameStatus Winner *Player}
func (g *Game) Current() Player { return g.players[len(g.history)%2] }
// Board is read-only, so a bot or a UI can look but only Play can change it.func (g *Game) Board() BoardView { return g.board }
func (g *Game) Play(p Player, row, col int) error { n := g.board.Size() switch { case g.Status != InProgress: return ErrGameOver case p != g.Current(): return ErrNotYourTurn case row < 0 || col < 0 || row >= n || col >= n: return ErrOutOfBounds case g.board.At(row, col) != Empty: return ErrCellTaken } g.board.place(row, col, p.Mark) g.history = append(g.history, Move{p, row, col}) if g.rule.Record(row, col, p.Mark) { g.Status, g.Winner = Won, &p } else if len(g.history) == n*n { g.Status = Draw } return nil}
// Undo takes back the last move, a winning one included. Everything Play changed is// reversed here, so the game is exactly as it was one move earlier.func (g *Game) Undo() bool { if len(g.history) == 0 { return false // nothing to undo } last := g.history[len(g.history)-1] g.history = g.history[:len(g.history)-1] g.rule.Undo(last.Row, last.Col, last.Player.Mark) g.board.place(last.Row, last.Col, Empty) // Empty is the zero value: the cell is free again g.Status, g.Winner = InProgress, nil // a game one move earlier was still open return true}// One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.interface Move { readonly player: Player; readonly row: number; readonly col: number;}
// Decides when a move wins. Whoever records a move must be able to take it back.interface WinRule { record(row: number, col: number, m: Mark): boolean; undo(row: number, col: number, m: Mark): void;}
// Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and// diagonal, where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract// what record added. A rule that rescanned the board would get undo for free; one that cached// "lines nearly won" would have to rebuild them.class LineCounterRule implements WinRule { private readonly rows: number[]; private readonly cols: number[]; private diag = 0; private anti = 0;
constructor(private readonly n: number) { this.rows = Array(n).fill(0); this.cols = Array(n).fill(0); }
// Adds this move to its row, column and diagonals, and says whether any of them is now full // of one mark. record(row: number, col: number, m: Mark): boolean { this.add(row, col, m === 'X' ? 1 : -1); const full = (v: number) => Math.abs(v) === this.n; return full(this.rows[row]) || full(this.cols[col]) || full(this.diag) || full(this.anti); }
// Subtracts exactly what record added. undo(row: number, col: number, m: Mark): void { this.add(row, col, m === 'X' ? -1 : 1); }
// One place that knows which counters a cell touches, shared by record and undo. private add(row: number, col: number, d: number): void { this.rows[row] += d; this.cols[col] += d; if (row === col) this.diag += d; if (row + col === this.n - 1) this.anti += d; }}
class Game { private readonly _board: Board; private readonly players: readonly [Player, Player]; private readonly history: Move[] = []; // newest last; its length replaces the move count status: GameStatus = 'in-progress'; winner: Player | null = null;
constructor( size: number, first: Player, second: Player, private readonly rule: WinRule = new LineCounterRule(size), ) { if (first.mark === second.mark) throw new Error('players need different marks'); this._board = new Board(size); this.players = [first, second]; }
get current(): Player { return this.players[this.history.length % 2]; }
// Read-only, so a bot or a UI can look at the board but only play() can change it. get board(): BoardView { return this._board; }
play(p: Player, row: number, col: number): MoveResult { const n = this._board.size; if (this.status !== 'in-progress') return 'game-over'; if (p.name !== this.current.name || p.mark !== this.current.mark) return 'not-your-turn'; if (row < 0 || col < 0 || row >= n || col >= n) return 'out-of-bounds'; if (this._board.at(row, col) !== null) return 'cell-taken';
this._board.place(row, col, p.mark); this.history.push({ player: p, row, col }); if (this.rule.record(row, col, p.mark)) { this.status = 'won'; this.winner = p; } else if (this.history.length === n * n) { this.status = 'draw'; } return 'ok'; }
// Takes back the last move, a winning one included. Everything play() changed is // reversed here, so the game is exactly as it was one move earlier. undo(): boolean { const last = this.history.pop(); if (!last) return false; // nothing to undo this.rule.undo(last.row, last.col, last.player.mark); this._board.clear(last.row, last.col); this.status = 'in-progress'; // a game one move earlier was still open this.winner = null; return true; }}Bots live outside the game. A bot is anything that looks at the board, picks a cell, and calls
game.play, just like a person’s app would. To make sure a bot can only look, it gets a read-only
view of the board, called BoardView. The game never knows which players are bots. So a random
bot, a “win or block” bot, and an unbeatable bot are each just one class behind the BotStrategy
interface.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Read-only view of the board. Bots and UIs get this, so nothing outside Game can place a mark.interface BoardView { int size(); Mark at(int row, int col);}
// The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it// through the read-only BoardView.class Board implements BoardView { final int size; private final Mark[][] cells;
Board(int size) { this.size = size; this.cells = new Mark[size][size]; }
public int size() { return size; }
public Mark at(int row, int col) { return cells[row][col]; }
void place(int row, int col, Mark m) { cells[row][col] = m; }
void clear(int row, int col) { cells[row][col] = null; } // only undo calls this}
// A cell a bot wants to play. Game still validates it like any other move.record Cell(int row, int col) {}
// How a bot picks a move. A strategy, because random, win-or-block and minimax are// interchangeable, and Game doesn't need to know that any of them exist.interface BotStrategy { Cell choose(BoardView board, Mark me);}
// Plays any empty cell. The Random is injected, so a test can fix the seed and// get the same game every run.class RandomBot implements BotStrategy { private final Random random;
RandomBot(Random random) { this.random = random; }
public Cell choose(BoardView board, Mark me) { List<Cell> empty = new ArrayList<>(); for (int r = 0; r < board.size(); r++) for (int c = 0; c < board.size(); c++) if (board.at(r, c) == null) empty.add(new Cell(r, c)); return empty.get(random.nextInt(empty.size())); }}import random
# Read-only view of the board. Bots and UIs get this, so they have no way to place a mark.class BoardView(Protocol): size: int
def at(self, row: int, col: int) -> Mark | None: ...
# The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it# through the read-only BoardView.class Board: def __init__(self, size: int): self.size = size self._cells: list[list[Mark | None]] = [[None] * size for _ in range(size)]
def at(self, row: int, col: int) -> Mark | None: return self._cells[row][col]
def place(self, row: int, col: int, m: Mark) -> None: self._cells[row][col] = m
def clear(self, row: int, col: int) -> None: self._cells[row][col] = None # only undo calls this
# How a bot picks a move: a (row, col) the game still validates like any other move.# A strategy, because random, win-or-block and minimax are interchangeable.class BotStrategy(Protocol): def choose(self, board: BoardView, me: Mark) -> tuple[int, int]: ...
# Plays any empty cell. The Random is injected, so a test can fix the seed and# get the same game every run.class RandomBot: def __init__(self, rng: random.Random): self._rng = rng
def choose(self, board: BoardView, me: Mark) -> tuple[int, int]: n = board.size empty = [(r, c) for r in range(n) for c in range(n) if board.at(r, c) is None] return self._rng.choice(empty)// Read-only view of the board. Bots and UIs get this, so nothing outside Game can place a mark.public interface IBoardView{ int Size { get; } Mark? At(int row, int col);}
// The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it// through the read-only IBoardView.public sealed class Board(int size) : IBoardView{ public int Size { get; } = size; private readonly Mark?[,] _cells = new Mark?[size, size];
public Mark? At(int row, int col) => _cells[row, col];
internal void Place(int row, int col, Mark m) => _cells[row, col] = m;
internal void Clear(int row, int col) => _cells[row, col] = null; // only Undo calls this}
// A cell a bot wants to play. Game still validates it like any other move.public readonly record struct Cell(int Row, int Col);
// How a bot picks a move. A strategy, because random, win-or-block and minimax are// interchangeable, and Game doesn't need to know that any of them exist.public interface IBotStrategy{ Cell Choose(IBoardView board, Mark me);}
// Plays any empty cell. The Random is injected, so a test can fix the seed and// get the same game every run.public sealed class RandomBot(Random random) : IBotStrategy{ public Cell Choose(IBoardView board, Mark me) { var empty = new List<Cell>(); for (int r = 0; r < board.Size; r++) for (int c = 0; c < board.Size; c++) if (board.At(r, c) is null) empty.Add(new Cell(r, c)); return empty[random.Next(empty.Count)]; }}// BoardView is the read-only side of the board. Bots and UIs get this, so nothing outside// the package can place a mark.type BoardView interface { Size() int At(row, col int) Mark}
// Cell is where a bot wants to play. Game still validates it like any other move.type Cell struct{ Row, Col int }
// BotStrategy is how a bot picks a move. A strategy, because random, win-or-block and// minimax are interchangeable, and Game doesn't need to know that any of them exist.type BotStrategy interface { Choose(board BoardView, me Mark) Cell}
// RandomBot plays any empty cell. The source of randomness is injected, so a test can// fix the seed and get the same game every run.type RandomBot struct{ Rand *rand.Rand }
func (b RandomBot) Choose(board BoardView, me Mark) Cell { var empty []Cell for r := 0; r < board.Size(); r++ { for c := 0; c < board.Size(); c++ { if board.At(r, c) == Empty { empty = append(empty, Cell{r, c}) } } } return empty[b.Rand.IntN(len(empty))]}// Read-only view of the board. Bots and UIs get this, so nothing outside Game can place a mark.interface BoardView { readonly size: number; at(row: number, col: number): Mark | null;}
// The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it// through the read-only BoardView.class Board implements BoardView { private readonly cells: (Mark | null)[][];
constructor(readonly size: number) { this.cells = Array.from({ length: size }, () => Array<Mark | null>(size).fill(null)); }
at(row: number, col: number): Mark | null { return this.cells[row][col]; }
place(row: number, col: number, m: Mark): void { this.cells[row][col] = m; }
clear(row: number, col: number): void { this.cells[row][col] = null; // only undo calls this }}
// A cell a bot wants to play. Game still validates it like any other move.type Cell = readonly [row: number, col: number];
// How a bot picks a move. A strategy, because random, win-or-block and minimax are// interchangeable, and Game doesn't need to know that any of them exist.interface BotStrategy { choose(board: BoardView, me: Mark): Cell;}
// Plays any empty cell. The random source is injected, so a test can pass a seeded one// and get the same game every run.class RandomBot implements BotStrategy { constructor(private readonly random: () => number = Math.random) {}
choose(board: BoardView, me: Mark): Cell { const empty: Cell[] = []; for (let r = 0; r < board.size; r++) for (let c = 0; c < board.size; c++) if (board.at(r, c) === null) empty.push([r, c]); return empty[Math.floor(this.random() * empty.length)]; }}A bot’s turn is two lines in whatever runs the game: ask the bot for a cell, then call
game.play with it. The game checks the bot’s move like anyone else’s, so a buggy bot can’t break
the game.
Go deeperA bot's turn, step by step1 min
- Color key
- Interface
- Facade
There is one small cost. Something outside the game has to decide when the bot moves (after the person’s move comes back OK). In a game on one screen, that’s a small loop. It just isn’t the game’s job anymore.
Go deeperIsn't undo the Command pattern?1 min
Interviewer asksIsn't undo the Command pattern? Why no Command classes?
It’s the same idea: each move is a small object that knows enough to be reversed. A full Command
pattern (a PlaceMarkCommand class with execute and undo) pays off when there are many kinds
of action. Here there’s only one, placing a mark. So a Move value plus one undo method says
the same thing with less code. If the game grew other actions, like resigning, I’d add the
interface then.
This is enough for a mid-level answer. Moves are checked in one place, the win check only looks at the lines through the last move, and undo works from a history list. 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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;
// The two marks a cell can hold. A closed set of names, so an enum.enum Mark { X, O }
// The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it// through the read-only BoardView.class Board implements BoardView { final int size; private final Mark[][] cells;
Board(int size) { this.size = size; this.cells = new Mark[size][size]; }
public int size() { return size; }
public Mark at(int row, int col) { return cells[row][col]; }
void place(int row, int col, Mark m) { cells[row][col] = m; }
void clear(int row, int col) { cells[row][col] = null; } // only undo calls this}
// One game, shared by both players' connections. One lock guards all of it, so a move is// checked and placed as one step, and games never wait on each other.class Game { private final Object lock = new Object(); // one lock per game; games share nothing private final Board board; private final WinRule rule; private final Player[] players; private final Deque<Move> history = new ArrayDeque<>(); private GameStatus status = GameStatus.IN_PROGRESS; private Player winner;
Game(int size, WinRule rule, Player first, Player second) { if (first.mark() == second.mark()) throw new IllegalArgumentException("players need different marks"); this.board = new Board(size); this.rule = rule; this.players = new Player[] { first, second }; }
// moveNumber is the move the client thinks it is making: 0 for the first, 1 for the // second. A double tap or a network retry sends the same number twice, and the second // copy is turned away as STALE instead of being played as the opponent's move. MoveResult play(Player p, int moveNumber, int row, int col) { // The lock makes "check, then place" one step. Without it two requests can both // pass the checks before either one places its mark. synchronized (lock) { int n = board.size(); if (moveNumber != history.size()) return MoveResult.STALE; if (status != GameStatus.IN_PROGRESS) return MoveResult.GAME_OVER; if (!p.equals(players[history.size() % 2])) return MoveResult.NOT_YOUR_TURN; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OUT_OF_BOUNDS; if (board.at(row, col) != null) return MoveResult.CELL_TAKEN;
board.place(row, col, p.mark()); history.addLast(new Move(p, row, col)); if (rule.record(row, col, p.mark())) { status = GameStatus.WON; winner = p; } else if (history.size() == n * n) { status = GameStatus.DRAW; } return MoveResult.OK; } }
// Undo takes the same lock, so it can never interleave with a half-finished move. boolean undo() { synchronized (lock) { Move last = history.pollLast(); if (last == null) return false; rule.undo(last.row(), last.col(), last.player().mark()); board.clear(last.row(), last.col()); status = GameStatus.IN_PROGRESS; winner = null; return true; } }
// Turns alternate, so whose turn it is follows from how many moves were made. Player current() { synchronized (lock) { return players[history.size() % 2]; } }
Optional<Player> winner() { synchronized (lock) { return Optional.ofNullable(winner); } }
// Readers take the lock too: it is what guarantees they see a move's writes all together. GameStatus status() { synchronized (lock) { return status; } }
// The number the next move must carry. A client reads it to know where the game is. int moveNumber() { synchronized (lock) { return history.size(); } }}
// A player is a value: a name and the mark they play. Two equal records are the same// player, which is exactly what the turn check compares.record Player(String name, Mark mark) {}
// Where the game stands. Callers read it after a move instead of working it out.enum GameStatus { IN_PROGRESS, WON, DRAW }
// What a move gets back. STALE means the move was sent for a turn that has already been played.enum MoveResult { OK, GAME_OVER, NOT_YOUR_TURN, OUT_OF_BOUNDS, CELL_TAKEN, STALE }
// Decides when a move wins. Whoever records a move must be able to take it back.interface WinRule { boolean record(int row, int col, Mark m); void undo(int row, int col, Mark m);}
// Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and// diagonal, where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract// what record added. A rule that rescanned the board would get undo for free; one that cached// "lines nearly won" would have to rebuild them.class LineCounterRule implements WinRule { private final int n; private final int[] rows, cols; private int diag, anti;
LineCounterRule(int n) { this.n = n; this.rows = new int[n]; this.cols = new int[n]; }
// Adds this move to its row, column and diagonals, and says whether any of them is now full // of one mark. public boolean record(int row, int col, Mark m) { add(row, col, (m == Mark.X) ? 1 : -1); return Math.abs(rows[row]) == n || Math.abs(cols[col]) == n || Math.abs(diag) == n || Math.abs(anti) == n; }
// Subtracts exactly what record added. public void undo(int row, int col, Mark m) { add(row, col, (m == Mark.X) ? -1 : 1); }
// One place that knows which counters a cell touches, shared by record and undo. private void add(int row, int col, int d) { rows[row] += d; cols[col] += d; if (row == col) diag += d; if (row + col == n - 1) anti += d; }}
// Read-only view of the board. Bots and UIs get this, so nothing outside Game can place a mark.interface BoardView { int size(); Mark at(int row, int col);}
// One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.record Move(Player player, int row, int col) {}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom dataclasses import dataclassfrom enum import Enumfrom typing import Protocol
# The two marks a cell can hold. A closed set of names, so an enum.class Mark(Enum): X = "X" O = "O"
# The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it# through the read-only BoardView.class Board: def __init__(self, size: int): self.size = size self._cells: list[list[Mark | None]] = [[None] * size for _ in range(size)]
def at(self, row: int, col: int) -> Mark | None: return self._cells[row][col]
def place(self, row: int, col: int, m: Mark) -> None: self._cells[row][col] = m
def clear(self, row: int, col: int) -> None: self._cells[row][col] = None # only undo calls this
# One game, shared by both players' connections. One lock guards all of it, so a move is checked# and placed as one step, and games never wait on each other.class Game: def __init__(self, size: int, first: Player, second: Player, rule: WinRule | None = None): if first.mark is second.mark: raise ValueError("players need different marks") self._lock = threading.Lock() # one lock per game; games share nothing self._board = Board(size) self._rule = rule or LineCounterRule(size) self._players = (first, second) self._history: list[Move] = [] self.status = GameStatus.IN_PROGRESS self.winner: Player | None = None
# move_number is the move the client thinks it is making: 0 for the first, 1 for the # second. A double tap or a network retry sends the same number twice, and the second # copy is turned away as STALE instead of being played as the opponent's move. def play(self, p: Player, move_number: int, row: int, col: int) -> MoveResult: # The lock makes "check, then place" one step. Without it two requests can both # pass the checks before either one places its mark. with self._lock: n = self._board.size if move_number != len(self._history): return MoveResult.STALE if self.status is not GameStatus.IN_PROGRESS: return MoveResult.GAME_OVER if p != self._players[len(self._history) % 2]: return MoveResult.NOT_YOUR_TURN if not (0 <= row < n and 0 <= col < n): return MoveResult.OUT_OF_BOUNDS if self._board.at(row, col) is not None: return MoveResult.CELL_TAKEN
self._board.place(row, col, p.mark) self._history.append(Move(p, row, col)) if self._rule.record(row, col, p.mark): self.status, self.winner = GameStatus.WON, p elif len(self._history) == n * n: self.status = GameStatus.DRAW return MoveResult.OK
# Undo takes the same lock, so it can never interleave with a half-finished move. def undo(self) -> bool: with self._lock: if not self._history: return False last = self._history.pop() self._rule.undo(last.row, last.col, last.player.mark) self._board.clear(last.row, last.col) self.status, self.winner = GameStatus.IN_PROGRESS, None return True
# Turns alternate, so whose turn it is follows from how many moves were made. def current(self) -> Player: with self._lock: return self._players[len(self._history) % 2]
# The number the next move must carry. A client reads it to know where the game is. def move_number(self) -> int: with self._lock: return len(self._history)
# A player is a value: a name and the mark they play. Two equal players are the same# player, which is exactly what the turn check compares.@dataclass(frozen=True)class Player: name: str mark: Mark
# Where the game stands. Callers read it after a move instead of working it out.class GameStatus(Enum): IN_PROGRESS = "in progress" WON = "won" DRAW = "draw"
# What a move gets back. STALE means the move was sent for a turn that has already been played.class MoveResult(Enum): OK = "ok" GAME_OVER = "game over" NOT_YOUR_TURN = "not your turn" OUT_OF_BOUNDS = "out of bounds" CELL_TAKEN = "cell taken" STALE = "stale"
# Decides when a move wins. Whoever records a move must be able to take it back.class WinRule(Protocol): def record(self, row: int, col: int, m: Mark) -> bool: ...
def undo(self, row: int, col: int, m: Mark) -> None: ...
# Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and diagonal,# where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract what record# added. A rule that rescanned the board would get undo for free; one that cached "lines nearly# won" would have to rebuild them.class LineCounterRule: def __init__(self, n: int): self._n = n self._rows, self._cols = [0] * n, [0] * n self._diag = self._anti = 0
# Adds this move to its row, column and diagonals, and says whether any of them is now full # of one mark. def record(self, row: int, col: int, m: Mark) -> bool: self._add(row, col, 1 if m is Mark.X else -1) lines = (self._rows[row], self._cols[col], self._diag, self._anti) return any(abs(v) == self._n for v in lines)
# Subtracts exactly what record added. def undo(self, row: int, col: int, m: Mark) -> None: self._add(row, col, -1 if m is Mark.X else 1)
# One place that knows which counters a cell touches, shared by record and undo. def _add(self, row: int, col: int, d: int) -> None: self._rows[row] += d self._cols[col] += d if row == col: self._diag += d if row + col == self._n - 1: self._anti += d
# Read-only view of the board. Bots and UIs get this, so they have no way to place a mark.class BoardView(Protocol): size: int
def at(self, row: int, col: int) -> Mark | None: ...
# One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.@dataclass(frozen=True)class Move: player: Player row: int col: int// The complete design, built from every stage on this page.
// The two marks a cell can hold. A closed set of names, so an enum.public enum Mark { X, O }
// The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it// through the read-only IBoardView.public sealed class Board(int size) : IBoardView{ public int Size { get; } = size; private readonly Mark?[,] _cells = new Mark?[size, size];
public Mark? At(int row, int col) => _cells[row, col];
internal void Place(int row, int col, Mark m) => _cells[row, col] = m;
internal void Clear(int row, int col) => _cells[row, col] = null; // only Undo calls this}
// One game, shared by both players' connections. One lock guards all of it, so a move is// checked and placed as one step, and games never wait on each other.public sealed class Game{ private readonly Lock _lock = new(); // one lock per game; games share nothing private readonly Board _board; private readonly IWinRule _rule; private readonly Player[] _players; private readonly Stack<Move> _history = new(); private GameStatus _status = GameStatus.InProgress; private Player? _winner;
public Game(int size, Player first, Player second, IWinRule? rule = null) { if (first.Mark == second.Mark) throw new ArgumentException("players need different marks"); _board = new Board(size); _rule = rule ?? new LineCounterRule(size); _players = [first, second]; }
// moveNumber is the move the client thinks it is making: 0 for the first, 1 for the // second. A double tap or a network retry sends the same number twice, and the second // copy is turned away as Stale instead of being played as the opponent's move. public MoveResult Play(Player p, int moveNumber, int row, int col) { // The lock makes "check, then place" one step. Without it two requests can both // pass the checks before either one places its mark. lock (_lock) { int n = _board.Size; if (moveNumber != _history.Count) return MoveResult.Stale; if (_status != GameStatus.InProgress) return MoveResult.GameOver; if (p != _players[_history.Count % 2]) return MoveResult.NotYourTurn; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OutOfBounds; if (_board.At(row, col) is not null) return MoveResult.CellTaken;
_board.Place(row, col, p.Mark); _history.Push(new Move(p, row, col)); if (_rule.Record(row, col, p.Mark)) { _status = GameStatus.Won; _winner = p; } else if (_history.Count == n * n) _status = GameStatus.Draw; return MoveResult.Ok; } }
// Undo takes the same lock, so it can never interleave with a half-finished move. public bool Undo() { lock (_lock) { if (!_history.TryPop(out var last)) return false; _rule.Undo(last.Row, last.Col, last.Player.Mark); _board.Clear(last.Row, last.Col); _status = GameStatus.InProgress; _winner = null; return true; } }
// Readers take the lock too: it is what guarantees they see a move's writes all together. public Player Current { get { lock (_lock) return _players[_history.Count % 2]; } }
public Player? Winner { get { lock (_lock) return _winner; } }
public GameStatus Status { get { lock (_lock) return _status; } }
public int MoveNumber { get { lock (_lock) return _history.Count; } }}
// A player is a value: a name and the mark they play. Two equal records are the same// player, which is exactly what the turn check compares.public sealed record Player(string Name, Mark Mark);
// Where the game stands. Callers read it after a move instead of working it out.public enum GameStatus { InProgress, Won, Draw }
// What a move gets back. Stale means the move was sent for a turn that has already been played.public enum MoveResult { Ok, GameOver, NotYourTurn, OutOfBounds, CellTaken, Stale }
// Decides when a move wins. Whoever records a move must be able to take it back.public interface IWinRule{ bool Record(int row, int col, Mark m); void Undo(int row, int col, Mark m);}
// Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and// diagonal, where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract// what Record added. A rule that rescanned the board would get undo for free; one that cached// "lines nearly won" would have to rebuild them.public sealed class LineCounterRule(int n) : IWinRule{ private readonly int[] _rows = new int[n], _cols = new int[n]; private int _diag, _anti;
// Adds this move to its row, column and diagonals, and says whether any of them is now full // of one mark. public bool Record(int row, int col, Mark m) { Add(row, col, m == Mark.X ? 1 : -1); return Math.Abs(_rows[row]) == n || Math.Abs(_cols[col]) == n || Math.Abs(_diag) == n || Math.Abs(_anti) == n; }
// Subtracts exactly what Record added. public void Undo(int row, int col, Mark m) => Add(row, col, m == Mark.X ? -1 : 1);
// One place that knows which counters a cell touches, shared by Record and Undo. private void Add(int row, int col, int d) { _rows[row] += d; _cols[col] += d; if (row == col) _diag += d; if (row + col == n - 1) _anti += d; }}
// Read-only view of the board. Bots and UIs get this, so nothing outside Game can place a mark.public interface IBoardView{ int Size { get; } Mark? At(int row, int col);}
// One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.public sealed record Move(Player Player, int Row, int Col);// The complete design, built from every stage on this page.
package tictactoe
import ( "errors" "sync")
// Mark is what a cell holds. The zero value, Empty, means nobody has played there,// so a fresh board needs no initialisation.type Mark int
const ( Empty Mark = iota X O)
// Board owns the grid and nothing else. It doesn't know whose turn it is,// so a different game on the same grid (Gomoku, Connect Four) can reuse it.type Board struct { size int cells [][]Mark}
func NewBoard(size int) *Board { cells := make([][]Mark, size) for i := range cells { cells[i] = make([]Mark, size) } return &Board{size: size, cells: cells}}
func (b *Board) Size() int { return b.size }
func (b *Board) At(row, col int) Mark { return b.cells[row][col] }
func (b *Board) place(row, col int, m Mark) { b.cells[row][col] = m }
// HasLine rescans every row, column and diagonal after every move (flaw 2). Fine for 3x3,// O(n^2) per move on an n x n board. Stage 3 replaces it with O(1) counters.func (b *Board) HasLine(m Mark) bool { diag, anti := true, true for i := 0; i < b.size; i++ { row, col := true, true for j := 0; j < b.size; j++ { row = row && b.cells[i][j] == m col = col && b.cells[j][i] == m } if row || col { return true } diag = diag && b.cells[i][i] == m anti = anti && b.cells[i][b.size-1-i] == m } return diag || anti}
// Player is a value: a name and the mark they play. Structs compare by value,// so p == g.Current() is exactly the turn check we want.type Player struct { Name string Mark Mark}
// GameStatus is where the game stands. Callers read it after a move instead of working it out.type GameStatus int
const ( InProgress GameStatus = iota Won Draw)
// Every way a move can be refused. In a game people play, a bad move is expected// input, so each is a named error the UI can show.var ( ErrGameOver = errors.New("game over") ErrNotYourTurn = errors.New("not your turn") ErrOutOfBounds = errors.New("out of bounds") ErrCellTaken = errors.New("cell taken"))
// LineCounterRule is n in a row on an n x n board, in O(1) per move. One counter per row, per// column and per diagonal: X adds 1, O subtracts 1. A counter reaches +n or -n only when all n// cells of its line hold the same mark. That relies on the game never letting a taken cell be// played again.type LineCounterRule struct { n int rows, cols []int diag, anti int}
func NewLineCounterRule(n int) *LineCounterRule { return &LineCounterRule{n: n, rows: make([]int, n), cols: make([]int, n)}}
// NewGame takes the rule as a parameter; pass nil for the classic n in a row.func NewGame(size int, rule WinRule, first, second Player) (*Game, error) { if first.Mark == second.Mark { return nil, errors.New("players need different marks") } if rule == nil { rule = NewLineCounterRule(size) } return &Game{board: NewBoard(size), rule: rule, players: [2]Player{first, second}}, nil}
// Move is one accepted move. Kept in a history so it can be undone, replayed, or sent// to the other player.type Move struct { Player Player Row, Col int}
// WinRule decides when a move wins. Whoever records a move must be able to take it back.type WinRule interface { Record(row, col int, m Mark) bool Undo(row, col int, m Mark)}
// Record and Undo share add, the one place that knows which counters a cell touches.// Counters make undo trivial: subtract what Record added.func (r *LineCounterRule) Record(row, col int, m Mark) bool { r.add(row, col, sign(m)) full := func(v int) bool { return v == r.n || v == -r.n } return full(r.rows[row]) || full(r.cols[col]) || full(r.diag) || full(r.anti)}
func (r *LineCounterRule) Undo(row, col int, m Mark) { r.add(row, col, -sign(m)) }
func (r *LineCounterRule) add(row, col, d int) { r.rows[row] += d r.cols[col] += d if row == col { r.diag += d } if row+col == r.n-1 { r.anti += d }}
func sign(m Mark) int { if m == X { return 1 } return -1}
// ErrStale means the move was sent for a turn that has already been played.var ErrStale = errors.New("stale move")
// Game is one game, shared by both players' connections. One mutex guards all of it, so a move// is checked and placed as one step, and games never wait on each other.type Game struct { mu sync.Mutex // one lock per game; games share nothing board *Board rule WinRule players [2]Player history []Move status GameStatus // unexported now: read it through Status(), which takes the lock winner *Player}
// Play takes moveNumber, the move the client thinks it is making: 0 for the first, 1 for// the second. A double tap or a network retry sends the same number twice, and the second// copy is turned away as ErrStale instead of being played as the opponent's move.func (g *Game) Play(p Player, moveNumber, row, col int) error { // The lock makes "check, then place" one step. Without it two requests can both // pass the checks before either one places its mark. g.mu.Lock() defer g.mu.Unlock() n := g.board.Size() switch { case moveNumber != len(g.history): return ErrStale case g.status != InProgress: return ErrGameOver case p != g.players[len(g.history)%2]: return ErrNotYourTurn case row < 0 || col < 0 || row >= n || col >= n: return ErrOutOfBounds case g.board.At(row, col) != Empty: return ErrCellTaken } g.board.place(row, col, p.Mark) g.history = append(g.history, Move{p, row, col}) if g.rule.Record(row, col, p.Mark) { g.status, g.winner = Won, &p } else if len(g.history) == n*n { g.status = Draw } return nil}
// Readers take the lock too: it is what guarantees they see a move's writes together.func (g *Game) Status() GameStatus { g.mu.Lock() defer g.mu.Unlock() return g.status}
// Current: turns alternate, so whose turn it is follows from how many moves were made.func (g *Game) Current() Player { g.mu.Lock() defer g.mu.Unlock() return g.players[len(g.history)%2]}
// Undo takes the same lock, so it can never interleave with a half-finished move.func (g *Game) Undo() bool { g.mu.Lock() defer g.mu.Unlock() if len(g.history) == 0 { return false } last := g.history[len(g.history)-1] g.history = g.history[:len(g.history)-1] g.rule.Undo(last.Row, last.Col, last.Player.Mark) g.board.place(last.Row, last.Col, Empty) g.status, g.winner = InProgress, nil return true}// The complete design, built from every stage on this page.
// The two marks a cell can hold. A string union is TypeScript's lightweight enum.type Mark = 'X' | 'O';
// The grid. Only the game changes it: place on a move, clear on undo. Bots and UIs see it// through the read-only BoardView.class Board implements BoardView { private readonly cells: (Mark | null)[][];
constructor(readonly size: number) { this.cells = Array.from({ length: size }, () => Array<Mark | null>(size).fill(null)); }
at(row: number, col: number): Mark | null { return this.cells[row][col]; }
place(row: number, col: number, m: Mark): void { this.cells[row][col] = m; }
clear(row: number, col: number): void { this.cells[row][col] = null; // only undo calls this }}
// One game, shared by both players' connections. Node runs one call at a time, so a move is// checked and placed as one step without a lock.class Game { private readonly _board: Board; private readonly players: readonly [Player, Player]; private readonly history: Move[] = []; status: GameStatus = 'in-progress'; winner: Player | null = null;
constructor( size: number, first: Player, second: Player, private readonly rule: WinRule = new LineCounterRule(size), ) { if (first.mark === second.mark) throw new Error('players need different marks'); this._board = new Board(size); this.players = [first, second]; }
// Turns alternate, so whose turn it is follows from how many moves were made. get current(): Player { return this.players[this.history.length % 2]; }
// The number the next move must carry. A client reads it to know where the game is. get moveNumber(): number { return this.history.length; }
// No lock: in one Node process nothing can interrupt play() while it runs, so two // requests can't both pass the checks. The move number is still needed: a double tap // or a retry sends the same number twice, and the second copy is turned away as // 'stale' instead of being played as the opponent's move. play(p: Player, moveNumber: number, row: number, col: number): MoveResult { const n = this._board.size; if (moveNumber !== this.history.length) return 'stale'; if (this.status !== 'in-progress') return 'game-over'; const turn = this.players[this.history.length % 2]; if (p.name !== turn.name || p.mark !== turn.mark) return 'not-your-turn'; if (row < 0 || col < 0 || row >= n || col >= n) return 'out-of-bounds'; if (this._board.at(row, col) !== null) return 'cell-taken';
this._board.place(row, col, p.mark); this.history.push({ player: p, row, col }); if (this.rule.record(row, col, p.mark)) { this.status = 'won'; this.winner = p; } else if (this.history.length === n * n) { this.status = 'draw'; } // If there were an `await` above this line (say, saving to a database first), another // request could run in the gap, and the race would be back. Keep play() synchronous. return 'ok'; }
undo(): boolean { const last = this.history.pop(); if (!last) return false; this.rule.undo(last.row, last.col, last.player.mark); this._board.clear(last.row, last.col); this.status = 'in-progress'; this.winner = null; return true; }}
// A player is a value: a name and the mark they play.interface Player { readonly name: string; readonly mark: Mark;}
// Where the game stands. Callers read it after a move instead of working it out.type GameStatus = 'in-progress' | 'won' | 'draw';
// What a move gets back. 'stale' means the move was sent for a turn that has already been// played.type MoveResult = 'ok' | 'game-over' | 'not-your-turn' | 'out-of-bounds' | 'cell-taken' | 'stale';
// Decides when a move wins. Whoever records a move must be able to take it back.interface WinRule { record(row: number, col: number, m: Mark): boolean; undo(row: number, col: number, m: Mark): void;}
// Wins n in a row on an n x n board, in O(1) per move: one counter per row, column and// diagonal, where X adds 1 and O subtracts 1. The counters also make undo trivial: subtract// what record added. A rule that rescanned the board would get undo for free; one that cached// "lines nearly won" would have to rebuild them.class LineCounterRule implements WinRule { private readonly rows: number[]; private readonly cols: number[]; private diag = 0; private anti = 0;
constructor(private readonly n: number) { this.rows = Array(n).fill(0); this.cols = Array(n).fill(0); }
// Adds this move to its row, column and diagonals, and says whether any of them is now full // of one mark. record(row: number, col: number, m: Mark): boolean { this.add(row, col, m === 'X' ? 1 : -1); const full = (v: number) => Math.abs(v) === this.n; return full(this.rows[row]) || full(this.cols[col]) || full(this.diag) || full(this.anti); }
// Subtracts exactly what record added. undo(row: number, col: number, m: Mark): void { this.add(row, col, m === 'X' ? -1 : 1); }
// One place that knows which counters a cell touches, shared by record and undo. private add(row: number, col: number, d: number): void { this.rows[row] += d; this.cols[col] += d; if (row === col) this.diag += d; if (row + col === this.n - 1) this.anti += d; }}
// Read-only view of the board. Bots and UIs get this, so nothing outside Game can place a mark.interface BoardView { readonly size: number; at(row: number, col: number): Mark | null;}
// One accepted move. Kept in a history so it can be undone, replayed, or sent to the other player.interface Move { readonly player: Player; readonly row: number; readonly col: number;}Three ideas hold it together:
- One source of truth for progress. The history list decides whose turn it is and how many moves were played. Nothing else stores either one, so undo can’t leave them out of step.
- Only
Gamechanges the board. The methods that place and clear marks can’t be called from outside the package. Everyone else gets the read-onlyBoardView. That’s why a buggy bot or screen can’t do any harm. To its callers,Gameis a simple front door (a facade) withplay,undoand a view of the board. - The two rules that vary sit behind interfaces (the amber boxes): how a win is decided, and
how a bot picks a move. Gomoku is a new
WinRule. A smarter bot is a newBotStrategy. That’s the Strategy pattern twice.
Stress it
Section titled “Stress it”The design works for two people on one screen. Now we put it online, where two requests can arrive at the same moment.
One turn, two requests
Section titled “One turn, two requests”It’s online now. Player X double-taps a cell, and both requests reach the server. What happens?
Whether you see that this is two problems: a race between threads, and a repeated request that isn’t a race at all. A lock fixes the first but not the second. Saying “add synchronized” and stopping there is the mid-level answer they’re trying to get past.
There are two different ways the same turn can be played twice.
The first is a race. Two requests run at the same time, on two server threads. Both check “is it X’s turn?” before either one places a mark. Both checks pass, and both moves land.
The second is a late copy. A request arrives after its turn is already over. For example, the phone retried after a lost response, or a tap was still on its way when the move was undone. Our normal checks catch some late copies by luck, but with a confusing answer (“cell taken” for your own move). And they miss one case completely. After an undo, it’s X’s turn again and the cell is free. So a late copy of the undone move passes every check and quietly plays it again.
First, fix the race. Put one lock around each game. play and undo both hold it while they
run. Now a move’s checks and its changes happen as one step, and no other request can get in
between. Readers take the lock too, so they always see a whole move or none of it.
A lock doesn’t fix the late copy, though. The late copy doesn’t overlap with anything. It simply arrives later. So we add a move number. The app sends the number of the move it’s making: “this is move 4”. If the game has already played move 4, the request is refused as stale. Now each turn can only be played once, in whatever order the copies arrive.
| Option | How | Fixes the race | Fixes the late copy |
|---|---|---|---|
| 1. A lock per game | play and undo hold the game’s lock |
Yes | No |
| 2. One thread per game | All requests for a game go through its own queue | Yes | No |
| 3. Expected move number | The client sends which move it is making; a mismatch is stale | No, alone | Yes |
| 4. Lock plus move number (our choice) | Both | Yes | Yes |
- Color key
- Facade
// What a move gets back. STALE means the move was sent for a turn that has already been played.enum MoveResult { OK, GAME_OVER, NOT_YOUR_TURN, OUT_OF_BOUNDS, CELL_TAKEN, STALE }
// One game, shared by both players' connections. One lock guards all of it, so a move is// checked and placed as one step, and games never wait on each other.class Game { private final Object lock = new Object(); // one lock per game; games share nothing private final Board board; private final WinRule rule; private final Player[] players; private final Deque<Move> history = new ArrayDeque<>(); private GameStatus status = GameStatus.IN_PROGRESS; private Player winner;
Game(int size, WinRule rule, Player first, Player second) { if (first.mark() == second.mark()) throw new IllegalArgumentException("players need different marks"); this.board = new Board(size); this.rule = rule; this.players = new Player[] { first, second }; }
// moveNumber is the move the client thinks it is making: 0 for the first, 1 for the // second. A double tap or a network retry sends the same number twice, and the second // copy is turned away as STALE instead of being played as the opponent's move. MoveResult play(Player p, int moveNumber, int row, int col) { // The lock makes "check, then place" one step. Without it two requests can both // pass the checks before either one places its mark. synchronized (lock) { int n = board.size(); if (moveNumber != history.size()) return MoveResult.STALE; if (status != GameStatus.IN_PROGRESS) return MoveResult.GAME_OVER; if (!p.equals(players[history.size() % 2])) return MoveResult.NOT_YOUR_TURN; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OUT_OF_BOUNDS; if (board.at(row, col) != null) return MoveResult.CELL_TAKEN;
board.place(row, col, p.mark()); history.addLast(new Move(p, row, col)); if (rule.record(row, col, p.mark())) { status = GameStatus.WON; winner = p; } else if (history.size() == n * n) { status = GameStatus.DRAW; } return MoveResult.OK; } }
// Undo takes the same lock, so it can never interleave with a half-finished move. boolean undo() { synchronized (lock) { Move last = history.pollLast(); if (last == null) return false; rule.undo(last.row(), last.col(), last.player().mark()); board.clear(last.row(), last.col()); status = GameStatus.IN_PROGRESS; winner = null; return true; } }
// Turns alternate, so whose turn it is follows from how many moves were made. Player current() { synchronized (lock) { return players[history.size() % 2]; } }
Optional<Player> winner() { synchronized (lock) { return Optional.ofNullable(winner); } }
// Readers take the lock too: it is what guarantees they see a move's writes all together. GameStatus status() { synchronized (lock) { return status; } }
// The number the next move must carry. A client reads it to know where the game is. int moveNumber() { synchronized (lock) { return history.size(); } }}import threading
# What a move gets back. STALE means the move was sent for a turn that has already been played.class MoveResult(Enum): OK = "ok" GAME_OVER = "game over" NOT_YOUR_TURN = "not your turn" OUT_OF_BOUNDS = "out of bounds" CELL_TAKEN = "cell taken" STALE = "stale"
# One game, shared by both players' connections. One lock guards all of it, so a move is checked# and placed as one step, and games never wait on each other.class Game: def __init__(self, size: int, first: Player, second: Player, rule: WinRule | None = None): if first.mark is second.mark: raise ValueError("players need different marks") self._lock = threading.Lock() # one lock per game; games share nothing self._board = Board(size) self._rule = rule or LineCounterRule(size) self._players = (first, second) self._history: list[Move] = [] self.status = GameStatus.IN_PROGRESS self.winner: Player | None = None
# move_number is the move the client thinks it is making: 0 for the first, 1 for the # second. A double tap or a network retry sends the same number twice, and the second # copy is turned away as STALE instead of being played as the opponent's move. def play(self, p: Player, move_number: int, row: int, col: int) -> MoveResult: # The lock makes "check, then place" one step. Without it two requests can both # pass the checks before either one places its mark. with self._lock: n = self._board.size if move_number != len(self._history): return MoveResult.STALE if self.status is not GameStatus.IN_PROGRESS: return MoveResult.GAME_OVER if p != self._players[len(self._history) % 2]: return MoveResult.NOT_YOUR_TURN if not (0 <= row < n and 0 <= col < n): return MoveResult.OUT_OF_BOUNDS if self._board.at(row, col) is not None: return MoveResult.CELL_TAKEN
self._board.place(row, col, p.mark) self._history.append(Move(p, row, col)) if self._rule.record(row, col, p.mark): self.status, self.winner = GameStatus.WON, p elif len(self._history) == n * n: self.status = GameStatus.DRAW return MoveResult.OK
# Undo takes the same lock, so it can never interleave with a half-finished move. def undo(self) -> bool: with self._lock: if not self._history: return False last = self._history.pop() self._rule.undo(last.row, last.col, last.player.mark) self._board.clear(last.row, last.col) self.status, self.winner = GameStatus.IN_PROGRESS, None return True
# Turns alternate, so whose turn it is follows from how many moves were made. def current(self) -> Player: with self._lock: return self._players[len(self._history) % 2]
# The number the next move must carry. A client reads it to know where the game is. def move_number(self) -> int: with self._lock: return len(self._history)// What a move gets back. Stale means the move was sent for a turn that has already been played.public enum MoveResult { Ok, GameOver, NotYourTurn, OutOfBounds, CellTaken, Stale }
// One game, shared by both players' connections. One lock guards all of it, so a move is// checked and placed as one step, and games never wait on each other.public sealed class Game{ private readonly Lock _lock = new(); // one lock per game; games share nothing private readonly Board _board; private readonly IWinRule _rule; private readonly Player[] _players; private readonly Stack<Move> _history = new(); private GameStatus _status = GameStatus.InProgress; private Player? _winner;
public Game(int size, Player first, Player second, IWinRule? rule = null) { if (first.Mark == second.Mark) throw new ArgumentException("players need different marks"); _board = new Board(size); _rule = rule ?? new LineCounterRule(size); _players = [first, second]; }
// moveNumber is the move the client thinks it is making: 0 for the first, 1 for the // second. A double tap or a network retry sends the same number twice, and the second // copy is turned away as Stale instead of being played as the opponent's move. public MoveResult Play(Player p, int moveNumber, int row, int col) { // The lock makes "check, then place" one step. Without it two requests can both // pass the checks before either one places its mark. lock (_lock) { int n = _board.Size; if (moveNumber != _history.Count) return MoveResult.Stale; if (_status != GameStatus.InProgress) return MoveResult.GameOver; if (p != _players[_history.Count % 2]) return MoveResult.NotYourTurn; if (row < 0 || col < 0 || row >= n || col >= n) return MoveResult.OutOfBounds; if (_board.At(row, col) is not null) return MoveResult.CellTaken;
_board.Place(row, col, p.Mark); _history.Push(new Move(p, row, col)); if (_rule.Record(row, col, p.Mark)) { _status = GameStatus.Won; _winner = p; } else if (_history.Count == n * n) _status = GameStatus.Draw; return MoveResult.Ok; } }
// Undo takes the same lock, so it can never interleave with a half-finished move. public bool Undo() { lock (_lock) { if (!_history.TryPop(out var last)) return false; _rule.Undo(last.Row, last.Col, last.Player.Mark); _board.Clear(last.Row, last.Col); _status = GameStatus.InProgress; _winner = null; return true; } }
// Readers take the lock too: it is what guarantees they see a move's writes all together. public Player Current { get { lock (_lock) return _players[_history.Count % 2]; } }
public Player? Winner { get { lock (_lock) return _winner; } }
public GameStatus Status { get { lock (_lock) return _status; } }
public int MoveNumber { get { lock (_lock) return _history.Count; } }}// ErrStale means the move was sent for a turn that has already been played.var ErrStale = errors.New("stale move")
// Game is one game, shared by both players' connections. One mutex guards all of it, so a move// is checked and placed as one step, and games never wait on each other.type Game struct { mu sync.Mutex // one lock per game; games share nothing board *Board rule WinRule players [2]Player history []Move status GameStatus // unexported now: read it through Status(), which takes the lock winner *Player}
// Play takes moveNumber, the move the client thinks it is making: 0 for the first, 1 for// the second. A double tap or a network retry sends the same number twice, and the second// copy is turned away as ErrStale instead of being played as the opponent's move.func (g *Game) Play(p Player, moveNumber, row, col int) error { // The lock makes "check, then place" one step. Without it two requests can both // pass the checks before either one places its mark. g.mu.Lock() defer g.mu.Unlock() n := g.board.Size() switch { case moveNumber != len(g.history): return ErrStale case g.status != InProgress: return ErrGameOver case p != g.players[len(g.history)%2]: return ErrNotYourTurn case row < 0 || col < 0 || row >= n || col >= n: return ErrOutOfBounds case g.board.At(row, col) != Empty: return ErrCellTaken } g.board.place(row, col, p.Mark) g.history = append(g.history, Move{p, row, col}) if g.rule.Record(row, col, p.Mark) { g.status, g.winner = Won, &p } else if len(g.history) == n*n { g.status = Draw } return nil}
// Readers take the lock too: it is what guarantees they see a move's writes together.func (g *Game) Status() GameStatus { g.mu.Lock() defer g.mu.Unlock() return g.status}
// Current: turns alternate, so whose turn it is follows from how many moves were made.func (g *Game) Current() Player { g.mu.Lock() defer g.mu.Unlock() return g.players[len(g.history)%2]}
// Undo takes the same lock, so it can never interleave with a half-finished move.func (g *Game) Undo() bool { g.mu.Lock() defer g.mu.Unlock() if len(g.history) == 0 { return false } last := g.history[len(g.history)-1] g.history = g.history[:len(g.history)-1] g.rule.Undo(last.Row, last.Col, last.Player.Mark) g.board.place(last.Row, last.Col, Empty) g.status, g.winner = InProgress, nil return true}// What a move gets back. 'stale' means the move was sent for a turn that has already been// played.type MoveResult = 'ok' | 'game-over' | 'not-your-turn' | 'out-of-bounds' | 'cell-taken' | 'stale';
// One game, shared by both players' connections. Node runs one call at a time, so a move is// checked and placed as one step without a lock.class Game { private readonly _board: Board; private readonly players: readonly [Player, Player]; private readonly history: Move[] = []; status: GameStatus = 'in-progress'; winner: Player | null = null;
constructor( size: number, first: Player, second: Player, private readonly rule: WinRule = new LineCounterRule(size), ) { if (first.mark === second.mark) throw new Error('players need different marks'); this._board = new Board(size); this.players = [first, second]; }
// Turns alternate, so whose turn it is follows from how many moves were made. get current(): Player { return this.players[this.history.length % 2]; }
// The number the next move must carry. A client reads it to know where the game is. get moveNumber(): number { return this.history.length; }
// No lock: in one Node process nothing can interrupt play() while it runs, so two // requests can't both pass the checks. The move number is still needed: a double tap // or a retry sends the same number twice, and the second copy is turned away as // 'stale' instead of being played as the opponent's move. play(p: Player, moveNumber: number, row: number, col: number): MoveResult { const n = this._board.size; if (moveNumber !== this.history.length) return 'stale'; if (this.status !== 'in-progress') return 'game-over'; const turn = this.players[this.history.length % 2]; if (p.name !== turn.name || p.mark !== turn.mark) return 'not-your-turn'; if (row < 0 || col < 0 || row >= n || col >= n) return 'out-of-bounds'; if (this._board.at(row, col) !== null) return 'cell-taken';
this._board.place(row, col, p.mark); this.history.push({ player: p, row, col }); if (this.rule.record(row, col, p.mark)) { this.status = 'won'; this.winner = p; } else if (this.history.length === n * n) { this.status = 'draw'; } // If there were an `await` above this line (say, saving to a database first), another // request could run in the gap, and the race would be back. Keep play() synchronous. return 'ok'; }
undo(): boolean { const last = this.history.pop(); if (!last) return false; this.rule.undo(last.row, last.col, last.player.mark); this._board.clear(last.row, last.col); this.status = 'in-progress'; this.winner = null; return true; }}Why is this correct? Every read and write of a game’s state happens under its lock, so a move’s checks and its changes can’t be split by another request. And each request names the turn it belongs to. Once that turn is played, every later copy of it is refused.
What does it cost? Very little. At most two players touch a game, and a move takes microseconds, so nobody waits long. Games never share a lock, so a million games work like one. The one limit is that a lock only works inside one program. If two servers held copies of the same game, the move number check would have to move into the database.
Interviewer asksHow does the app know which move number to send?
Every response includes the game’s current move number, and so does the board the app draws. The app sends the number it last saw. If it’s behind, the server answers “stale” and the app reloads the board, which is what the player needed to see anyway.
Go deeperHow each language does the lock, and the database version2 min
Java uses a private lock object instead of synchronized methods, so no outside code can lock on
the game and stall it. C# 13 has a dedicated Lock type. Go uses a sync.Mutex with defer, so
the lock is released on every return path. Python uses a threading.Lock in a with block.
TypeScript in one Node process needs no lock at all, because nothing interrupts a play call
while it runs. It still needs the move number, because retries can happen anywhere.
If the game is stored in a database, the move number becomes an update with a condition:
UPDATE games SET board = ?, move_count = move_count + 1WHERE id = ? AND move_count = ?;-- 1 row changed: the move was played. 0 rows changed: the request was stale.The database checks the condition and makes the change as one step, so two servers can’t both play the same move.
Go deeperWhy not a read-write lock?1 min
Screens read the board far more often than players move, so a read-write lock sounds like a good fit. But reading a board takes nanoseconds, and there are only two players. A read-write lock costs more per call than a plain lock, and it only helps when many readers compete, which a two-player game never has. If spectators were watching, we’d publish a frozen copy of the board after each move and let them read that without any lock.
How you’d prove it works
Section titled “How you’d prove it works”Name each test after the promise it protects. Then a failing test tells you which promise broke. These matter most:
- A win is found on every kind of line (row, column, both diagonals), on a board bigger than 3x3, so nothing is hard-coded.
- A full board with no line is a draw, and a line on the last cell is a win.
- Every illegal move gets its own result and changes nothing.
- Undoing a winning move reopens the game and reverses the counters.
- Undo hands the turn back to the player whose move was undone.
- Two requests for the same turn result in exactly one move.
The undo test is the one that catches real bugs. It plays a win, undoes it, then plays a move that would only count as a win if the counters were left behind. The race test sends nine requests for move 0 at the same moment, each for a different cell, and checks that exactly one landed. Remove the lock and it fails most runs. A race test can pass by luck, so pair it with the reasoning above.
Go deeperThe tests in five languages2 min
static final Player X = new Player("Ana", Mark.X), O = new Player("Bo", Mark.O);
// Plays (row, col) pairs alternately for X and O, numbering moves as a client would.static Game play(int size, int... cells) { Game g = new Game(size, new LineCounterRule(size), X, O); for (int i = 0; i < cells.length; i += 2) { assertEquals(MoveResult.OK, g.play(i % 4 == 0 ? X : O, i / 2, cells[i], cells[i + 1])); } return g;}
@Testvoid antiDiagonalWinsOnAFourByFourBoard() { // X takes (0,3) (1,2) (2,1) (3,0); O plays elsewhere. The counters work for any n. Game g = play(4, 0, 3, 0, 0, 1, 2, 1, 0, 2, 1, 2, 0, 3, 0); assertEquals(GameStatus.WON, g.status());}
@Testvoid fullBoardWithNoLineIsADraw() { // X O X / X O O / O X X: nine moves, no line. Game g = play(3, 0, 0, 0, 1, 0, 2, 1, 1, 1, 0, 1, 2, 2, 1, 2, 0, 2, 2); assertEquals(GameStatus.DRAW, g.status());}
@Testvoid undoingAWinReopensTheGameAndTheCounters() { Game g = play(3, 0, 0, 1, 0, 0, 1, 1, 1, 0, 2); // X wins the top row assertTrue(g.undo()); assertEquals(GameStatus.IN_PROGRESS, g.status()); // If undo forgot the counters, X's row would still read +3 and this move would "win". assertEquals(MoveResult.OK, g.play(X, 4, 2, 2)); assertEquals(GameStatus.IN_PROGRESS, g.status());}
@Testvoid racingRequestsForOneTurnPlayExactlyOnce() throws Exception { // Nine requests all claim to be move 0, each for a different cell. One may win. Game g = new Game(3, new LineCounterRule(3), X, O); ExecutorService pool = Executors.newFixedThreadPool(9); CountDownLatch start = new CountDownLatch(1); // the starting gun List<Future<MoveResult>> results = new ArrayList<>(); for (int cell = 0; cell < 9; cell++) { int r = cell / 3, c = cell % 3; results.add(pool.submit(() -> { start.await(); return g.play(X, 0, r, c); })); } start.countDown(); // release every thread at once int ok = 0; for (var f : results) if (f.get() == MoveResult.OK) ok++; assertEquals(1, ok); // exactly one move landed; the rest were STALE assertEquals(1, g.moveNumber());}from concurrent.futures import ThreadPoolExecutor
X, O = Player("Ana", Mark.X), Player("Bo", Mark.O)
# Plays (row, col) pairs alternately for X and O, numbering moves as a client would.def play(size: int, *cells: int) -> Game: g = Game(size, X, O) for i in range(0, len(cells), 2): p = X if i % 4 == 0 else O assert g.play(p, i // 2, cells[i], cells[i + 1]) is MoveResult.OK return g
def test_anti_diagonal_wins_on_a_four_by_four_board(): # X takes (0,3) (1,2) (2,1) (3,0); O plays elsewhere. The counters work for any n. g = play(4, 0, 3, 0, 0, 1, 2, 1, 0, 2, 1, 2, 0, 3, 0) assert g.status is GameStatus.WON
def test_full_board_with_no_line_is_a_draw(): # X O X / X O O / O X X: nine moves, no line. g = play(3, 0, 0, 0, 1, 0, 2, 1, 1, 1, 0, 1, 2, 2, 1, 2, 0, 2, 2) assert g.status is GameStatus.DRAW
def test_undoing_a_win_reopens_the_game_and_the_counters(): g = play(3, 0, 0, 1, 0, 0, 1, 1, 1, 0, 2) # X wins the top row assert g.undo() assert g.status is GameStatus.IN_PROGRESS # If undo forgot the counters, X's row would still read +3 and this move would "win". assert g.play(X, 4, 2, 2) is MoveResult.OK assert g.status is GameStatus.IN_PROGRESS
def test_racing_requests_for_one_turn_play_exactly_once(): # Nine requests all claim to be move 0, each for a different cell. One may win. g = Game(3, X, O) start = threading.Barrier(9) # releases once all nine threads are waiting
def attempt(cell: int) -> MoveResult: start.wait() return g.play(X, 0, cell // 3, cell % 3)
with ThreadPoolExecutor(max_workers=9) as pool: results = list(pool.map(attempt, range(9))) assert results.count(MoveResult.OK) == 1 # exactly one move landed; the rest were STALE assert g.move_number() == 1private static readonly Player X = new("Ana", Mark.X), O = new("Bo", Mark.O);
// Plays (row, col) pairs alternately for X and O, numbering moves as a client would.private static Game Play(int size, params int[] cells){ var g = new Game(size, X, O); for (int i = 0; i < cells.Length; i += 2) Assert.Equal(MoveResult.Ok, g.Play(i % 4 == 0 ? X : O, i / 2, cells[i], cells[i + 1])); return g;}
[Fact]public void AntiDiagonalWinsOnAFourByFourBoard(){ // X takes (0,3) (1,2) (2,1) (3,0); O plays elsewhere. The counters work for any n. var g = Play(4, 0, 3, 0, 0, 1, 2, 1, 0, 2, 1, 2, 0, 3, 0); Assert.Equal(GameStatus.Won, g.Status);}
[Fact]public void FullBoardWithNoLineIsADraw(){ // X O X / X O O / O X X: nine moves, no line. var g = Play(3, 0, 0, 0, 1, 0, 2, 1, 1, 1, 0, 1, 2, 2, 1, 2, 0, 2, 2); Assert.Equal(GameStatus.Draw, g.Status);}
[Fact]public void UndoingAWinReopensTheGameAndTheCounters(){ var g = Play(3, 0, 0, 1, 0, 0, 1, 1, 1, 0, 2); // X wins the top row Assert.True(g.Undo()); Assert.Equal(GameStatus.InProgress, g.Status); // If Undo forgot the counters, X's row would still read +3 and this move would "win". Assert.Equal(MoveResult.Ok, g.Play(X, 4, 2, 2)); Assert.Equal(GameStatus.InProgress, g.Status);}
[Fact]public async Task RacingRequestsForOneTurnPlayExactlyOnce(){ // Nine requests all claim to be move 0, each for a different cell. One may win. var g = new Game(3, X, O); var start = new TaskCompletionSource(); // the starting gun var attempts = Enumerable.Range(0, 9).Select(cell => Task.Run(async () => { await start.Task; return g.Play(X, 0, cell / 3, cell % 3); })).ToArray(); start.SetResult(); // release every task at once var results = await Task.WhenAll(attempts); Assert.Equal(1, results.Count(r => r == MoveResult.Ok)); // the rest were Stale Assert.Equal(1, g.MoveNumber);}var ( ana = Player{"Ana", X} bo = Player{"Bo", O})
// play makes (row, col) moves alternately for X and O, numbering them as a client would.func play(t *testing.T, size int, cells ...int) *Game { t.Helper() g, _ := NewGame(size, nil, ana, bo) for i := 0; i < len(cells); i += 2 { p := ana if i%4 != 0 { p = bo } if err := g.Play(p, i/2, cells[i], cells[i+1]); err != nil { t.Fatalf("move %d: %v", i/2, err) } } return g}
func TestAntiDiagonalWinsOnAFourByFourBoard(t *testing.T) { // X takes (0,3) (1,2) (2,1) (3,0); O plays elsewhere. The counters work for any n. g := play(t, 4, 0, 3, 0, 0, 1, 2, 1, 0, 2, 1, 2, 0, 3, 0) if g.Status() != Won { t.Fatal("want a win on the anti-diagonal") }}
func TestUndoingAWinReopensTheGameAndTheCounters(t *testing.T) { g := play(t, 3, 0, 0, 1, 0, 0, 1, 1, 1, 0, 2) // X wins the top row if !g.Undo() || g.Status() != InProgress { t.Fatal("undo should reopen the game") } // If Undo forgot the counters, X's row would still read +3 and this move would "win". if err := g.Play(ana, 4, 2, 2); err != nil || g.Status() != InProgress { t.Fatalf("got %v, status %v", err, g.Status()) }}
// Run with: go test -race ./... The race detector also flags unsynchronized reads.func TestRacingRequestsForOneTurnPlayExactlyOnce(t *testing.T) { // Nine requests all claim to be move 0, each for a different cell. One may win. g, _ := NewGame(3, nil, ana, bo) start := make(chan struct{}) // closing it is the starting gun var ok atomic.Int32 var wg sync.WaitGroup for cell := range 9 { wg.Add(1) go func() { defer wg.Done() <-start if g.Play(ana, 0, cell/3, cell%3) == nil { ok.Add(1) } }() } close(start) // release every goroutine at once wg.Wait() if ok.Load() != 1 { t.Fatalf("%d moves landed, want exactly 1", ok.Load()) }}import { test } from 'node:test';import assert from 'node:assert/strict';
const X: Player = { name: 'Ana', mark: 'X' };const O: Player = { name: 'Bo', mark: 'O' };
// Plays (row, col) pairs alternately for X and O, numbering moves as a client would.function play(size: number, ...cells: number[]): Game { const g = new Game(size, X, O); for (let i = 0; i < cells.length; i += 2) { assert.equal(g.play(i % 4 === 0 ? X : O, i / 2, cells[i], cells[i + 1]), 'ok'); } return g;}
test('the anti-diagonal wins on a four by four board', () => { // X takes (0,3) (1,2) (2,1) (3,0); O plays elsewhere. The counters work for any n. const g = play(4, 0, 3, 0, 0, 1, 2, 1, 0, 2, 1, 2, 0, 3, 0); assert.equal(g.status, 'won');});
test('a full board with no line is a draw', () => { // X O X / X O O / O X X: nine moves, no line. const g = play(3, 0, 0, 0, 1, 0, 2, 1, 1, 1, 0, 1, 2, 2, 1, 2, 0, 2, 2); assert.equal(g.status, 'draw');});
test('a double tap plays once', () => { // No threads in Node, but the same request can still arrive twice. const g = new Game(3, X, O); assert.equal(g.play(X, 0, 1, 1), 'ok'); assert.equal(g.play(X, 0, 1, 1), 'stale'); // the retry, not "not your turn" or "cell taken" assert.equal(g.moveNumber, 1);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| Move off the board | OUT_OF_BOUNDS, nothing changes |
Game.play checks |
| Move on a taken cell | CELL_TAKEN; this is also what keeps the counters honest |
Game.play checks |
| Move after a win or draw | GAME_OVER |
status check, first in line |
| Last cell completes a line | WON, not DRAW: the win is checked first |
Game.play |
| Undo with no moves | Returns false | Game.undo |
| Undo a winning move | Status back to in progress, winner cleared, counters reversed | Game.undo, WinRule.undo |
| Double tap or network retry | The second copy is STALE |
move number check |
| Two players send at once | One waits for the lock; the other gets NOT_YOUR_TURN or STALE |
the game’s lock |
| Game created with two X players | Throws when the game is created: a bug, not input | Game constructor |
| Bot returns a taken cell | Refused like any move; whatever runs the bot asks again | Game.play |
| Screen reads while a move runs | Reads take the lock and see the move whole or not at all | readers lock too |
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 |
|---|---|---|
| Board and game | Board holds cells; Game holds rules and turn |
Nothing |
| Illegal moves | Named results (errors in Go), not exceptions | The caller can ignore a result |
| Whose turn | Worked out from the history’s length | Nothing; one fewer field to keep in step |
| Win check | Signed counters per line, one step per move | A second copy of state that undo must reverse |
| Win rule | Strategy (WinRule) |
A little indirection |
| Undo | History list of Move values |
Memory for the history (tiny) |
| Bots | Callers of play behind BotStrategy |
Something outside must decide when bots move |
| Online play | Lock per game plus an expected move number | One program only, unless the database checks too |
Go deeperGenuinely different designs, and when to pick them2 min
Bitboards. Each player’s marks are bits in one number (9 bits for 3x3), and the eight winning
lines are eight masks. A win is (bits & mask) == mask. This is very fast, and it’s how game
engines store positions, because an unbeatable bot checks millions of them. But it doesn’t work
for big boards and it hides the model. So it’s a speed trick for a bot, not the interview design.
A game state that never changes. play returns a new GameState instead of changing the old
one. Undo means keeping the previous state. Online play only needs one compare-and-set on “the
current state”. Replays come for free. The cost is copying the board on every move: nothing at
3x3, a lot at 1,000 by 1,000.
Storing only the moves. Keep just the list of moves, and rebuild the board, counters and status from it whenever needed. This is the history list taken all the way, and it’s called event sourcing. It’s the natural design once games are saved and replayed.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. Board size, local or online, undo, bots.
- 3 to 5. The promises. Name “one move per turn, even online” as the hard one.
- 5 to 23. Stages 1 to 4. Code
play, the counter rule andundo. Describe the bot in words. - 23 to 25. Walk through the full diagram and name each pattern by the rule it protects.
- 25 to 33. Online play: the race and the late copy, then the lock and the move number.
- 33 to 45. One extension, usually Gomoku or a smarter bot, then your own questions.
If time is short, describe the bot in a sentence instead of coding it. Never skip the counters. They are what the question is really about.
Follow-up questions
Section titled “Follow-up questions”Make it Gomoku: 15 by 15, five in a row wins.
Whether the change lands in one new class. If Game.play needs editing beyond what you pass in
when creating the game, the win rule wasn’t really separated.
Most extensions fit into one place:
| Extension | What changes | Touches Game? |
|---|---|---|
| Gomoku (k in a row, k < n) | A KInARowRule that walks four directions from the last move; its undo does nothing |
No |
| Smarter bot | A MinimaxBot or WinOrBlockBot implementing BotStrategy |
No |
| Replays | Go through the history and play each move into a fresh Game |
No |
| Spectators | Publish a frozen copy of the board after each move to a list of listeners | One hook in play |
| Three players | Turn is moves % players.length; the rule keeps a set of counters per mark |
Constructor |
| Saving games | Store the move list; loading a game means replaying it | No |
For Gomoku, the counters stop working, because a row of 15 can hold five in a row anywhere along it. The new rule walks outward from the cell just played, in each of the four directions, and counts matching marks in a row. That’s about k steps per move, whatever the board size.
Go deeperBuilding an unbeatable bot, step by step4 min
The interviewer asks: “How would you build a bot that never loses?” The answer is a search called minimax, and our design makes it short to write.
Start with the idea. The bot wants the best result it can be sure of, even if the opponent plays perfectly. So for every move it could make, it asks: “If I play here, and then my opponent plays their best reply, and then I play my best reply, and so on to the end, how does the game end?” Then it picks the move whose ending is best for it.
To compare endings, give each finished game a score from the bot’s point of view: +1 for a win, 0 for a draw, and −1 for a loss. Now the search is a simple rule, applied over and over:
- On the bot’s turn, try every free cell and take the move with the highest score.
- On the opponent’s turn, try every free cell and assume they take the move with the lowest score, because that’s best for them.
- When the game is over, return the score.
That’s where the name comes from: the bot picks the maximum, and assumes the opponent picks the minimum.
How does it “try” a move? With the parts we already built. It calls play, searches further, then
calls undo. The counters make each win check one step, and undo puts everything back exactly as
it was. So the bot doesn’t build a new board for every position it looks at. It plays and undoes
on one board. This is a good moment to point out that undo wasn’t only for players. It’s what
makes the bot cheap.
How big is the search? On a 3x3 board, all possible games add up to fewer than 600,000 positions. A computer checks that in well under a second. A trick called alpha-beta pruning makes it much faster. While searching, the bot keeps track of the best score it’s already sure of. If a branch can’t possibly beat that, it stops searching that branch. On 3x3 this skips most of the positions.
On bigger boards, the search can’t reach the end of the game. A 15x15 Gomoku board has far too many
possible games. So the bot searches only a few moves ahead, then scores the position with a rule of
thumb, for example “how many open lines of four do I have, minus how many my opponent has”. This
is how real game engines work. The engine is just another BotStrategy. Game doesn’t change at
all.
Two details matter in an interview. First, prefer quicker wins: score a win as +10 minus the number of moves it took, so the bot doesn’t play slowly when it could win now. Second, the bot does all this playing and undoing on its own copy of the game, made once before it starts searching. Never search on the live game players are using. Otherwise a person’s request could arrive in the middle of the bot’s play and undo.
Go deeperTwo more follow-up questions1 min
Interviewer asksA player disconnects mid-game. What happens?
The game itself doesn’t care. It just waits for the next valid move. A timer outside the game
decides what to do: after 60 seconds, the server calls a forfeit(player) method that gives the
win to the other side. It runs under the same lock as play, so a late move can’t sneak in after
the forfeit.
Interviewer asksHow would you run a million games at once?
Each game is small and independent, so the design already scales: one lock per game and nothing shared. A million 3x3 games take a few hundred megabytes. Across many servers, send every request for a game to the server that holds it (by hashing the game ID), or keep games in a database and use the move number in the update.
Further reading
Section titled “Further reading”- Strategy pattern, Command pattern
- Object modeling, Thread safety basics
- Design chess, the same structure with richer move rules
- Wikipedia: Minimax, the algorithm behind an unbeatable bot
- Wikipedia: m,n,k-game, the family tic-tac-toe and Gomoku belong to
- Martin Fowler: Event Sourcing, the history stack 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.