AlgoMaster Logo

Design Tic Tac Toe Game

High Priorityeasy20 min readUpdated June 27, 2026
Listen to this chapter
Unlock Audio

In this chapter, we will explore the low-level design of a tic tac toe game in detail.

Lets start by clarifying the requirements:

1. Clarifying 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:

After gathering the details, we can summarize the key system requirements.

Functional Requirements
  • The game is played on a 3x3 grid.
  • Two players take alternate turns, identified by markers ‘X’ and ‘O’.
  • The game should detect and announce the winner.
  • The game should declare a draw if all cells are filled and no player has won.
  • The game should reject invalid moves and inform the player.
  • Moves can be hardcoded in a driver/demo class to simulate gameplay.
Non-Functional Requirements
  • The design should follow object-oriented principles with clear responsibilities and separation of concerns.
  • The system should be modular and extensible to support future features like larger boards, AI opponent, move history, etc.
  • The game logic should be testable and easy to maintain.
  • The system should provide clear console output that reflects the current state of the game board.

After the requirements are clear, the next step is to identify the core entities that will form the foundation of our design.

2. Identifying Core Entities

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.

1. The game is played on a 3x3 grid.

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.

2. Two players take alternate turns, identified by markers ‘X’ and ‘O’.

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 XO, and EMPTY gives us type safety. The compiler will catch invalid symbols at compile time rather than runtime.

3. The game processes moves and determines game outcomes.

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_PROGRESSWINNER_XWINNER_O, and DRAW captures all possibilities cleanly.

Entity Overview

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, and Game orchestrates gameplay and win detection.

Scroll
EntityTypeResponsibility
SymbolEnumCell values: X, O, or EMPTY
GameStatusEnumGame state: IN_PROGRESS, WINNER_X, WINNER_O, DRAW
CellData ClassHolds a single symbol
PlayerData ClassHolds player name and assigned symbol
BoardCore ClassManages the 3x3 grid
GameCore ClassOrchestrates gameplay and win detection

With our entities identified, let's define their attributes, behaviors, and relationships.

3. Designing Classes 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.

3.1 Class Definitions

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

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.

Symbol

Represents the values a cell can contain.

Scroll
ValueDisplay CharacterPurpose
X'X'First player's marker
O'O'Second player's marker
EMPTY'_'Unoccupied cell

Each enum value maps to a display character for printing the board.

GameStatus

Defines the possible states of the game. Tracks where we are in the game lifecycle.

Scroll
ValueDescriptionTerminal?
IN_PROGRESSGame is still being playedNo
WINNER_XPlayer with X symbol wonYes
WINNER_OPlayer with O symbol wonYes
DRAWBoard is full, no winnerYes

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.

Data Classes

Data classes are simple containers that hold data with minimal behavior. They represent the "nouns" in our system that have attributes but little logic.

Player

Holds player information.

Scroll
AttributeTypeDescription
nameStringPlayer identifier (e.g., "Alice")
symbolSymbolThe marker assigned to the player (X or O)
MethodDescription
Player(name, symbol)Constructor with validation (rejects EMPTY symbol)

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.

Cell

Holds the current value of a board position.

Scroll
AttributeTypeDescription
symbolSymbolCurrent value: X, O, or EMPTY
MethodDescription
Cell()Constructor, initializes symbol to EMPTY
isEmpty()Returns true if symbol is EMPTY

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)

Core Classes

Core classes contain the actual game logic. They coordinate between data classes and implement the rules of the game.

Board

Encapsulates the 3x3 grid and handles all board-related operations including its state and the rules for checking win/draw conditions.

Scroll
AttributeTypeDescription
gridCell[][]2D array of cells
sizeintBoard dimension (3 for standard game)
MethodDescription
Board(size)Constructor, creates size×size grid of empty cells
placeSymbol(row, col, symbol)Places a symbol at the given position
isCellEmpty(row, col)Returns true if the cell is available
isFull()Returns true if no empty cells remain
printBoard()Displays the current board state to console

Game

The orchestrator that brings all components together and manages gameplay.

Scroll
AttributeTypeDescription
boardBoardThe game board
playersPlayer[]The two players
currentPlayerIndexintWhose turn it is (0 or 1)
statusGameStatusCurrent game state
MethodDescription
Game(p1, p2, boardSize)Constructor, initializes all components
makeMove(row, col)Core method: validate, place, check win/draw, switch turn

Game ties everything together. It owns the Board, knows the Players, tracks whose turn it is, and checks for a winner after each move.

3.2 Full Class Diagram

Now that we've designed our classes and relationships, let's bring this to life with code.

Try It Yourself (Exercise)

Before reading the full implementation, try building this yourself. Below are template stubs for the classes from the design. The enums, data classes, and the demo are already written for you. The job is to fill in the core logic where the TODO comments appear.

Your task: Implement every TODO using the design from the previous sections. When you are done, run the demo. A correct implementation produces the expected output.

Loading editor...

4. Code Implementation

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.

4.1 Enums

We start with the two enums that other classes depend on.

Symbol

Each 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.

GameStatus

Four possible states. The game starts IN_PROGRESS and ends in one of the three terminal states.

4.2 Custom Exception

Before we write classes that can fail, let's define how they fail. A custom exception makes error handling cleaner than catching generic RuntimeException.

InvalidMoveException

We'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.

4.3 Data Classes

These are simple containers. They hold data with minimal logic.

Player

Notice 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.

Cell

Unlike 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).

4.4 Board Class

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:

  • Constructor creates all cells: The initializeBoard() method runs in the constructor, so you never have a Board with null cells.
  • Validation is centralized: The 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.

4.5 Game Class

This is where everything comes together. The Game coordinates the players and the board. 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 makeMove flow:

  1. Check if game is over (fail fast)
  2. Validate the cell is empty
  3. Place the symbol
  4. Check for a win after placing the symbol
  5. Check for draw if no winner
  6. Switch to next player if game continues

Win check: The checkWin method checks the row and column of the last move, then the two diagonals when the move lies on them. The moment one of those lines is completely filled with the current player's symbol, we have a winner.

Move Sequence Diagram

The following diagram illustrates what happens when a player makes a move:

makeMove(row, col)isCellEmpty(row, col)trueplaceSymbol(row, col, symbol)checkWin(row, col, symbol)true (winner found)status = WINNER_XUserGameBoardUserGameBoard
7 / 7
algomaster.io

5. Run and Test

Loading editor...

6. Concurrency and Thread Safety

A console game where Alice and Bob take turns typing runs on a single thread, so thread safety does not matter. A web version is different: two players in separate browsers send requests to the same server, each request runs on its own thread, and both threads share one Game object. Now concurrent calls to makeMove() can corrupt the game.

The Race Condition

Suppose it is Alice's turn (currentPlayerIndex = 0). Note that makeMove(row, col) takes only a cell, not a player: it stamps the move with players[currentPlayerIndex] and trusts that whoever calls is the current player. The web server simply turns each click into a makeMove call, it does not pass along who clicked.

Now Alice clicks (0, 0) and Bob clicks (1, 1) at nearly the same moment, so two threads enter makeMove together. Both read currentPlayerIndex = 0 before either advances it, so both moves are stamped with Alice's symbol. Bob's cell (1, 1) gets an X instead of an O, and the two threads then race to advance the turn counter. The board is corrupt because the read-place-advance steps of the two moves interleaved.

The two threads interleave like this:

both reads return 0, so both movesare stamped as players[0] = Alice (X)Bob's cell gets X instead of Otwo moves played, counter advanced onceread currentPlayerIndex (= 0)read currentPlayerIndex (= 0)place X at (0,0)place X at (1,1)currentPlayerIndex -> 1currentPlayerIndex -> 1Thread A (Alice's click)Game (shared state)Thread B (Bob's click)Thread A (Alice's click)Game (shared state)Thread B (Bob's click)
9 / 9
algomaster.io

The Fix

Locking makeMove() makes each move atomic. The first thread acquires the lock and runs the whole sequence, place the symbol, check for a win, switch turns, before releasing it. The second thread waits, then reads the updated state and plays correctly. Since validation, placement, and the status update all happen under one lock, a move either completes fully or does not run at all.

7. Extensions

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 three common extension requests and see how our design handles them.

7.1 Variable Board Size

Scenario: "Support 4x4 and 5x5 boards."

Our design already handles this. The Board takes a size parameter, and the win check uses board.getSize() instead of hardcoding 3.

What stays unchanged: Everything. The same Game class works for any board size.

7.2 AI Opponent

Scenario: "Add a computer player that makes moves automatically."

We introduce a MoveStrategy interface for selecting moves. It controls how a player picks a move, separate from how the Game checks for a win.

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, the win check.

7.3 Scoreboard

Scenario: "Track how many games each player has won across a session."

A single Game plays one match and is then finished. To keep a running tally across many games without coupling the Game to any scoring logic, we use the Observer pattern. The Game lets components register as listeners and notifies them when a match ends. A Scoreboard listens and records the winner.

First, define the observer contract and a Scoreboard that implements it:

Then give the Game a way to register observers and notify them when a match reaches a terminal state:

Because the Game only knows about the GameObserver contract, you can add other listeners later, such as a logger or an analytics tracker, the same way and without touching the Game again.

What stays unchanged: Board, Cell, and the core Game logic. The scoreboard plugs in through the observer hook.

8. Quiz

Design Tic Tac Toe Quiz

21 quizzes