The idea
What it is
“Design tic-tac-toe.” It is the friendliest prompt in the set, and it hides a trap: the problem is so small that a working solution is not evidence of anything. Nested ifs over nine hardcoded cells will play a correct game — and score badly.
What is actually being asked is: can you build a small game engine? Something where the board size is data, the win rule is a rule, and adding a second player or a bigger grid does not mean rewriting the win check. That is the difference between coding a puzzle and designing a system.
The whole system in three sentences
A Board of n × n cells and a queue of Players. play(row, col) validates the move, writes the piece, and updates a handful of counters. If any counter reaches ±n, that player has won — decided from a single number, never from a scan.
What separates a pass from a strong pass
- The 3 is a parameter, not a literal. If
nappears as3anywhere outside the constructor, you have hardcoded the problem. - Win detection does not scan. Rescanning eight lines every move is fine for a 3×3 grid and embarrassing on a 19×19 Gomoku board — and they will ask about Gomoku.
- Validation happens before mutation. An invalid move must leave the board exactly as it was.
- Two players is a list, not two variables.
Player x, o;cannot become three players;Queue<Player>can. - It runs. Print a board after each move. This is the one problem where a full game in
main()is genuinely quick.
Mechanics
How it works
Step 1 · Clarify — 3 minutes, and one question matters
- Is the board always 3×3? — the question that shapes everything. Answer for yourself: build it as n×n regardless, because it costs nothing.
- Is a win always a full line? — on 3×3 yes, but on a 5×5 board it is usually k in a row. Ask, then build
nand treatk = nas the default. - Two players, or more? — build a queue. Three players on a 5×5 board is a real variant and costs you one line.
- Do we need an AI opponent? — almost always no. If yes, it is a separate
Playerimplementation and you should say so, not start writing minimax. - Undo? Replay? A game log? — all three fall out of keeping a move history, so build the history even if they say no.
The trap is agreeing to 3×3 too eagerly
“Let's keep it 3×3” sounds like the interviewer being kind. Ten minutes later comes “now make it 10×10 with 5 in a row”, and a solution built around eight hardcoded lines has to be rewritten. Building n×n from the start costs about four extra lines.
Step 2 · Win detection — the whole reason this problem is asked
The obvious approach after every move: check all n rows, all n columns and both diagonals. That is O(n²) work per move, and it re-reads cells that have not changed since the game began.
But only one cell changed. And a cell can only ever belong to four lines: its row, its column, and the two diagonals if it happens to sit on them. So keep a running total per line and update just those.
+1 / −1 trick is what makes one integer per line enough. A counter can only reach +n if every cell in that line is X, and −n only if every cell is O — mixed lines cancel out on the way.// after writing the piece at (row, col), where value is +1 for X and -1 for O
rows[row] += value;
cols[col] += value;
if (row == col) diagonal += value;
if (row + col == n - 1) antiDiagonal += value;
boolean won = Math.abs(rows[row]) == n
|| Math.abs(cols[col]) == n
|| Math.abs(diagonal) == n
|| Math.abs(antiDiagonal) == n;When k is smaller than n
Once a win is k in a row on an n×n board (Connect 4, Gomoku), a single counter per line is no longer enough — a line can contain both players. The move is to scan outward from the placed cell in the four directions, counting consecutive matches. That is O(k) per move, still independent of n². Know that this is the next step; you rarely have to build it.
Step 3 · Validate before you mutate
Three things can be wrong with a move, and all three must be caught before the board changes. A half-applied move — piece written, counters not updated — is unrecoverable state.
Step 4 · The class diagram
n is a field on the Board, and the win rule is an interface. Everything the interviewer asks next is one of those two boxes. Notation: Class diagrams.Why Move should be immutable
A move is a fact about the past: this player put this piece here. Facts do not change. Keeping the list of them gives you undo (replay the counter arithmetic backwards), replay (apply them to a fresh board) and a game log — three features for one design decision. Related: Immutability & value objects and Command.
Undo is subtraction, not a snapshot
Because a move touched at most four counters and one cell, undoing it touches exactly the same things with the sign flipped. No board copies, no history of full states.
One move, message by message
Game orchestrates, Board owns the data and the counters, and WinCondition reads but never writes. Notation: Sequence diagrams.The follow-ups — this is a single family of games
n as data and the win rule as an object.- “Add a computer opponent.” →
Playerbecomes an interface withHumanPlayerandAiPlayer; the game loop does not change because it only ever asks a player for a move. Do not start writing minimax unless asked. - “Undo the last move.” → already free from the move history.
- “Save and resume a game.” → serialise the move list, not the board. Replaying it rebuilds board and counters, so there is only one thing to persist.
- “Watch a game live from another screen.” → the game publishes a
MovePlayedevent; renderers subscribe. That is Observer, and it keeps display code out of the rules. - “What if two players submit at once?” → in an online version,
play()must be atomic on the game. Same shape as the check-and-take in Parking Lot.
How this round is lost
3hardcoded. Eight explicit line checks, and the first follow-up destroys them.- Rescanning the whole board every move. It works, and it tells the interviewer you did not think about what actually changed.
Player x, o;as two fields. Two players is a coincidence of this game, not a fact about board games.- No draw detection, or draw detection by scanning for empty cells.
moves == n * nis one comparison. - A
char[][]and nothing else. NoMove, no history — so undo, replay and logging all become new work later.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
A playable game with its algorithm showing. Play a move and watch the right-hand panel: every mark bumps 2 to 4 counters — its row, its column, and the diagonals if it sits on one. X adds +1, O adds −1. The instant any counter hits ±n, that line is a win, and the board lights up. Nothing was ever scanned. The two boxes at the bottom keep score of that: cells read by a rescan versus counter reads — after five moves it is already 45 against 15. Then press 4×4 or 5×5: same code, same counters, bigger board. And ⎌ Undo — because a move is a tiny record, undoing is just subtraction.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Watch a single move do its work
Play the centre cell first. The explain line says 4 counters — row 1, column 1, and both diagonals, because the centre is the only cell on both. Now play a corner (3 counters) and then an edge (2 counters). The number of counters a cell touches is a property of where it sits, and that is the whole optimisation.
Win on a diagonal and read the number
Play X at top-left, O anywhere, X centre, O anywhere, X bottom-right. The \ counter goes +1 → +2 → +3, the line lights up, and the explain line tells you the board was never re-read. Check the two cost boxes: about 45 cells rescanned versus 15 counter reads, after five moves on the smallest possible board.
Try an illegal move
Click a cell that already has a piece. It shakes, the call line shows InvalidMoveException, and — the important part — the move count and every counter stay exactly where they were. Then click anywhere after a game has ended. Same story.
Undo a win
Win a game, then press ⎌ Undo. The winning counter drops from ±3 back to ±2, the highlight clears, and it is that player's turn again. No board snapshot was stored — the counters were simply run backwards.
Break the hardcoded 3
Press 5×5 and play a full row for X. The win fires at +5, not +3, and the counter panel now shows five rows and five columns with no code change. This is the exact follow-up an interviewer will throw at you; the chip exists so you can see what a parameterised n buys.
Build it from memory
Blank file, in this order: Piece enum → Move record → Board(n) with rows[], cols[], diag, anti → place() returning whether it won → Game with a player queue and history → play() with all three validations → main() that plays a full game and prints the board each move. Then add undo() — if it takes more than five lines, your Move is not carrying enough.
In practice
When to use it — and what trips people up
The pattern you just learned, generalised
The counter trick is an example of something worth naming, because it appears constantly once you look for it: keep a summary that is cheap to maintain incrementally, instead of recomputing it from scratch.
- Running totals — a dashboard that maintains
sumandcounton write rather than aggregating on every read. - Inverted indexes — a search index updated per document instead of scanning every document per query.
- Materialised views — the same idea, given a database name.
- Dirty flags and memoisation — recompute only what the last change could possibly have affected.
The precondition is always the same: you must be able to work out exactly what the change affects. In tic-tac-toe a cell affects at most four lines, and that bound is what makes the whole thing valid.
When you should not bother
- When the recompute is genuinely cheap and rare. On a 3×3 board played by humans, rescanning is free. Build the counters anyway here — but say that you know they are not needed at this size, and why you built them regardless.
- When the incremental update is hard to get right. A summary that can silently drift out of sync with the data is worse than a slow, obviously correct scan. Counters are safe because the update is two lines and the undo is the same two lines negated.
- When k < n. A single counter per line stops working the moment a line can hold both players' pieces — you need the directional scan instead. Knowing the limit of your own optimisation is worth as much as the optimisation.
If you only remember one thing
Only one cell changed, so only recompute what that cell could have affected. Say that sentence in the interview and then write four lines of code that do exactly it.
What it gives you
- Win detection is O(1) per move and completely independent of board size — the answer to “now make it 19×19”.
- The board size n is a constructor parameter, so bigger boards need zero new code.
- Players are a queue, so three or four players is a one-line change instead of a rewrite.
- An immutable Move history buys undo, replay, save/resume and a game log from one decision.
- Validation strictly precedes mutation, so a rejected move leaves the board and every counter untouched.
Common mistakes
- The +1/−1 counter scheme only works for exactly two players and a full-line win — three players or k-in-a-row need a different representation.
- Counters duplicate information already in the board, so any code path that writes cells directly can desynchronise them.
- On a 3×3 board the optimisation is invisible, which makes it look like over-engineering unless you explain why you built it.
- A WinCondition interface is genuinely more machinery than a 45-minute round needs unless the interviewer asks for variants.
- The design assumes moves arrive one at a time; an online multiplayer version needs play() to be atomic and this says nothing about that.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
enum Piece {
X(+1), O(-1);
final int value; // +1 / -1 is what makes one counter per line enough
Piece(int value) { this.value = value; }
}
record Player(String name, Piece piece) {}
/** A fact about the past — immutable, which is what gives us undo and replay. */
record Move(int row, int col, Player player) {}
class InvalidMoveException extends RuntimeException {
InvalidMoveException(String msg) { super(msg); }
}
class Board {
private final int n;
private final Piece[][] cells;
private final int[] rows, cols;
private int diag, antiDiag;
private int filled;
Board(int n) {
this.n = n;
this.cells = new Piece[n][n];
this.rows = new int[n];
this.cols = new int[n];
}
int size() { return n; }
boolean isFull(){ return filled == n * n; }
boolean inBounds(int r, int c) { return r >= 0 && r < n && c >= 0 && c < n; }
boolean isEmpty(int r, int c) { return cells[r][c] == null; }
Piece at(int r, int c) { return cells[r][c]; }
/** Writes the piece and updates at most four counters. Returns true if this move wins. */
boolean place(int r, int c, Piece piece) {
cells[r][c] = piece;
filled++;
int v = piece.value;
rows[r] += v;
cols[c] += v;
if (r == c) diag += v;
if (r + c == n - 1) antiDiag += v;
return Math.abs(rows[r]) == n
|| Math.abs(cols[c]) == n
|| Math.abs(diag) == n
|| Math.abs(antiDiag) == n;
}
/** Undo is the same arithmetic with the sign flipped — no snapshots. */
void remove(int r, int c) {
Piece piece = cells[r][c];
if (piece == null) return;
cells[r][c] = null;
filled--;
int v = piece.value;
rows[r] -= v;
cols[c] -= v;
if (r == c) diag -= v;
if (r + c == n - 1) antiDiag -= v;
}
void print() {
for (int r = 0; r < n; r++) {
StringBuilder sb = new StringBuilder(" ");
for (int c = 0; c < n; c++) sb.append(cells[r][c] == null ? "." : cells[r][c].name()).append(' ');
System.out.println(sb);
}
}
}
enum Status { IN_PROGRESS, WON, DRAW }
class Game {
private final Board board;
private final Deque<Player> turnOrder = new ArrayDeque<>(); // a queue, so 3 players costs one line
private final List<Move> history = new ArrayList<>();
private Status status = Status.IN_PROGRESS;
private Player winner;
Game(int n, List<Player> players) {
if (players.size() < 2) throw new IllegalArgumentException("need at least two players");
this.board = new Board(n);
this.turnOrder.addAll(players);
}
Status status() { return status; }
Player winner() { return winner; }
Player current() { return turnOrder.peekFirst(); }
Board board() { return board; }
Status play(int r, int c) {
// ---- validate first; nothing has changed yet ----
if (status != Status.IN_PROGRESS) throw new InvalidMoveException("game is over");
if (!board.inBounds(r, c)) throw new InvalidMoveException("off the board: " + r + "," + c);
if (!board.isEmpty(r, c)) throw new InvalidMoveException("cell taken: " + r + "," + c);
// ---- now mutate ----
Player player = turnOrder.removeFirst();
history.add(new Move(r, c, player));
boolean won = board.place(r, c, player.piece());
if (won) { status = Status.WON; winner = player; return status; }
if (board.isFull()) { status = Status.DRAW; return status; }
turnOrder.addLast(player); // rotate only when the game continues
return status;
}
void undo() {
if (history.isEmpty()) return;
Move m = history.remove(history.size() - 1);
board.remove(m.row(), m.col());
if (status == Status.IN_PROGRESS) turnOrder.removeLast(); // un-rotate
turnOrder.addFirst(m.player());
status = Status.IN_PROGRESS;
winner = null;
}
}
public class Main {
public static void main(String[] args) {
Player asha = new Player("Asha", Piece.X);
Player ravi = new Player("Ravi", Piece.O);
Game game = new Game(3, List.of(asha, ravi));
int[][] script = {{0,0},{0,1},{1,1},{0,2},{2,2}}; // X wins on the \ diagonal
for (int[] mv : script) {
System.out.println(game.current().name() + " plays " + mv[0] + "," + mv[1]);
Status s = game.play(mv[0], mv[1]);
game.board().print();
if (s == Status.WON) { System.out.println("=> " + game.winner().name() + " wins"); break; }
if (s == Status.DRAW) { System.out.println("=> draw"); break; }
}
try { game.play(1, 0); }
catch (InvalidMoveException e) { System.out.println("rejected: " + e.getMessage()); }
game.undo();
System.out.println("after undo, status=" + game.status() + ", next=" + game.current().name());
// the follow-up: same code, bigger board
Game big = new Game(5, List.of(asha, ravi));
for (int i = 0; i < 5; i++) { big.play(0, i); if (big.status() == Status.IN_PROGRESS) big.play(1, i); }
System.out.println("5x5 => " + big.status() + " by " + big.winner().name());
}
}References & further reading
6 sources- Articlegithub.com
awesome-low-level-design — Tic Tac Toe
A second take on the same problem — compare its class split against yours.
- Docs
LeetCode 348 — Design Tic-Tac-Toe
The exact counter optimisation as a standalone algorithm problem (a premium problem). If the interview turns algorithmic, this is where it goes.
- Docsrefactoring.guru
Command pattern — Refactoring Guru
The general form of “an action that knows how to undo itself”, which is what Move plus the history list is.
- Articleen.wikipedia.org
m,n,k-game — the general family
Tic-tac-toe, Gomoku and Connect Four are all one parameterised game. Useful vocabulary for the follow-ups.
- Bookgameprogrammingpatterns.com
Game Programming Patterns — Bruce Nystrom
Free online. The Command and State chapters are directly applicable to any board-game design question.
- Book
Effective Java — prefer immutable value types
Item 17. The argument for why Move and Player should be records rather than mutable objects.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
After a move at (r, c), which lines is it enough to check for a win?
question 02 / 08
Why represent X as +1 and O as −1 rather than as two separate counts per line?
question 03 / 08
The interviewer says: “now make it 10×10 with 5 in a row to win.” What actually has to change?
question 04 / 08
Why store the players in a queue rather than as two fields x and o?
question 05 / 08
A player clicks a cell that already has a piece. What must be true afterwards?
question 06 / 08
How should a draw be detected?
question 07 / 08
Why is undo cheap in this design?
question 08 / 08
On a 3×3 board the counter optimisation saves almost nothing. Should you still build it?
0/8 answered