Technology · ssh / indianDevelopers
LLD 03 — Design Tic-Tac-Toe: Building a Clean Game in Java
Tic-Tac-Toe becomes an interesting LLD problem when the board size changes, rules evolve, invalid moves appear and the game engine must remain maintainable.
Nine cells. Two players. Put an X or an O into an empty square and check whether somebody has completed a line.
It is tempting to solve Tic-Tac-Toe with one class and twenty minutes of if statements.
For a fixed 3×3 board, that would even work.
But an LLD interviewer usually cares about what happens after the obvious solution works.
What if the board becomes 5×5?
What if we want a different winning rule?
Who decides whether a move is legal?
Should Player modify the board directly?
Should the board itself decide whose turn comes next?
And why should adding a new win-checking algorithm require touching the entire game engine?
That is where Tic-Tac-Toe becomes a useful design problem.
The goal is not to make a tiny game complicated. It is to find a few clean boundaries so that the code remains understandable when the requirements move.
First Decide What Game We Are Building
Our baseline version has deliberately simple rules.
Two players participate.
Each player owns one symbol:
X
OThe board is N × N.
For the initial implementation, winning means occupying an entire:
row
column
main diagonal
anti-diagonal
So on a 3×3 board, a player needs three symbols in a line. On a 5×5 board, that same rule requires five.
Players alternate turns.
A move is valid only when:
the game is still running
the position lies inside the board
the selected cell is empty
The game ends with either:
WINor:
DRAWWe are not adding networking, matchmaking, timers, persistence, spectators, AI opponents or multiplayer servers.
Those are separate problems.
The First Design Question: Who Owns What?
Before drawing a UML diagram, it helps to state the responsibilities in plain English.
Playerrepresents a participantBoardowns the grid and enforces board rulesMoverecords a player actionWinningStrategydecides if a move winsTicTacToeGameorchestrates the match
Flow:
whose turn is it?
↓
is the game running?
↓
apply move
↓
check win
↓
check draw
↓
switch player
Each object has a single reason to change.

A Player Does Not Need a Huge Class Hierarchy
Instead of:
Player
├── XPlayer
└── OPlayerWe use:
public enum Symbol {
X,
O
}
public final class Player {
private final String name;
private final Symbol symbol;
public Player(String name, Symbol symbol) {
this.name = Objects.requireNonNull(name);
this.symbol = Objects.requireNonNull(symbol);
}
public String getName() {
return name;
}
public Symbol getSymbol() {
return symbol;
}
}
Represent a Position Explicitly
public record Position(int row, int column) {}public record Move(
Player player,
Position position
) {}The Board Owns the Grid
public final class Board {
private final int size;
private final Symbol[][] cells;
private int occupiedCells;
public Board(int size) {
if (size < 3) {
throw new IllegalArgumentException("Board size must be at least 3");
}
this.size = size;
this.cells = new Symbol[size][size];
}
public int size() {
return size;
}
public Symbol get(Position position) {
validateBounds(position);
return cells[position.row()][position.column()];
}
public void place(Position position, Symbol symbol) {
Objects.requireNonNull(symbol);
validateBounds(position);
if (get(position) != null) {
throw new InvalidMoveException("Cell is already occupied");
}
cells[position.row()][position.column()] = symbol;
occupiedCells++;
}
public boolean isFull() {
return occupiedCells == size * size;
}
private void validateBounds(Position position) {
if (position.row() < 0 || position.row() >= size ||
position.column() < 0 || position.column() >= size) {
throw new InvalidMoveException("Position out of bounds");
}
}
public String render() {
StringBuilder sb = new StringBuilder();
for (int r = 0; r < size; r++) {
for (int c = 0; c < size; c++) {
Symbol s = cells[r][c];
sb.append(s == null ? "." : s.name());
if (c < size - 1) sb.append(" ");
}
sb.append(System.lineSeparator());
}
return sb.toString();
}
}
Winning Strategy
public interface WinningStrategy {
boolean isWinningMove(Board board, Move lastMove);
}
public final class StraightLineWinningStrategy implements WinningStrategy {
@Override
public boolean isWinningMove(Board board, Move lastMove) {
Position p = lastMove.position();
Symbol symbol = lastMove.player().getSymbol();
return row(board, p.row(), symbol)
|| column(board, p.column(), symbol)
|| diagonal(board, p, symbol);
}
private boolean row(Board board, int row, Symbol symbol) {
for (int c = 0; c < board.size(); c++) {
if (board.get(new Position(row, c)) != symbol) return false;
}
return true;
}
private boolean column(Board board, int col, Symbol symbol) {
for (int r = 0; r < board.size(); r++) {
if (board.get(new Position(r, col)) != symbol) return false;
}
return true;
}
private boolean diagonal(Board board, Position p, Symbol symbol) {
boolean main = p.row() == p.column();
boolean anti = p.row() + p.column() == board.size() - 1;
if (main) {
for (int i = 0; i < board.size(); i++) {
if (board.get(new Position(i, i)) != symbol) return false;
}
return true;
}
if (anti) {
int n = board.size();
for (int i = 0; i < n; i++) {
if (board.get(new Position(i, n - 1 - i)) != symbol) return false;
}
return true;
}
return false;
}
}
Game Engine
public final class TicTacToeGame {
private final Board board;
private final List<Player> players;
private final WinningStrategy strategy;
private int currentIndex;
private GameStatus status = GameStatus.IN_PROGRESS;
private Player winner;
public TicTacToeGame(int size, Player p1, Player p2, WinningStrategy strategy) {
this.board = new Board(size);
this.players = List.of(p1, p2);
this.strategy = strategy;
}
public MoveResult play(int row, int col) {
if (status != GameStatus.IN_PROGRESS) {
throw new IllegalStateException("Game over");
}
Player player = players.get(currentIndex);
Position pos = new Position(row, col);
board.place(pos, player.getSymbol());
Move move = new Move(player, pos);
if (strategy.isWinningMove(board, move)) {
status = GameStatus.WON;
winner = player;
return snapshot();
}
if (board.isFull()) {
status = GameStatus.DRAW;
return snapshot();
}
currentIndex = (currentIndex + 1) % players.size();
return snapshot();
}
private MoveResult snapshot() {
return new MoveResult(status, players.get(currentIndex), winner);
}
}

Key Design Insight
Separate:
Game engine → flow control
Board → state
Strategy → rules
This makes the system extensible without modifying core logic.
Core Lesson
Do not mix game flow, state management, and rule evaluation.
That separation is what makes Tic-Tac-Toe a real LLD problem instead of a coding exercise.

Conversation
Comments
Sign in to join the conversation.