This gives away every stage. For a clean attempt, practise it first, then come back.
The short version
- Everyone knows the rules, so the interview is purely about design: who owns the board, who checks for a win, who decides whose turn it is.
- X and O differ only in a character. They’re a field on the player, not two classes. And the player is separate from whatever chooses its moves, so a person and a bot are the same kind of player.
- Checking for a win after each move only needs to look at the lines through that move. Counting marks per line makes it constant time; scanning the whole board is the version that doesn’t scale.
- Invalid moves are normal user mistakes, so they come back as results, not exceptions. The order of the checks matters.
- Undo needs a move history, and any rule that keeps counters must be able to take a move back.
- Two clicks from the same player can arrive together. One lock per game fixes it; games never share anything, so nothing global is needed.
How this walkthrough is built
In a low-level design round you get about an hour to turn something familiar into classes and working code. With tic-tac-toe, the “something” is so familiar that you can’t hide behind requirements. Every minute is spent on design decisions, and the interviewer sees all of them.
We’ll build it in the same order you would in the room. Each step only uses what the steps before it produced.
| Step | What you do | What comes out of it |
|---|---|---|
| 1. Understand the problem | Ask questions, number the requirements | What the game must do, and how well |
| 2. Break it into entities | Nouns → classes, verbs → methods, find what changes | A handful of classes, each with one job |
| 3. Choose a direction | Top-down to design, bottom-up to code | The order you’ll work in |
| 4. Design each class | What each class knows, does and doesn’t do | A card per class |
| 5. Connect the classes | Pick the right relationships | Who owns whom |
| 6. Draw the diagram | Three versions, growing | The full class diagram |
| 7. Name the patterns | Where the requirements asked for one | Strategy, Observer, and why not Singleton |
| 8. Walk the flows | A move, a win, an undo, call by call | Gaps found before coding |
| 9. Write the code | Bottom-up, one class at a time | Code that compiles and runs |
| 10. Make it safe | Two clicks at once | One lock per game, and a test |
| 11. Extend it | Bigger boards, a bot, more players | New classes, no edits |
The one idea to take away: a small problem is a chance to show clean design, not a reason to skip it. Candidates who write one 200-line Game class with a main loop pass the “does it work” test and fail the interview.
Step 1Understand the problem
Picture the real thing first
Two people, a 3×3 grid drawn on paper. One draws crosses, the other noughts. They take turns. Whoever gets three in a row, across, down or diagonally, wins. If the grid fills up first, it’s a draw. Someone usually says “wait, let me take that back”, and the other person usually says no.
Even this tiny story has a few hints: grid, turns, three in a row, fills up, take it back. And a few questions it doesn’t answer: Is it always 3×3? Is the other player a person or the computer? Are they at the same screen or on two phones?
Ask before you design
Even for a game everyone knows, ask. The answers here change the design more than you’d expect. Group them: the board, the players, the rules, and how it’s played.
Interviewer: Design tic-tac-toe.
You: Happy to. A few questions first. Is the board always 3×3?
Interviewer: Start with 3×3, but we may want bigger boards later.
You: Two players, always?
Interviewer: Two for now.
You: Are they both people, or can one be the computer?
Interviewer: Both should be possible. A person against a person, or a person against a bot.
You: Do they play on one device, or each on their own phone?
Interviewer: Let’s say it’s an online game. Each player is on their own device, and moves come to our server.
You: Do we need undo?
Interviewer: Yes, in casual games.
You: Should we keep score across games?
Interviewer: Yes, a simple scoreboard of wins and draws.
You: What should happen if someone plays out of turn or picks a taken cell?
Interviewer: Reject it with a clear reason, and keep the game going.
You: Timers, chat, matchmaking?
Interviewer: Out of scope.
Eight questions. Here’s what each one decided:
| Question | What the answer decided |
|---|---|
| Always 3×3? | Board size is a number, not a constant. The win check must work for any size. |
| Two players? | Players are a list, so three players is a small change, not a rewrite. |
| Person or bot? | Something must choose moves, and it varies. Keep it separate from the player. |
| Own devices, online? | Concurrency. Two requests can arrive at the same moment. |
| Undo? | We need a history of moves, and every piece of state must be reversible. |
| Scoreboard? | Something reacts when a game ends, without the game knowing what it is. |
| Bad moves? | Rejections are normal outcomes, returned with a reason, not crashes. |
| Out of scope | Said out loud, so the follow-ups have somewhere to come from. |
Functional requirements
- FR1. The board is N×N, 3×3 by default. Cells start empty.
- FR2. Players take turns in a fixed order. Each places their mark in an empty cell.
- FR3. A player who completes a full row, column or diagonal wins. If the board fills first, it’s a draw.
- FR4. An invalid move (out of turn, outside the board, taken cell, game over) is rejected with a reason. The game continues.
- FR5. A player can be a person or a bot.
- FR6. The last move can be undone, any number of times.
- FR7. A scoreboard counts wins and draws across games.
Non-functional requirements
- NFR1: Easy to extend. Bigger boards, new win rules and new kinds of bot are added, not edited in.
- NFR2: Correct under concurrency. Two requests at once can never break turn order or put two marks in one cell.
- NFR3: Fast win check. Checking for a win after each move shouldn’t get slower the bigger the board gets, or at least not much.
- NFR4: Testable. Games can be scripted move by move, and bots can be tested without a person.
Assumptions we’ll write down
| Assumption | Why it’s reasonable |
|---|---|
| Rows and columns count from 0, top-left is (0, 0) | Matches arrays; the UI can show 1–9 |
| The first player in the list moves first | The caller decides who that is |
| Undo is allowed after the game ends, and reopens it | “Let me take that back” usually comes right after losing |
| A bot answers immediately | Thinking time is a follow-up |
| Everything lives in memory | Saving games is a follow-up |
Step 2Break it into entities
Pull out the nouns
From the requirements: board, cell, row, column, diagonal, mark, X, O, player, person, bot, turn, move, win, draw, history, scoreboard, game.
Three questions that decide if a noun is a class
For each noun: does it have its own data, its own rules, and will it change on its own?
| Noun | Own data? | Own rules? | Changes alone? | Becomes |
|---|---|---|---|---|
| Game | board, players, whose turn, state | turn order, when it ends | — | Game class |
| Board | the grid | a cell holds at most one mark | — | Board class |
| Cell | a mark, or nothing | — | — | a slot in the grid, not a class |
| Row, column, diagonal | — | — | — | nothing: they’re how the win rule looks at the grid |
| Mark, X, O | one character | — | — | a char on the player |
| Player | name, mark | — | — | Player record |
| Person, bot | — | how a move is chosen | yes | MoveChooser interface |
| Move | row, column, who | — | — | Move record |
| Win | — | what counts as a win | yes (bigger boards, 5 in a row) | WinRule interface |
| Win, draw, in progress | — | — | — | GameState enum |
| History | past moves | — | — | a stack inside Game |
| Scoreboard | wins, draws | — | yes (later: rankings) | Scoreboard, behind GameListener |
| Turn | — | — | — | an index into the player list |
Two nouns are missing from the requirements and only appear while filling this table: what chooses a bot’s move? and who reacts when a game ends? They become MoveChooser and GameListener.
The trap: a class for X and a class for O
The tempting design gives every noun a class, and every class a subclass:
abstract class Piece { }
class PieceX extends Piece { }
class PieceO extends Piece { }
class Cell { Piece piece; boolean isEmpty() { ... } }
class Board { Cell[][] cells; }
abstract class Player { abstract Move nextMove(Board board); }
class HumanPlayer extends Player { ... }
class BotPlayer extends Player { ... }Look at what PieceX and PieceO actually do differently. Nothing. The only difference is a character. Two classes for one character is the same mistake as a class per vehicle in the parking lot: inheritance is for different behaviour; when only the data differs, use a field. A mark is a char on the player.
Cell is the same story. It holds one mark and has no rules of its own (the board decides whether a cell can be taken). A char[][] says exactly the same thing, and a 15×15 board doesn’t need 225 objects.
HumanPlayer and BotPlayer are more interesting, because they do behave differently: one waits for a tap, the other computes. So something should vary. But it’s not the player that varies, it’s how the next move is chosen. Split them:
Playeris who you are in this game: a name and a mark. Plain data.MoveChooseris how a move gets picked: a person’s taps, a random bot, a perfect bot.
Why bother? Because the two change separately. If a person disconnects, a bot can take over the same Player mid-game and the scoreboard still credits the right name. With HumanPlayer extends Player, you’d have to swap the whole player object. And the game itself never needs to know which kind of chooser is involved: on an online server, the game just receives moves.
Pull out the verbs
Place a mark, check the cell is empty, check it’s your turn, check for a win, check for a draw, pass the turn, undo, choose a move, record a result.
Grouped into use cases:
| Use case | Who starts it | Verbs inside it |
|---|---|---|
| UC1: Play a move | a player (person or bot) | check game is on, check turn, check cell, place, check win or draw, pass the turn |
| UC2: Undo | a player | take back the last move, reopen the game if it had ended |
| UC3: Record results | the game, when it ends | count a win or a draw |
Give each job to the class that knows the most
| Job | Information needed | Who has it | Owner |
|---|---|---|---|
| Is this cell inside the board, and empty? | the grid | the board | Board |
| Put a mark in a cell | the grid | the board | Board |
| Is the board full? | how many cells are filled | the board | Board |
| Did this move win? | the grid, plus a rule that will change | nobody, yet | WinRule |
| Whose turn is it? Is the game over? | players, turn, state | the game | Game |
| What was the last move? | the history | the game | Game |
| Which cell should the bot pick? | a read-only look at the board, plus a policy | nobody, yet | MoveChooser |
| Count wins and draws | results of finished games | whoever listens | Scoreboard |
Notice that Game doesn’t check for wins itself, and Board doesn’t know whose turn it is. Each keeps one kind of rule.
CRC cards
| Class | Responsibilities | Collaborators |
|---|---|---|
Board | hold marks; say if a cell is inside, empty, or full | — |
WinRule | decide if a move just won | BoardView, Move |
Game | turn order; validate and apply moves; win, draw, undo; tell listeners | Board, WinRule, Player, GameListener |
MoveChooser | pick a cell for a player | BoardView |
Match | play a game to the end by asking choosers | Game, MoveChooser |
Scoreboard | count results | Game (as a listener) |
Find what will change
| Will change | Hint from the interviewer | Becomes |
|---|---|---|
| What counts as a win | “bigger boards later” | WinRule interface |
| How a move is chosen | “a person or a bot” | MoveChooser interface |
| Who cares when a game ends | “a scoreboard” (later: ratings, achievements) | GameListener interface |
Things that won’t change get no interface: the board, a move, a player.
Step 3Top-down or bottom-up?
Top-down asks what the outside world calls. A player’s device calls play(player, row, col), so Game.play exists. To play, the game needs to know if a cell is free, so Board.isEmpty exists. To know if the move won, it needs a WinRule. To run a game against a bot, something needs to keep asking the bot for moves, so Match exists.
Bottom-up starts from the pieces that must be right first: the board, then the win rule (tested on its own on hundreds of random boards), then the game on top.
| Top-down | Bottom-up | |
|---|---|---|
| Starts from | what callers need | the smallest pieces |
| Good at | nothing useless gets built | each piece is tested before anything depends on it |
| Use it for | the diagram and the flows | writing the code |
Same answer as most LLD problems: design top-down, code bottom-up. Say it out loud in the interview. It takes five seconds and shows you have a plan.
Step 4Design each class
Game: the referee
Job: own one game’s rules: whose turn it is, whether a move is allowed, and whether the game has ended.
| Knows | Does | Doesn’t do |
|---|---|---|
| the board | play(player, row, col) → MoveResult | decide what counts as a win (the rule does) |
| the players, in order | undo() → true/false | choose moves (choosers do) |
| whose turn it is | state(), winner(), current() | keep score across games (listeners do) |
| the state: in progress, won, draw | subscribe(listener) | |
| the history of moves | ||
| the win rule, and listeners |
Two decisions. First, play returns a MoveResult (OK, game over, not your turn, outside the board, cell taken) instead of throwing exceptions. A player tapping a taken cell is not a bug; it happens all the time. Exceptions are for things that should never happen, like the board being asked to overwrite a mark after the game has already checked it was empty.
Second, Game is the only class that changes the board. Bots and the UI get a read-only view, so a buggy bot can’t place two marks.
Board and BoardView: the grid
Job: hold the marks and answer simple questions about cells.
| Knows | Does | Doesn’t do |
|---|---|---|
| size | inside(r, c), isEmpty(r, c), at(r, c) | know whose turn it is |
| the grid of marks | place(r, c, mark), clear(r, c) | check for wins |
| how many cells are filled | isFull() |
BoardView is an interface with only the reading methods. Board implements it. Anyone outside the game (bots, the UI, the win rule) receives a BoardView, so the type system itself stops them from changing the board. That’s the interface segregation principle from chapter 2 doing real work.
Why keep a filled counter instead of scanning for empty cells? The game asks “is it full?” after every move, and the counter makes that instant.
WinRule and LineCounter: what counts as a win
Job: after a move is placed, say whether it completed a winning line.
WinRule is an interface with isWinningMove(board, move) and a default undo(move) that does nothing. Rules that keep their own state, like LineCounter, override it.
The key insight, which deserves a whole section in step 8: a move can only complete lines that pass through it. So there’s never a reason to look at the whole board. LineCounter goes one step further and keeps a running count of each player’s marks in each row, column and diagonal. A move adds one to at most four counters, and a count reaching N means a win. That’s constant time, whatever the board size.
Player, Move, GameState, MoveResult: the vocabulary
Player is a record of name and mark. Move is a record of row, column and player. GameState is an enum: in progress, won, draw. MoveResult is an enum of every outcome of play. All of them are values: once made, they never change.
MoveChooser and Match: who picks the move
MoveChooser has one method, choose(boardView, myMark), returning a cell. RandomChooser picks any empty cell. Later, PerfectChooser never loses.
Match is a small loop for offline games: ask the current player’s chooser for a cell, call game.play, repeat until the game ends. On the online server, there’s no Match. Moves arrive as requests and go straight to game.play. That’s why the loop lives outside Game: the game works the same either way.
GameListener and Scoreboard: reacting to results
GameListener has two methods with empty defaults: moved and finished. Scoreboard implements finished and counts. The game calls every listener without knowing what they are, so adding a rating system later is a new listener, not an edit to Game.
Step 5How the classes relate
| Relationship | Meaning | In this design |
|---|---|---|
| Composition (filled diamond) | owns; the part can’t exist alone | a game is made of its board and its history |
| Aggregation (empty diamond) | holds; the part lives on its own | a game has players, who play many games |
| Association (arrow) | uses, keeps a reference | a game uses a win rule; a match uses choosers |
| Realization (dashed, hollow arrow) | implements an interface | Board is a BoardView; LineCounter is a WinRule |
| Dependency (dashed arrow) | uses briefly | a chooser looks at a BoardView passed in |
The composition test: if the game disappears, does the part disappear too? The board does: it has no meaning outside its game. The players don’t: Asha plays tomorrow’s game too.
Step 6The class diagram, built up
Version 1: what a game is made of
Version 2: the parts that change
The win check and move choosing come out as interfaces, and the board gets a read-only face.
Version 3: the whole picture
The records and enums (Player, GameState, MoveResult) are left off to keep the picture readable. Say so when you draw it; interviewers don’t want every value type, but they want to know you didn’t forget them.
Check it against the requirements
| Requirement | Where it lives |
|---|---|
| FR1: N×N board | Board(size); LineCounter(n) |
| FR2: turns in order | Game.turn, an index into players |
| FR3: win or draw | WinRule.isWinningMove, Board.isFull |
| FR4: invalid moves rejected with a reason | MoveResult, checks at the top of Game.play |
| FR5: person or bot | MoveChooser; Match for offline games |
| FR6: undo | Game.history, Board.clear, WinRule.undo |
| FR7: scoreboard | GameListener → Scoreboard |
| NFR1: easy to extend | WinRule, MoveChooser, GameListener |
| NFR2: concurrency | synchronized on Game (step 10) |
| NFR3: fast win check | LineCounter: at most four counter updates per move |
| NFR4: testable | scripted play calls; bots behind an interface; Random passed in |
Step 7The patterns, and why each one is here
| Pattern | Where | The problem it solved |
|---|---|---|
| Strategy | WinRule, MoveChooser | what counts as a win, and how moves are chosen, both vary |
| Observer | GameListener, Scoreboard | things react to results without the game knowing them |
| Facade | Game | callers see play and undo, not boards and rules |
| Value objects | Player, Move | data passed around can’t change by surprise |
The move history is a light form of the Command pattern: each Move is a record of something that was done, kept so it can be undone. We don’t need full command objects with execute() and undo() methods, because there’s only one kind of action. If the game gained several kinds (place, swap, remove a piece), each would become a command class.
Why Game is not a Singleton
“There’s only one game”, so some answers write Game.getInstance(). It’s wrong the moment there are two games, which on an online server is immediately: thousands at once. It also makes tests leak into each other. Create a Game per game and pass it to whoever needs it.
Patterns we deliberately didn’t use
- State, for in progress / won / draw. Three states, and only one of them allows moves. One
ifat the top ofplaysays that more clearly than three state classes would. If the game gained phases with very different rules (a setup phase, a swap phase), State would earn its place. - Factory.
Game.classic(x, o)is a static method that sets up the usual 3×3 game. That’s enough; there’s no family of games to choose between. - Builder. Three constructor arguments don’t need one.
Step 8Walk the flows
A move
- The client sends who is playing and where. On the server, “who” comes from the logged-in session, never from the request body, so nobody can play as someone else.
- The checks run in a deliberate order: game over first, then turn, then the cell. A finished game should say “game over”, not “not your turn”, and a player moving out of turn should hear that before they hear the cell is taken.
- Only after every check passes does anything change.
- The win rule runs after the mark is placed. That’s in its contract, and the order matters: a rule that looks at the board would miss the new mark otherwise.
- The turn only passes if the game goes on. After a win,
current()still points at the winner, which is what an undo needs.
A winning move
Here’s the story game from step 9, at the moment Asha wins. She played X at (1,1) and (0,2), Ravi played O at (0,0) and (1,0). Ravi is now threatening the left column, so Asha blocks at (2,0):
O . X
O X .
X . .The block completes the other diagonal, (0,2), (1,1), (2,0). The win rule sees Asha’s anti-diagonal count reach 3. The game sets its state to WON, records Asha as the winner, doesn’t pass the turn, tells listeners about the move and then about the finish. The scoreboard adds a win for Asha.
How the win check works
This is the part of tic-tac-toe interviewers like to dig into, so it’s worth understanding properly. There are three ways to check for a win, and they get faster in this order:
| Approach | What it looks at after each move | Cost per move |
|---|---|---|
| Scan the whole board | every row, column and both diagonals | about 2N² cells |
| Scan from the last move | only the four lines through the move | about 4N cells |
| Count marks per line | four counters | constant: 4 additions |
The idea behind the second and third: a move can only complete a line that passes through it. If Asha’s move at (2,0) wins, the winning line must be row 2, column 0, or the anti-diagonal. Nothing else on the board changed.
The counting version keeps, for each player, a count of their marks in each row, each column and the two diagonals. Placing at (r, c) adds one to row r and column c, plus a diagonal if the cell is on one (r = c for the main diagonal, r + c = N − 1 for the other). A count reaching N is a win. Undo subtracts the same counts.
We measured all three, filling a whole board with random moves and checking after every one (the best of three runs):
n=30 counters=0.1 ms scan-from-move=0.1 ms whole-board=0.8 ms
n=100 counters=1.2 ms scan-from-move=1.5 ms whole-board=73.5 ms
n=300 counters=3.4 ms scan-from-move=5.7 ms whole-board=skippedOn a 100×100 board, scanning the whole board after every move is about 60 times slower than counting. For 3×3 none of this matters (all three are instant), so be honest about that in the interview. But the question “how would you make the win check fast?” is asked almost every time, and “only the lines through the last move” is the answer they want. We also ran all three rules on 20,000 random games of sizes 3 to 6 and they agreed on every single move.
Undo
Two details here came from walking the flow. First, the win rule must also undo. LineCounter keeps counts; if undo clears the board but not the counts, the next mark in that row wins a game nobody won. That’s why WinRule has an undo method at all. Second, the turn goes back to whoever made the undone move, not to “the previous player”. With two players those are the same, but with three they aren’t, and with a finished game the turn never moved on in the first place.
What walking the flows taught us
| Found while walking | Change |
|---|---|
| A rule that keeps counts gets out of step after undo | WinRule.undo(move), empty by default |
| After a win, the turn shouldn’t pass | Only advance the turn when the game goes on |
| Undo after a win must reopen the game | Undo resets state and winner |
| “Who is playing” can be faked in a request | The server takes the player from the session |
The life of a game
| Game is | Moves to | When |
|---|---|---|
| In progress | In progress | a move that doesn’t end the game |
| In progress | Won | a move completes a line |
| In progress | Draw | the board fills with no winner |
| Won or Draw | In progress | undo |
| Won or Draw | — | any play returns GAME_OVER |
When things go wrong
| Situation | Result | Game afterwards |
|---|---|---|
| Move after the game ended | GAME_OVER | unchanged |
| Move out of turn | NOT_YOUR_TURN | unchanged |
| Cell outside the board | OUTSIDE_BOARD | unchanged |
| Cell already taken | CELL_TAKEN | unchanged |
| Undo with no moves | false | unchanged |
| Two moves from one player at the same instant | one OK, one NOT_YOUR_TURN | step 10 |
Every rejection leaves the game exactly as it was. That’s a property worth saying out loud: nothing changes until every check has passed.
Step 9Write the code, bottom-up
Each class below only uses classes written before it. Put together, this is complete Java that compiles and runs, and every output on this page comes from running it.
9.1 The vocabulary
record Player(String name, char mark) { }
record Move(int row, int col, Player player) { }
enum GameState { IN_PROGRESS, WON, DRAW }
enum MoveResult { OK, GAME_OVER, NOT_YOUR_TURN, OUTSIDE_BOARD, CELL_TAKEN }9.2 The board
interface BoardView {
int size();
boolean inside(int row, int col);
boolean isEmpty(int row, int col);
char at(int row, int col); 1
}
final class Board implements BoardView {
private final int size;
private final char[][] cells;
private int filled;
Board(int size) {
if (size < 3) throw new IllegalArgumentException("size must be at least 3");
this.size = size;
this.cells = new char[size][size];
}
public int size() { return size; }
public boolean inside(int r, int c) {
return r >= 0 && r < size && c >= 0 && c < size;
}
public boolean isEmpty(int r, int c) { return cells[r][c] == 0; }
public char at(int r, int c) { return cells[r][c]; }
void place(int r, int c, char mark) {
if (!isEmpty(r, c)) throw new IllegalStateException("cell taken"); // (2)
cells[r][c] = mark;
filled++;
}
void clear(int r, int c) {
if (isEmpty(r, c)) return;
cells[r][c] = 0;
filled--;
}
boolean isFull() { return filled == size * size; }
@Override public String toString() {
StringBuilder sb = new StringBuilder();
for (int r = 0; r < size; r++) {
for (int c = 0; c < size; c++) sb.append(isEmpty(r, c) ? '.' : cells[r][c]);
if (r < size - 1) sb.append('\n');
}
return sb.toString();
}
}- 10 means empty
- A Java
charstarts as 0, so a fresh grid is already empty. No loop needed. placeandclearhave nopublic: only code in the same package (the game) can call them. The game always checks the cell first, so if this exception ever fires, it’s a bug in our code, not a player’s mistake.
9.3 The win rule
interface WinRule {
boolean isWinningMove(BoardView board, Move move); 1
default void undo(Move move) { } 2
}
final class LineCounter implements WinRule {
private final int n;
private final Mapint[]> rows = new HashMap<>();
private final Mapint[]> cols = new HashMap<>();
private final Mapint[]> diags = new HashMap<>(); 3
LineCounter(int n) { this.n = n; }
public boolean isWinningMove(BoardView board, Move m) {
return add(m, +1);
}
public void undo(Move m) { add(m, -1); }
private boolean add(Move m, int delta) {
char k = m.player().mark();
int[] row = rows.computeIfAbsent(k, x -> new int[n]);
int[] col = cols.computeIfAbsent(k, x -> new int[n]);
int[] diag = diags.computeIfAbsent(k, x -> new int[2]);
row[m.row()] += delta;
col[m.col()] += delta;
if (m.row() == m.col()) diag[0] += delta;
if (m.row() + m.col() == n - 1) diag[1] += delta; 4
return row[m.row()] == n || col[m.col()] == n || diag[0] == n || diag[1] == n;
}
} - 1move is already placed
- 2for rules with state
- 3[0] main, [1] anti
- 4the other diagonal
- The contract is written on the interface, because the game and every rule must agree on it.
- A
defaultmethod means stateless rules (like the scan-from-move rule in step 11) don’t have to write an emptyundo. - Counts are kept per mark, in maps. A common trick is one shared count, +1 for X and −1 for O, with a win at ±N. It’s neat, but it only works for exactly two players. Per-mark counts cost a little more memory and make three players free.
- The centre of an odd board is on both diagonals, so these are two separate
ifs, notif/else.
9.4 The game
interface GameListener {
default void moved(Game game, Move move) { }
default void finished(Game game, GameState state, Player winner) { }
}
final class Game {
private final Board board;
private final List players;
private final WinRule rule;
private final Deque history = new ArrayDeque<>();
private final List listeners = new CopyOnWriteArrayList<>();
private int turn;
private GameState state = GameState.IN_PROGRESS;
private Player winner;
Game(int size, List players, WinRule rule) {
if (players.size() < 2) throw new IllegalArgumentException("need two players");
long marks = players.stream().map(Player::mark).distinct().count();
if (marks != players.size())
throw new IllegalArgumentException("marks must differ");
this.board = new Board(size);
this.players = List.copyOf(players);
this.rule = rule;
}
static Game classic(Player x, Player o) {
return new Game(3, List.of(x, o), new LineCounter(3));
}
void subscribe(GameListener l) { listeners.add(l); }
synchronized MoveResult play(Player p, int row, int col) {
if (state != GameState.IN_PROGRESS) return MoveResult.GAME_OVER; // (1)
if (!p.equals(players.get(turn))) return MoveResult.NOT_YOUR_TURN;
if (!board.inside(row, col)) return MoveResult.OUTSIDE_BOARD;
if (!board.isEmpty(row, col)) return MoveResult.CELL_TAKEN;
Move move = new Move(row, col, p);
board.place(row, col, p.mark());
history.push(move);
if (rule.isWinningMove(board, move)) {
state = GameState.WON;
winner = p;
} else if (board.isFull()) {
state = GameState.DRAW;
} else {
turn = (turn + 1) % players.size(); // (2)
}
for (GameListener l : listeners) l.moved(this, move);
if (state != GameState.IN_PROGRESS)
for (GameListener l : listeners) l.finished(this, state, winner);
return MoveResult.OK;
}
synchronized boolean undo() {
if (history.isEmpty()) return false;
Move last = history.pop();
board.clear(last.row(), last.col());
rule.undo(last); // (3)
state = GameState.IN_PROGRESS;
winner = null;
turn = players.indexOf(last.player()); // (4)
return true;
}
synchronized GameState state() { return state; }
synchronized Player winner() { return winner; }
synchronized Player current() { return players.get(turn); }
synchronized List moves() {
List out = new ArrayList<>(history);
Collections.reverse(out);
return out;
}
BoardView board() { return board; }
List players() { return players; }
@Override public synchronized String toString() { return board.toString(); }
} - Game over first, then turn, then the cell, as in step 8. Every check returns before anything changes.
- The turn only moves if the game goes on.
- Without this line, undo would leave the counters one too high, and a later move could “win” with only two marks in a row.
- The player whose move was undone plays again.
synchronized is explained in step 10. Notice that even the small getters are synchronized: reading state while another thread is halfway through play could see a half-finished move.
The constructor checks that every player has a different mark. Two players both using X would make the win rule credit wins to the wrong person. Failing loudly when the game is created is much better than a confusing scoreboard later.
9.5 Choosing moves
interface MoveChooser {
int[] choose(BoardView board, char me); 1
}
final class RandomChooser implements MoveChooser {
private final Random random;
RandomChooser(Random random) { this.random = random; }
public int[] choose(BoardView b, char me) {
List<int[]> free = new ArrayList<>();
for (int r = 0; r < b.size(); r++)
for (int c = 0; c < b.size(); c++)
if (b.isEmpty(r, c)) free.add(new int[] { r, c });
return free.get(random.nextInt(free.size()));
}
}
final class Match {
private final Game game;
private final Map choosers;
Match(Game game, Map choosers) {
this.game = game;
this.choosers = Map.copyOf(choosers);
}
Player playOut() {
while (game.state() == GameState.IN_PROGRESS) {
Player p = game.current();
int[] cell = choosers.get(p).choose(game.board(), p.mark());
MoveResult r = game.play(p, cell[0], cell[1]);
if (r != MoveResult.OK) throw new IllegalStateException(p.name() + ": " + r);
}
return game.winner(); // (2)
}
} - 1a read-only view: can’t cheat
- The chooser gets a
BoardView. Even if a bot wanted to place two marks, it has no method to do it with. nullmeans a draw. A bot that returns a bad cell is a bug, soMatchthrows rather than looping forever.
The Random is passed in, not created inside. Tests can pass new Random(42) and get the same games every run (NFR4).
9.6 The scoreboard
final class Scoreboard implements GameListener {
private final Map wins = new TreeMap<>();
private int draws;
public synchronized void finished(Game g, GameState s, Player winner) {
if (s == GameState.DRAW) draws++;
else wins.merge(winner.name(), 1, Integer::sum);
}
@Override public synchronized String toString() {
return "wins=" + wins + ", draws=" + draws;
}
} One scoreboard listens to many games, and on a server those games finish on different threads. So it has its own lock.
9.7 Putting it together
The story game from step 8, with every kind of rejected move, then an undo:
public class Demo {
public static void main(String[] args) {
Player asha = new Player("Asha", 'X'), ravi = new Player("Ravi", 'O');
Game g = Game.classic(asha, ravi);
System.out.println("X (1,1): " + g.play(asha, 1, 1));
System.out.println("X again: " + g.play(asha, 0, 0));
System.out.println("O (1,1): " + g.play(ravi, 1, 1));
System.out.println("O (3,0): " + g.play(ravi, 3, 0));
g.play(ravi, 0, 0);
g.play(asha, 0, 2);
g.play(ravi, 1, 0);
System.out.println("X (2,0): " + g.play(asha, 2, 0));
System.out.println(g.state() + ", winner " + g.winner().name());
System.out.println(g);
System.out.println("O (2,2): " + g.play(ravi, 2, 2));
g.undo();
System.out.println("after undo: " + g.state() + ", "
+ g.current().name() + " to play");
Scoreboard score = new Scoreboard();
Random random = new Random(42);
for (int i = 0; i < 10_000; i++) {
Game m = Game.classic(asha, ravi);
m.subscribe(score);
new Match(m, Map.of(asha, new RandomChooser(random),
ravi, new RandomChooser(random))).playOut();
}
System.out.println("10,000 random games: " + score);
}
}The output:
X (1,1): OK
X again: NOT_YOUR_TURN
O (1,1): CELL_TAKEN
O (3,0): OUTSIDE_BOARD
X (2,0): OK
WON, winner Asha
O.X
OX.
X..
O (2,2): GAME_OVER
after undo: IN_PROGRESS, Asha to play
10,000 random games: wins={Asha=5801, Ravi=2923}, draws=1276That last line is a nice sanity check. When both players move at random, the first player wins about 58% of games, the second about 29%, and about 13% are draws. Those are the known figures for random tic-tac-toe, which tells us the rules and the win check are right.
Step 10Make it safe when moves arrive at once
Interviewer: It’s an online game. What can go wrong?
You: Every move is a request, and requests run on different threads. So I’d list what’s shared and changes.
| Shared state | Who changes it | Danger | Protection |
|---|---|---|---|
| the board | play, undo | two marks in one cell | the game’s lock |
| whose turn it is | play, undo | one player moves twice | the game’s lock |
| state and winner | play, undo | a move accepted after the game ended | the game’s lock |
| the win rule’s counts | play, undo | counts out of step with the board | the game’s lock |
| the listener list | subscribe, play | crash while looping | CopyOnWriteArrayList |
| a scoreboard shared by games | many games | lost updates | the scoreboard’s own lock |
The race: a double click
The bug that really happens: Asha’s connection is slow, so she taps twice, or taps two different cells. Two requests arrive at the same instant:
| Time | Request 1: Asha at (0,0) | Request 2: Asha at (2,2) | Turn |
|---|---|---|---|
| 1 | is it Asha’s turn? yes | Asha | |
| 2 | is it Asha’s turn? yes | Asha | |
| 3 | place X at (0,0) | Asha | |
| 4 | place X at (2,2) | Asha | |
| 5 | pass turn to Ravi | pass turn… | Asha again |
Asha has played twice, and depending on timing, the turn may have advanced twice, back to her. Try it:
We tested it: 100,000 trials of two simultaneous requests from the same player. Without a lock, Asha got two moves in between 1 and 3 of every 100,000 trials, depending on the run. With synchronized, it never happened.
That rarity is the dangerous part. A bug that shows up a few times in 100,000 passes every test you write by hand, and then a busy server with thousands of games hits it every day.
Why one lock per game
Everything in the table above belongs to one game. The turn check and the board update must happen as one step, so they need the same lock. Making play synchronized does exactly that: the game object is the lock.
Interviewer: Why not a lock per cell, like the parking lot’s per-spot claim?
You: Because the conflict isn’t over a cell. In the double-click race, the two requests want different cells. What they share is the turn. A per-cell lock would let both through.
Interviewer: And one lock for the whole server?
You: Games never touch each other’s data, so a server-wide lock would just make ten thousand games wait in one line for no reason. One lock per game means games run fully in parallel, and moves within a game run one at a time, which is how the game works anyway.
The lesson that carries to other problems: find what the conflicting operations actually share, and lock exactly that. In the parking lot it was one spot. Here it’s one game.
Prove it
This test plays 2,000 games, each with two threads hammering random cells as fast as they can, and then checks that every game’s moves strictly alternate:
2000 games, two threads each: finished=2000, out-of-turn moves=0Step 11Follow-ups, extended without editing
A bigger board, and five in a row
Interviewer: Now make it a 15×15 board where five in a row wins.
That’s gomoku, and it breaks LineCounter: counting marks in a row of 15 can’t tell five together from five scattered.
Bad solutionCopy Game and change the loops
A GomokuGame with its own hard-coded checks. Two copies of the turn logic, the undo logic and the concurrency, and every bug fixed twice.
Good solutionScan the whole board for five in a row
Correct, and it fits behind WinRule. But it looks at every cell after every move: on a 15×15 board, 225 cells to find out whether the one cell that changed made a line.
Great solutionA new rule that scans from the last move
Same idea as before: a move can only complete a line through it. Walk outwards from the move in both directions along each of the four lines, and count matching marks. It’s a new class; Game doesn’t change at all.
final class KInARow implements WinRule {
private static final int[][] DIRECTIONS = { {0, 1}, {1, 0}, {1, 1}, {1, -1} };
private final int k;
KInARow(int k) { this.k = k; }
public boolean isWinningMove(BoardView b, Move m) {
char mark = m.player().mark();
for (int[] d : DIRECTIONS) {
int line = 1 + count(b, m, d[0], d[1], mark)
+ count(b, m, -d[0], -d[1], mark);
if (line >= k) return true; // (1)
}
return false;
}
private int count(BoardView b, Move m, int dr, int dc, char mark) {
int n = 0, r = m.row() + dr, c = m.col() + dc;
while (n < k - 1 && b.inside(r, c) && b.at(r, c) == mark) {
n++;
r += dr;
c += dc;
}
return n;
}
}- Counting both ways at once matters. If the move lands in the middle of a line (two marks on each side), checking only one direction would miss it.
It keeps no state, so it doesn’t need undo: the default does nothing, and that’s correct. Used as new Game(15, players, new KInARow(5)). In our run, X played (7,7), (8,8), (6,6), (9,9) and then (5,5), filling the gap at the end of the diagonal, and the game reported WON on exactly that move.
This rule also works for ordinary N×N with k = N, and it’s the “scan from the last move” line in the benchmark above: a little slower than counters, but simpler and more general.
A bot that never loses
Interviewer: Make the computer opponent unbeatable.
Tic-tac-toe is small enough to search completely. Minimax tries every possible move, then every reply, and so on to the end of the game, and scores each final position: +1 if the player to move eventually wins, −1 if they lose, 0 for a draw. Each player picks the move that’s best for them, assuming the other does the same.
final class PerfectChooser implements MoveChooser {
private final Map memo = new HashMap<>();
public int[] choose(BoardView b, char me) {
char[][] g = copy(b);
char them = other(g, me);
int best = Integer.MIN_VALUE;
int[] pick = null;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) {
if (g[r][c] != 0) continue;
g[r][c] = me;
int score = -value(g, them, me); // (1)
g[r][c] = 0;
if (score > best) { best = score; pick = new int[] { r, c }; }
}
return pick;
}
// Score for the player about to move: +1 win, 0 draw, -1 loss.
private int value(char[][] g, char toMove, char other) {
if (wins(g, other)) return -1; // (2)
String key = toMove + key(g);
Integer known = memo.get(key);
if (known != null) return known;
int best = Integer.MIN_VALUE;
boolean moved = false;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) {
if (g[r][c] != 0) continue;
moved = true;
g[r][c] = toMove;
best = Math.max(best, -value(g, other, toMove));
g[r][c] = 0;
}
int v = moved ? best : 0; // (3)
memo.put(key, v);
return v;
}
private static boolean wins(char[][] g, char p) {
for (int i = 0; i < 3; i++) {
if (g[i][0] == p && g[i][1] == p && g[i][2] == p) return true;
if (g[0][i] == p && g[1][i] == p && g[2][i] == p) return true;
}
return (g[0][0] == p && g[1][1] == p && g[2][2] == p)
|| (g[0][2] == p && g[1][1] == p && g[2][0] == p);
}
private static char[][] copy(BoardView b) {
if (b.size() != 3) throw new IllegalArgumentException("3x3 only");
char[][] g = new char[3][3];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) g[r][c] = b.at(r, c);
return g;
}
private static char other(char[][] g, char me) {
for (char[] row : g) for (char ch : row) if (ch != 0 && ch != me) return ch;
return me == 'X' ? 'O' : 'X';
}
private static String key(char[][] g) {
StringBuilder sb = new StringBuilder(9);
for (char[] row : g) for (char ch : row) sb.append(ch == 0 ? '.' : ch);
return sb.toString();
}
} - A position that’s good for the opponent is bad for us by the same amount, so one function with a minus sign handles both players. This form of minimax is called negamax.
- If the player who just moved has won, the player to move has lost.
- A full board with no winner is a draw.
The bot works on its own copy of the board, so searching never touches the real game. The memo map remembers positions it has already scored: tic-tac-toe has only a few thousand reachable positions, so after the first game every answer is instant.
The results, from real runs:
perfect vs perfect, 100 games: wins={}, draws=100
perfect (Asha) vs random, 2,000 games: wins={Asha=1807}, draws=193Two perfect players always draw, which is the famous result for tic-tac-toe. Against a random player, playing first or second, the bot won 1,807 and drew 193 of 2,000 games, and never lost.
And nothing else changed: it’s one more MoveChooser. Match and Game don’t know it exists. Worth saying: full search only works because the game is tiny. On 15×15 there are far too many positions, so a gomoku bot would search a few moves ahead and score positions with a rule of thumb. That’s another MoveChooser, too.
Three players
Interviewer: Three players, X, O and Δ, on a 4×4 board.
Already done. Players are a list, the turn wraps with % players.size(), and LineCounter keeps counts per mark. new Game(4, List.of(a, b, c), new LineCounter(4)) just works. This is where the ±1 counting trick from 9.3 would have broken, and why we avoided it.
Saving and replaying games
The move history is already a complete record: replaying those moves into a new game rebuilds it exactly. So saving a game means saving the list of moves, and loading it means replaying them. This is the same idea as an event log: store what happened, and the current state can always be rebuilt from it. It also gives you a replay feature for free.
A time limit per move
Don’t put a timer inside Game. Pass in a Clock and have the server check, when a move arrives, whether the player’s time ran out; a scheduled job can also end games where nobody moved. The game only needs one new outcome: a player who timed out loses. That’s a new GameState reason and a small method, not a rework.
Mistakes people make on this one
- *Writing one big
Gameclass with amainloop,* reading input and printing the board, with the rules mixed into the loop. - *
PieceXandPieceOclasses,* or aCellobject per square, when acharsays the same thing. - *
HumanPlayer extends Player,* tying who a player is to how their moves are chosen. - Scanning the whole board after every move, and not being able to explain why only the lines through the move matter.
- The ±1 counting trick without noticing it limits the game to two players.
- Throwing exceptions for normal bad moves, or worse, changing state before all the checks pass.
- Forgetting that undo must also undo the win rule’s counts.
- No locking in an online game, or a single lock for every game on the server.
- *Making
Gamea Singleton.*
How this answer gets scored
| Moment | What earns credit | What costs you |
|---|---|---|
| The first five minutes | Asking about board size, bots, online play and undo | “Everyone knows tic-tac-toe” and starting to code |
| Finding classes | Board, game, win rule, chooser; X and O as data | A class for every noun, and subclasses for X and O |
| Responsibilities | Game owns turns, board owns cells, rule owns wins | Everything in one class |
| Win check | Only the lines through the last move; counters for O(1) | Scanning the whole board, with no idea it matters |
| Bad moves | Results with reasons, checks in a sensible order, no change until all pass | Exceptions, or a half-applied move |
| Undo | History, plus undoing the rule’s state | Clearing the cell and forgetting the counts |
| Concurrency | Spotting the double click; one lock per game, and why not per cell or global | “It’s a game, there’s no concurrency” |
| Follow-ups | A new WinRule for gomoku, a new MoveChooser for the bot | Editing Game for each new idea |
The whole design on one page
- Structure:
Gameowns aBoard(behind a read-onlyBoardView), a list ofPlayers and a history ofMoves. - A move: check game over, then turn, then cell; place; ask the
WinRule; then won, draw, or pass the turn. - Win check: only lines through the last move.
LineCounterkeeps per-mark counts for constant time;KInARowscans outwards for gomoku. - What varies:
WinRuleandMoveChooserare strategies;GameListenerlets aScoreboardreact to results. - Undo: pop the move, clear the cell, undo the rule, reopen the game, give the turn back.
- Concurrency: one lock per game, because the turn is what conflicting moves share. Games run in parallel.
Stay in the loop
Get new problems and walkthroughs by email
One short email when something new is published: a deep-dive walkthrough, an LLD problem, or a real outage retold. No spam, unsubscribe any time.