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.
Article author
Hyive uses necessary cookies for security, sign-in, anti-abuse controls, and remembering this choice. Optional analytics uses privacy-limited PostHog Cloud EU and consent-gated browser Sentry only after you allow analytics. Advertising remains inactive.
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.
Article author
LLD 03 — Design Tic-Tac-Toe: Building a Clean Game Engine in Java<p>Nine cells. Two players. Put an <code>X</code> or an <code>O</code> into an empty square and check whether somebody has completed a line.</p><p>It is tempting to solve Tic-Tac-Toe with one class and twenty minutes of <code>if</code> statements.</p><p>For a fixed 3×3 board, that would even work.</p><p>But an LLD interviewer usually cares about what happens after the obvious solution works.</p><p>What if the board becomes 5×5?</p><p>What if we want a different winning rule?</p><p>Who decides whether a move is legal?</p><p>Should <code>Player</code> modify the board directly?</p><p>Should the board itself decide whose turn comes next?</p><p>And why should adding a new win-checking algorithm require touching the entire game engine?</p><p>That is where Tic-Tac-Toe becomes a useful design problem.</p><p>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.</p><hr><h2>First Decide What Game We Are Building</h2><p>Our baseline version has deliberately simple rules.</p><p>Two players participate.</p><p>Each player owns one symbol:</p><pre><code>X O</code></pre><p>The board is <code>N × N</code>.</p><p>For the initial implementation, winning means occupying an entire:</p><ul><li><p>row</p></li><li><p>column</p></li><li><p>main diagonal</p></li><li><p>anti-diagonal</p></li></ul><p>So on a 3×3 board, a player needs three symbols in a line. On a 5×5 board, that same rule requires five.</p><p>Players alternate turns.</p><p>A move is valid only when:</p><ul><li><p>the game is still running</p></li><li><p>the position lies inside the board</p></li><li><p>the selected cell is empty</p></li></ul><p>The game ends with either:</p><pre><code>WIN</code></pre><p>or:</p><pre><code>DRAW</code></pre><p>We are not adding networking, matchmaking, timers, persistence, spectators, AI opponents or multiplayer servers.</p><p>Those are separate problems.</p><hr><h2>The First Design Question: Who Owns What?</h2><p>Before drawing a UML diagram, it helps to state the responsibilities in plain English.</p><ul><li><p><code>Player</code> represents a participant</p></li><li><p><code>Board</code> owns the grid and enforces board rules</p></li><li><p><code>Move</code> records a player action</p></li><li><p><code>WinningStrategy</code> decides if a move wins</p></li><li><p><code>TicTacToeGame</code> orchestrates the match</p></li></ul><p>Flow:</p><pre><code>whose turn is it?↓is the game running?↓apply move↓check win↓check draw↓switch player</code></pre><p>Each object has a single reason to change.</p><hr><img src="https://media.hyive.com/users/f5853095-943d-4758-9e77-4b2200b54772/a5ceae4f-95b1-46d9-ac87-c680b343056b.png" alt="image"><h2>A Player Does Not Need a Huge Class Hierarchy</h2><p>Instead of:</p><pre><code>Player├── XPlayer└── OPlayer</code></pre><p>We use:</p><pre><code>public enum Symbol { X, O}</code></pre><pre><code>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; }}</code></pre><hr><h2>Represent a Position Explicitly</h2><pre><code>public record Position(int row, int column) {}</code></pre><pre><code>public record Move( Player player, Position position) {}</code></pre><hr><h2>The Board Owns the Grid</h2><pre><code>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(); }}</code></pre><hr><h2>Winning Strategy</h2><pre><code>public interface WinningStrategy { boolean isWinningMove(Board board, Move lastMove);}</code></pre><pre><code>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; }}</code></pre><hr><h2>Game Engine</h2><pre><code>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); }}</code></pre><hr><img src="https://media.hyive.com/users/f5853095-943d-4758-9e77-4b2200b54772/6681eea5-5ed7-4a68-a0c4-c5dcfee6f031.png" alt="image"><h2>Key Design Insight</h2><p>Separate:</p><ul><li><p>Game engine → flow control</p></li><li><p>Board → state</p></li><li><p>Strategy → rules</p></li></ul><p>This makes the system extensible without modifying core logic.</p><hr><h2>Core Lesson</h2><blockquote><p>Do not mix <strong>game flow</strong>, <strong>state management</strong>, and <strong>rule evaluation</strong>.</p></blockquote><p>That separation is what makes Tic-Tac-Toe a real LLD problem instead of a coding exercise.</p>
ssh / indianDevelopers
ArticleUnderstand how UPI connects apps, PSPs, banks and NPCI, with a developer-focused look at payment flow, authentication, failures and settlement.
ssh / indianDevelopers
StoryI’ve spent some time comparing GPT-6 Astra and Fable 5 , and honestly, Astra feels like the stronger model overall . Fable 5 is not bad at all — it can be fast, creative, and sometimes surprisingly smooth in casual tasks. But when I look at what really matters in day-to-day us…
Conversation
Comments