Last Updated: March 16, 2026
Tic Tac Toe is a classic two-player game played on a 3x3 grid. Players take turns marking empty cells with their respective symbols: X or O.
Loading simulation...
The goal is to be the first to place three of your symbols in a row, either horizontally, vertically, or diagonally. At the same time, you must try to prevent your opponent from achieving the same. If all cells are filled and no player wins, the game ends in a draw.
In this chapter, we will explore the low-level design of a tic tac toe game in detail.
Lets start by clarifying the requirements:
Before starting any design, it's important to ask thoughtful questions to uncover hidden assumptions, clarify ambiguities, and define the system's scope.
Here is an example of how a discussion between the candidate and the interviewer might unfold:
Candidate: "Should the game support variable board sizes, such as 4x4 or 5x5?"
Interviewer: "For the purpose of this interview, let’s stick with the standard 3x3 board."
Candidate: "Should the game support both player-vs-player and player-vs-computer modes?"
Interviewer: "Let’s keep it simple and focus only on the player-vs-player mode for now."
Candidate: "What should happen if a player tries to make an invalid move, like selecting an already filled cell?"
Interviewer: "The game should reject the move and inform the player to make another selection."
Candidate: "Should the system maintain a scoreboard across multiple games to track player wins?"
Interviewer: "Yes, tracking the scoreboard across games would be a good addition."
Candidate: "How should the user input be handled? Should we take input from the console, or just hardcode a sample game sequence?"
Interviewer: "To keep things focused on the design, you can hardcode a sample sequence in a demo or main method."
Candidate: "Should we track the history of moves to allow features like undo or move replay?"
Interviewer: "That's an interesting feature to consider, but let’s leave it out for now and focus on the core gameplay logic."
After gathering the details, we can summarize the key system requirements.
After the requirements are clear, the next step is to identify the core entities that we will form the foundation of our design.
How do you go from a list of requirements to actual entities/classes?
The key is to look for nouns in the requirements that have distinct attributes or behaviors. Not every noun becomes a class, but this approach gives you a starting point.
Let's walk through our requirements and identify what needs to exist in our system.
The grid is central to everything. We need something to represent it. This gives us our first entity: Board.
But what is a grid made of? Individual squares. Each square can be empty or contain a symbol. This suggests a second entity: Cell. The Board will contain 9 Cells arranged in a 3x3 structure.
Because they have different responsibilities. The Board manages the overall grid structure and operations like "is the board full?" or "place a symbol at position (1,2)". The Cell just holds a single value.
This separation also makes the code cleaner. If we later want to add features like highlighting the winning cells, the Cell class is the natural place for that logic.
We need to represent players. Each player has a name and an assigned symbol. This gives us the Player entity.
What about the symbols themselves?
We could use strings ("X", "O") or characters, but that's error-prone. What stops someone from creating a player with symbol "Z"?
Using an enum Symbol with values X, O, and EMPTY gives us type safety. The compiler will catch invalid symbols at compile time rather than runtime.
Something needs to coordinate the gameplay: accept moves, validate them, check for wins, switch turns. This orchestrator is our Game entity.
The game also needs to track its current state. Is it still in progress? Did someone win? Is it a draw?
We could use a boolean isGameOver and a winner field, but that gets messy. What if we need to distinguish between "X won" and "O won"?
An enum GameStatus with values IN_PROGRESS, WINNER_X, WINNER_O, and DRAW captures all possibilities cleanly.
A single Game object handles one game. But our requirements say we need to track scores across multiple games. This suggests two more entities:
Scoreboard: Tracks how many times each player has wonTicTacToeSystem: A central controller that creates games and maintains the shared scoreboardThe TicTacToeSystem acts as a facade. External code doesn't need to know about Game, Board, or Scoreboard directly. It just calls system.createGame() and system.makeMove().
Here's how these entities relate to each other:
We've identified three types of entities:
Enums define fixed sets of values. They provide type safety and make code self-documenting.
Data Classes primarily hold data with minimal behavior. Player and Cell are simple containers.
Core Classes contain the main logic. Board manages the grid, Game orchestrates gameplay, Scoreboard tracks history, and TicTacToeSystem ties everything together.
With our entities identified, let's define their attributes, behaviors, and relationships.
Now that we know what entities we need, let's flesh out their details. For each class, we'll define what data it holds (attributes) and what it can do (methods). Then we'll look at how these classes connect to each other.
While listing class methods, we will skip trivial getters and setters to keep the walkthrough focused on core behaviors
We'll work bottom-up: simple types first, then data containers, then the classes with real logic. This order makes sense because complex classes depend on simpler ones.
Enums define fixed sets of values that provide type safety and make code self-documenting. Using enums prevents invalid states at compile time rather than runtime.
SymbolRepresents the values a cell can contain.
Each enum value maps to a display character for printing the board.
GameStatusDefines the possible states of the game. Tracks where we are in the game lifecycle.
Four distinct states cover all possible game outcomes. A game starts as IN_PROGRESS and transitions to exactly one terminal state when it ends.
Notice that all terminal states are one-way. There's no transition from DRAW back to IN_PROGRESS, and no transition from WINNER_X to WINNER_O. Once a game ends, it stays ended.
We use WINNER_X and WINNER_O instead of a generic WINNER with a separate winner field. This makes status checks simpler: if (status == GameStatus.WINNER_X) instead of if (status == GameStatus.WINNER && winner.getSymbol() == Symbol.X).
It also makes the enum self-contained. You can determine the winner from the status alone without needing additional context.
Data classes are simple containers that hold data with minimal behavior. They represent the "nouns" in our system that have attributes but little logic.
PlayerHolds player information.
The Player class is immutable. Once created, a player's name and symbol don't change. This prevents bugs where someone accidentally reassigns a player's symbol mid-game.
CellHolds the current value of a board position.
Unlike Player, Cell is mutable. It starts as EMPTY and gets set to X or O when a player makes a move.
The isEmpty() helper method makes calling code more readable:
if (cell.isEmpty()) is clearer than if (cell.getSymbol() == Symbol.EMPTY)Interfaces define contracts for interchangeable behavior.
WinningStrategyAfter each move, the Game needs to check whether someone won. We could write three separate checks (row, column, diagonal) inline, but that's rigid. If the interviewer says "now add a four-corners win condition," you'd have to modify Game.
WinningStrategy defines the contract for win detection algorithms.
The Game iterates through all strategies without knowing which specific checks exist. Adding a new win condition is just a matter of creating a new class that implements this interface and adding it to the list.
GameObserverWhen a game ends, other components might need to react. The Scoreboard records the result, but a logger might write to a file, or an analytics service might track game duration. We don't want Game to know about all of these.
GameObserver defines the contract for listening to game end events.
The Game notifies all observers when it ends. Observers decide what to do with the information. The Scoreboard extracts the winner and records the result. A future logger could extract the move count and game duration. Neither requires any changes to Game.
Core classes contain the actual game logic. They coordinate between data classes and implement the rules of the game.
BoardEncapsulates the 3x3 grid and handles all board-related operations including its state and the rules for checking win/draw conditions.
getCell(), which validates bounds first.GameThe orchestrator that brings all components together and manages gameplay.
Game ties everything together. It owns the Board, knows the Players, tracks whose turn it is, and uses WinningStrategies to detect wins. When the game ends, it notifies observers (like the Scoreboard).
ScoreboardTracks wins across multiple games.
The Scoreboard implements GameObserver so it can automatically update when games end.
TicTacToeSystem
This is the public-facing facade.
External code only interacts with TicTacToeSystem. It doesn't need to know about Board, Cell, or WinningStrategy.
How do these classes connect? There are three types of relationships we use.
Composition means one object owns another. When the owner is destroyed, the owned object is destroyed too.
Association means one object uses another, but doesn't own it. Both objects have independent lifecycles.
Implementation means a class fulfills an interface contract.
You might notice some structural patterns emerging in our design. Let's make them explicit and justify why each pattern is appropriate here.
The Problem: A player can win in three distinct ways: completing a row, a column, or a diagonal. If we hardcode all win conditions in a single method, we end up with a long, complex function that's hard to test and modify. Adding a new win condition (like "four corners" in a variant) would require changing existing code.
The Solution: The Strategy pattern encapsulates each win-checking algorithm in its own class. The Game holds a list of WinningStrategy implementations and iterates through them to check for a winner.
The Strategy pattern gives us:
We check all strategies on every move rather than optimizing for the last move position. This is simpler and more maintainable.
For a 3x3 board, the performance difference is negligible. If we were building for larger boards, we might optimize by only checking strategies relevant to the last move's position.
The Problem: When a game ends, the Scoreboard needs to update. The naive approach is to have the Game directly call scoreboard.recordWin(). But this couples the Game to the Scoreboard. What if we want to add analytics tracking? Or a replay recorder? Each new listener would require modifying the Game class.
The Solution: The Observer pattern decouples the Game (subject) from its listeners (observers). The Game maintains a list of observers and notifies them when the game ends.
Why Observer Pattern?
For a single Scoreboard, direct method calls would work fine. We use Observer because:
The Game only notifies observers when it transitions to a terminal state, not on every move. This keeps the observer interface simple and avoids unnecessary updates.
If we needed move-by-move notifications, we could add a separate onMove() method to the observer interface.
The Problem: We need a single, globally accessible entry point to the system that maintains a consistent scoreboard across multiple games.
The Solution: The Singleton pattern ensures only one instance of TicTacToeSystem exists. It provides a global access point via getInstance().
Singleton is often overused, but it's appropriate here because we genuinely need one scoreboard shared across all games.
Now that we've designed our classes and relationships, let's bring this to life with code.
Now let's translate our design into working code. We'll build bottom-up: foundational types first, then data classes, then the classes with real logic. This order matters because each layer depends on the ones below it.
We start with the two enums that other classes depend on.
SymbolEach Symbol maps to a display character. This keeps display logic centralized. If we later want to use 'x' instead of 'X', we change it in one place.
GameStatusFour possible states. The game starts IN_PROGRESS and ends in one of the three terminal states.
Before we write classes that can fail, let's define how they fail. A custom exception makes error handling cleaner than catching generic RuntimeException.
InvalidMoveExceptionWe'll throw this when someone tries to play on an occupied cell, make a move after the game ends, or specify an out-of-bounds position.
These are simple containers. They hold data with minimal logic.
PlayerNotice the constructor validation. A player with Symbol.EMPTY makes no sense, so we reject it immediately. This is "fail fast" design. If you create an invalid Player, you find out right away, not three hours later when debugging a weird game state.
Both fields are final. Once you create a Player, their name and symbol never change. Immutability prevents bugs.
CellUnlike Player, Cell is mutable. It starts empty and gets filled during gameplay. The isEmpty() helper makes calling code more readable: if (cell.isEmpty()) is clearer than if (cell.getSymbol() == Symbol.EMPTY).
Now we define the contracts that our strategy and observer classes will implement.
WinningStrategyThe interface takes the board, the position of the last move, and the symbol to check. Each implementation decides how to use these parameters. Row strategy only cares about row. Column strategy only cares about col. Diagonal strategy ignores both and checks the whole diagonal.
GameObserverSimple notification interface. When a game ends, observers get the Game object and can extract whatever information they need (winner, final board state, etc.).
Each strategy checks one way to win. Let's implement all three.
RowWinningStrategy checks if all cells in the row of the last move contain the same symbol.
We iterate through every column in the given row. If any cell doesn't match, return false immediately. No need to check further.
ColumnWinningStrategy works the same way, but iterates through rows instead of columns.
DiagonalWinningStrategy is more complex because there are two diagonals: main (top-left to bottom-right) and anti-diagonal (top-right to bottom-left).
The main diagonal has cells at positions (0,0), (1,1), (2,2). The anti-diagonal has cells at (0,2), (1,1), (2,0). Notice how size - 1 - i gives us the anti-diagonal column index.
Each strategy is independently testable. You can unit test RowWinningStrategy without creating a full Game. Just create a Board, set up a winning row, and verify the strategy returns true.
The Board encapsulates all grid operations. It doesn't know about players, turns, or game rules. It just manages a 2D array of cells.
A few things to note about the Board:
initializeBoard() method runs in the constructor, so you never have a Board with null cells.validatePosition() method is private and called by every public method that takes coordinates. This prevents code duplication.isFull() short-circuits: As soon as we find an empty cell, we return false. No need to scan the entire board.printBoard() is for debugging: In a real application, you'd probably have a separate view layer. But for interviews and testing, a simple print method is useful.This is where everything comes together. The Game coordinates players, board, strategies, and observers. It's the most complex class, but each method has a single responsibility.
Let's break down the key design decisions in the Game class:
Thread Safety: The makeMove method is synchronized. This prevents two threads from making moves simultaneously, which could corrupt the game state. The observer list uses CopyOnWriteArrayList, which allows safe iteration even if observers are added during notification.
The makeMove flow:
Strategy iteration: The checkWin method iterates through all strategies. As soon as one returns true, we have a winner. This is where the Strategy pattern pays off. Adding a new win condition just means adding another strategy to the list.
Observer notification: We only notify observers when the game ends (win or draw). This keeps the observer interface simple. If we needed move-by-move notifications, we could add a separate onMove() method to GameObserver.
The Scoreboard demonstrates the Observer pattern in action. It listens for game end events and automatically updates scores.
The Scoreboard is decoupled from the Game. It doesn't know when games start or how moves work. It just receives a notification when a game ends, extracts the winner, and updates its internal map.
Note the use of ConcurrentHashMap and the merge() method. The merge() call atomically gets the current value (or 0 if absent), adds 1, and stores the result. This is thread-safe without explicit synchronization.
The system class is the public entry point. External code only needs to know about this class. It hides the complexity of Game, Board, and Scoreboard behind a simple interface.
instance == null twice. The first check avoids the cost of synchronization when the instance already exists. The second check (inside the synchronized block) handles the race condition where two threads both pass the first check.volatile keyword: This ensures that when one thread creates the instance, other threads immediately see the fully constructed object. Without volatile, threads might see a partially constructed instance due to instruction reordering.resetInstance() for testing: Singletons are notoriously hard to test because the instance persists across tests. This method lets us reset the singleton between tests. In production, you'd probably remove this or make it package-private.The TicTacToeSystem class simplifies the API. Compare these two approaches:
Without facade:
With facade:
The facade handles object creation, wiring, and lifecycle. Callers don't need to know that games have observers or that scoreboards exist.
Let's see the system in action with a demo that plays three games.
The following diagram illustrates what happens when a player makes a move:
Does Tic-Tac-Toe actually need thread safety?
For a simple console application where Alice and Bob take turns typing, no. But consider a web-based version: two players in different browsers making HTTP requests to the same game server. Each request is handled by a separate thread, and both threads access the same Game object. Without synchronization, things can go wrong.
Setup: Alice (X) and Bob (O) are playing through a web interface. It's Alice's turn (currentPlayerIndex = 0). Alice clicks (0, 0) and Bob clicks (1, 1) at nearly the same time. Two HTTP request threads hit Game.makeMove() concurrently.
currentPlayerIndex = 0, confirms it's Alice's turncurrentPlayerIndex = 0, also sees it's Alice's turnisCellEmpty(0, 0) -> trueisCellEmpty(1, 1) -> truecurrentPlayerIndex is still 0)currentPlayerIndex to 1currentPlayerIndex to 0 (wraps back)Result: Both cells contain X. Bob's move was lost. The turn counter wrapped around, so Alice would go again. The board state is corrupted.
The synchronized keyword on makeMove() ensures Thread-A acquires the lock first. Thread-A completes the entire move atomically (place symbol, check win, switch player). Only then does Thread-B acquire the lock. Thread-B now reads the updated currentPlayerIndex = 1, confirms it's Bob's turn, and places O correctly.
One of the best ways to validate a design is to see how it handles change. If adding a feature requires modifying multiple classes, the design has problems. If you can add features by creating new classes without touching existing code, you've achieved the Open/Closed Principle.
Let's walk through five common extension requests and see how our design handles them.
Scenario: "Add a win condition where occupying all four corners wins the game."
This is where the Strategy pattern shines. We add a new strategy class without touching any existing code.
To enable it, add one line to initializeStrategies():
What stays unchanged: RowWinningStrategy, ColumnWinningStrategy, DiagonalWinningStrategy, Board, Cell, Game logic, Observer pattern.
Scenario: "Support 4x4 and 5x5 boards."
Our design already handles this. The Board takes a size parameter, and strategies use board.getSize() instead of hardcoding 3.
For larger boards, you might want a configurable win length (e.g., "5 in a row on a 10x10 board"). That would require updating the strategies:
What stays unchanged: Board, Cell, Observer pattern, Scoreboard.
Scenario: "Add a computer player that makes moves automatically."
We introduce a MoveStrategy interface for selecting moves. This is separate from WinningStrategy, which checks for wins.
A simple random strategy:
A smarter minimax strategy (simplified):
The Game class can check if the current player has a MoveStrategy and auto-play:
What stays unchanged: Board, Cell, WinningStrategy implementations, Observer pattern.
Scenario: "Track move history and allow undo."
The Command pattern is perfect here. Each move becomes a command that can be executed and reversed.
Update the Game class to use commands:
What stays unchanged: Board, Cell, WinningStrategy, existing Game methods.
Scenario: "Add analytics tracking and replay recording."
The Observer pattern already supports this. Just create new observer implementations.
Register multiple observers:
What stays unchanged: Game class, Scoreboard, Board, strategies. The Game doesn't know or care what observers are watching it.
Which entity is primarily responsible for managing the state of the Tic Tac Toe grid?