This is a classic LLD interview problem. Walk through it like you would at a whiteboard, out loud, one decision at a time. I have run this round maybe a hundred times across L4 and L5 loops, and the pattern is always the same: the candidates who shine are not the ones with the fanciest code. They ask two sharp questions first, draw five or six honest classes, and leave room to grow when I push. The weak ones jump to a nested if tree for win detection and never recover.
If you remember five things
The whole answer in one glance. Everything below defends these five calls.
- Board knows nothing about winning. It stores marks and checks bounds. The win rule lives elsewhere. This one boundary is what makes the design survive every variant.
- Win rule is a Strategy. One swappable object owns the rule, so "four in a row" or "K in a row" is a new class, not a rewrite.
- Check the last move, not the whole board. Only the row, column, and diagonals the move touched can win, so detection is O(N) per move, not O(N squared).
- Win before draw. A move can fill the final cell and complete a line at once. Check the win first or you rob the player of a real win.
- Lock per game, not globally. The race is a double submit on one turn, so guard the board and turn pointer together, per game instance.
1Clarify the requirements#
Pin down what you are building before you touch a class name. Tic-Tac-Toe sounds obvious, but the interviewer almost always has a variant in mind, and catching it is the whole point of this round.
Questions I ask out loud:
- Standard 3x3, or an N x N board? If N x N, does a win mean a full row, column, or diagonal, or K in a row?
- Two players only, or more marks (say X, O, and a third symbol)?
- Single process with two humans taking turns, or is there a network layer or an AI opponent?
- Do we need replay, undo, or persisted state? Or is one in-memory game enough?
- What happens on an illegal move: throw, return a status, or ignore it?
For this guide I lock the scope here.
Functional:
- A board of configurable size N, default 3.
- Two players, each with a distinct symbol.
- Players alternate turns. The engine rejects a move on an occupied cell or out of bounds.
- After each move, the engine reports one of three things: game in progress, a winner, or a draw.
- A win is any full row, any full column, or one of the two main diagonals.
Non-functional:
- The move check must be O(1) amortized, not a full O(N squared) board scan every turn. On a 3x3 the scan is free, so this does not matter for correctness. But the interviewer is really asking "can you make win detection cheap," so I show that I can.
- The design should extend to N x N and to new rules without rewriting the core.
- One game runs in one thread in the base version. Concurrency comes in section 6.
I am scoring this opening before you write any code. Making the "report status after each move" and "cheap win check" requirements explicit is what separates a 5-minute toy from a real design discussion. Skip this step and you will redesign halfway through the moment I say "now make it N x N." Name the variant question first; it tells me you design for change.
2Identify the core entities#
List the nouns, then throw out the ones that do not earn a class. On an Easy problem, every class has to justify its own existence.
- Board. Owns the grid and knows how to place a mark and report its dimensions. It must NOT know the rules of winning. That is the single most important boundary in this design, and I say so out loud. Keeping rules out of the board is what makes it reusable across variants.
- Cell / Symbol. A cell holds a symbol or is empty. I model the symbol as an enum:
X,O,EMPTY. I do not make a separateCellobject on a problem this size. A 2D array of symbols is cleaner with zero wasted objects. - Player. A name plus the symbol they play. Thin, but real. It gives me a place to hang a human or an AI later without touching the game loop.
- Move. Row, column, and who made it. Modeling the move as an immutable value object pays off the moment someone asks for undo or replay, and it costs almost nothing now.
- WinningStrategy. The rule that decides if the last move ended the game. Pulling this out of the board is the single best decision here. It swaps 3-in-a-row for "four in a row" without touching the board or the loop.
- Game. The orchestrator. Holds the board, the players, whose turn it is, and the status. It drives the loop and asks the strategy whether anyone won.
- GameStatus. An enum:
IN_PROGRESS,WIN,DRAW.
Notice what I did NOT make:
- A
Gridclass separate fromBoard. - A
RuleEnginemanager. - A
GameFactoryinterface with three implementations.
On an Easy problem, over-abstraction reads as worse, not better. I add abstraction only where I can name a second concrete thing that would use it today.
Burying win detection inside the Board, then freezing when I say "now it is four in a row." If the rule lives in the board, your design is stuck the moment the rule changes. Pull it into a strategy before I ask, and say why.
3Class design#
The shape is simple. Game is the brain. It composes a Board, a list of Player, and a WinningStrategy. The board is dumb storage with bounds checking. The strategy is the only place rules live.
A few design notes before the diagram:
Boardowns its cells by composition. If the game dies, the board dies with it. No board exists on its own, so this is a filled diamond.Gameholds players by aggregation. APlayercould outlive one game and join another, so this is a hollow diamond.WinningStrategyis an interface with one method.RowColDiagonalStrategyimplements the classic rules. Swapping the implementation is how we handle every variant.Moveis a plain immutable value object passed intoGame.play.
Game *-- Board is composition (the board cannot outlive the game), Game o-- Player is aggregation (a player can). The one arrow that carries the design is WinningStrategy <|-- RowColDiagonalStrategy: the rule is behind an interface, so every variant is a new implementation.Here is the runtime flow for a single turn, the part interviewers probe hardest. They want to see Game orchestrate and delegate, not do everything itself.
Game does not place marks or judge wins itself. It delegates storage to Board and the rule to WinningStrategy, then hands the caller a plain status. That delegation is exactly the signal the interviewer is reading for.4Key implementation#
I show the parts that carry the design: the enums, the board, the strategy with the cheap win check, and the game loop. I skip getters and constructors where they add nothing on a whiteboard.
public enum Symbol { X, O, EMPTY }
public enum GameStatus { IN_PROGRESS, WIN, DRAW }
public class Move {
final int row, col;
final Symbol symbol;
Move(int row, int col, Symbol symbol) {
this.row = row; this.col = col; this.symbol = symbol;
}
}
The board is deliberately thin:
- Stores marks in a 2D array.
- Checks bounds and rejects occupied cells.
- Counts fills so a draw is an O(1) check, not a full-grid scan.
public class Board {
private final Symbol[][] cells;
private final int size;
private int filled = 0;
public Board(int size) {
this.size = size;
this.cells = new Symbol[size][size];
for (Symbol[] row : cells) java.util.Arrays.fill(row, Symbol.EMPTY);
}
public boolean place(int row, int col, Symbol sym) {
if (row < 0 || row >= size || col < 0 || col >= size) return false;
if (cells[row][col] != Symbol.EMPTY) return false;
cells[row][col] = sym;
filled++;
return true;
}
public boolean isFull() { return filled == size * size; }
public Symbol get(int row, int col) { return cells[row][col]; }
public int getSize() { return size; }
}
Reveal the answer
Now the part that earns points. I only check the lines the last move actually touched:
- Its row and its column, always.
- The main diagonal, only if
row == col. - The anti-diagonal, only if
row + col == n - 1.
That is O(N) per move. Here is the clean version, which reads well on a whiteboard and is the right default to write first.
public class RowColDiagonalStrategy implements WinningStrategy {
@Override
public boolean checkWin(Board board, Move move) {
int n = board.getSize();
Symbol s = move.symbol;
boolean rowWin = true, colWin = true;
for (int i = 0; i < n; i++) {
if (board.get(move.row, i) != s) rowWin = false;
if (board.get(i, move.col) != s) colWin = false;
}
if (rowWin || colWin) return true;
if (move.row == move.col) {
boolean diag = true;
for (int i = 0; i < n; i++)
if (board.get(i, i) != s) { diag = false; break; }
if (diag) return true;
}
if (move.row + move.col == n - 1) {
boolean anti = true;
for (int i = 0; i < n; i++)
if (board.get(i, n - 1 - i) != s) { anti = false; break; }
if (anti) return true;
}
return false;
}
}
If the interviewer wants true O(1), here is the trade:
- Swap the scans for running counters: one per row, one per column, one for each diagonal.
- On each move, increment the counter for the current player on the lines it touches.
- Any counter hitting N means a win.
- Cost: O(N) counters of memory. Payoff: constant-time checks per move.
- I only write it out if they ask, because the O(N) version is clearer and 3x3 does not care. Saying "I know the faster version and here is exactly how" is usually worth more than five minutes of coding it.
Finally the orchestrator. It owns turn order, placement, and status, and nothing else.
public class Game {
private final Board board;
private final List<Player> players;
private final WinningStrategy strategy;
private int current = 0;
private GameStatus status = GameStatus.IN_PROGRESS;
public Game(int size, List<Player> players, WinningStrategy strategy) {
this.board = new Board(size);
this.players = players;
this.strategy = strategy;
}
public GameStatus play(int row, int col) {
if (status != GameStatus.IN_PROGRESS)
throw new IllegalStateException("Game is over");
Player p = players.get(current);
if (!board.place(row, col, p.getSymbol()))
throw new IllegalArgumentException("Illegal move at " + row + "," + col);
Move move = new Move(row, col, p.getSymbol());
if (strategy.checkWin(board, move)) {
status = GameStatus.WIN;
} else if (board.isFull()) {
status = GameStatus.DRAW;
} else {
current = (current + 1) % players.size();
}
return status;
}
public GameStatus getStatus() { return status; }
}
The key move in that loop:
- On a win or a draw, I do NOT advance the turn, and I freeze the status.
- The caller gets the result straight back.
- A UI or a network layer just reacts to the return value instead of poking at internal state.
5Design decisions and patterns used#
One pattern carries this problem. The rest are options you name and hold in reserve.
- Strategy pattern.
WinningStrategyis the heart of it. The board stores marks, the game runs the loop, the win rule lives in one swappable object. When the interviewer says "now it is four in a row" or "add Connect Four scoring," I hand them a new strategy and change nothing else. This is the one pattern they are checking you know to apply here. - Value object.
Moveand the enums are immutable little carriers. Immutability means I can log them, queue them, or replay them without worrying who mutated what behind my back. - Composition over inheritance.
Gameis built from a board, players, and a strategy rather than extending someAbstractGame. A Tic-Tac-Toe game is not a kind of board; it has one. Keeping that honest avoids the deep hierarchies that turn brittle the moment requirements shift. - Factory, held in reserve. If the interviewer asks for several game types, I introduce a small factory that picks the board size and strategy from a config. I do not add it up front. On an Easy problem, an unused factory is clutter, and clutter costs points.
- Observer, mentioned not built. If a UI or scoreboard needs to react to each move, I would add a listener interface that
Gamenotifies afterplay. I name it so the interviewer knows I see the seam, then build it only if they want it.
Pick the one pattern the problem actually needs, which is Strategy, and treat the rest as options you reach for on demand.
6Handling edge cases and concurrency#
The cases I make sure my code survives:
- Move on an occupied cell.
Board.placereturns false,Game.playthrows a clear exception. I prefer throwing over silently ignoring, because a swallowed illegal move hides bugs in the caller and desyncs the UI from the real state. - Out-of-bounds coordinates. Same path:
placereturns false, the game rejects with anIllegalArgumentException. - Playing after the game ended.
playguards onstatusand throwsIllegalStateException. Nobody marks a cell after a win or a draw. - The draw-vs-win order. I check the win first, then the full board. A single move can fill the last cell AND complete a line at once. Check "full" first and you wrongly call a draw and rob the player of a legitimate win. This exact ordering bug is a thing I watch for, and plenty of people get it backwards.
- The winning move also being the last cell. Covered by checking win before draw, as above.
Win-before-draw is a one-line decision that proves you actually traced the last move of a full game. Checking the board-full flag first is the most common silent bug in this problem. Naming it unprompted, with the reason (a move can win and fill the final cell at once), is cheap signal that you think about the end states, not just the happy path.
On concurrency, the base version assumes one thread drives one game, the normal setup for a turn-based game. But the interviewer may ask "what if two clients send moves at once?"
- The race is NOT two players moving simultaneously, since turns alternate by rule.
- The real race is a double submit: two requests for the same turn landing together.
- I protect
playwith a lock on the game instance (or make the method synchronized), so the second request sees the already-updated turn and the already-filled cell, and gets rejected. - The board state and the
currentpointer are the shared mutable things. Guard them together, or you get a torn update where two marks land on the same turn and the counter advances twice. - For a server hosting many games, I lock per game, not globally. One hot game should never stall a thousand cold ones.
7Extensions and follow-ups#
Where this design goes when I get pushed:
- N x N or K-in-a-row. Board size is already a constructor argument. For "K in a row on an N board," I write a new strategy that scans a window around the last move. Board untouched, game untouched.
- AI opponent.
Playerbecomes an interface withgetMove(board). AHumanPlayerreads input, aBotPlayerruns minimax. The loop does not change; it just asks the current player for a move. - Undo and replay. Every turn produces an immutable
Move, so I push them onto a stack. Undo pops the last move, clears that cell, decrementsfilled, and rewinds the turn pointer. Replay reapplies the list in order. - Networked multiplayer. The game already returns a status per move, so a thin server wraps
play, broadcasts the new state, and the Observer seam pushes updates to both clients. - Scoring across rounds. A
Matchobject holds severalGameinstances and a score map. The single-game design stays pure and untouched.
Every one of these lands cleanly because the board does not know the rules and the game returns a plain status. That separation is the whole point, and it is why I spend the first two minutes defending the board-knows-nothing-about-winning boundary.
"Make win detection truly O(1) per move. How?"
"Now it is four-in-a-row on a big board. What changes?"
"A move fills the final empty cell and completes a line. Win or draw?"
"Two clients submit moves at the same time. What breaks?"
"How would you add an AI player without touching the loop?"
8What the interviewer is really checking#
I am not grading whether you can detect three X's in a row. A kid can do that. Here is the rubric I actually score on:
- Did you scope it before coding? Asking about N x N and win conditions tells me you design for change instead of hardcoding 3.
- Is the win rule separated from storage? If win detection is buried in the board, your design is stuck the moment rules change. If it is a strategy, you are thinking in seams. This is the line between a hire and a no-hire for me on this problem.
- Right amount of abstraction. Too little and it is a pile of arrays. Too much and I cannot find the logic. The sweet spot on an Easy problem is five or six honest classes, exactly what we built.
- The ordering and edge cases. Win before draw, reject moves after game over, clean handling of illegal input. These show care, and care is the cheapest signal to send.
- Can you grow it on demand? When I say "now add an AI," a good design barely flinches. That calm is the signal I am buying.
What you can now explain in the room
- Why keeping the win rule out of the board is the one boundary that makes this design survive every variant.
- How the Strategy pattern turns "four in a row" into a new class instead of a rewrite.
- Why checking only the lines the last move touched drops win detection from O(N squared) to O(N) per move, and how counters make it O(1).
- Why you must check win before draw, and the bug you cause if you reverse them.
- The real concurrency race (a double submit on one turn) and why you lock per game, not globally.
- How undo, replay, AI opponents, and networked play all slot in without touching the core.
Next, try Design a Parking Lot or Design an Elevator System to see the Strategy and State patterns stretch across bigger boards.
Get a prep plan built around your Design Tic-Tac-Toe interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.