Problem 02 · Complete walkthrough•Beginner•30 min

Tic-Tac-Toe

Prefer to learn one decision at a time?

Try questions, reveal the reasoning, trace the code, and finish with the simulator.

Start Focus Mode

Design a two-player game that accepts legal moves, alternates turns, and stops with a win or draw.

Two players taking turns placing X and O marks on a three by three board
The whole game loop: one player chooses a cell, the board changes, the result is checked, and the next turn begins.

1. Requirements (~5 minutes)

The interview begins with one sentence:

“Design Tic-Tac-Toe.”

You probably know how Tic-Tac-Toe works. The danger is assuming that your version of the rules is the interviewer's version. The sentence does not tell us the board size, who starts, how invalid moves are reported, or whether features such as undo are required.

Before thinking about classes, turn the familiar game into a precise promise that our code can keep.

Use these four question groups every time

1
Actions

What must a user be able to do?

2
Rules

When does an action succeed, fail, or change state?

3
Errors

Which invalid actions must we reject?

4
Boundaries

What should we deliberately not build?

Ask these questions

AskHow many players are there, and how are their marks assigned?

Interviewer answer

There are two local players. One uses X and the other uses O. X starts.

AskIs the board always 3 by 3? What exactly counts as a win?

Interviewer answer

Use a fixed 3 by 3 board. Three equal marks in a row, column, or diagonal wins.

AskWhen is the game a draw, and can moves continue after the game ends?

Interviewer answer

It is a draw when all nine cells are filled without a winner. Reject every move after a win or draw.

AskWhich moves are invalid, and how should the caller learn why?

Interviewer answer

Reject a wrong player, a cell outside the board, an occupied cell, or a move after completion. Return a clear result.

AskDo we need a computer player, undo, score history, larger boards, or online play?

Interviewer answer

No. Discuss them only if asked as extensions.

Confirm the specification

Confirmed specification

6 requirements · 6 boundaries

The first version must

  1. 1.Two local players use X and O; X takes the first turn.
  2. 2.Players alternate placing one mark on an empty cell of a fixed 3 by 3 board.
  3. 3.A row, column, or diagonal containing three equal marks wins.
  4. 4.A full board without a winner is a draw.
  5. 5.Reject the wrong player, out-of-range cells, occupied cells, and moves after completion.
  6. 6.Expose the board, current player, status, winner, and a reset operation.
Not building in this version6 boundaries
  • Computer opponent
  • Undo or replay history
  • Score across matches
  • Variable board size
  • Online multiplayer
  • UI and storage

2. Entities and relationships (~3 minutes)

Now return to the confirmed requirements and look for candidate objects: game, player, board, cell, mark, and winning rule. We will not keep all of them as classes.

For each candidate, ask: what information changes here, and which rule needs that information? The class should be placed where the answer is clearest.

Final model

Types we will create

Class
Owns changing state or coordinates rules.
Record
A Java record for validated, immutable data with value equality.
Interface
Defines behavior that another implementation can replace.
GameClass

Coordinates turns, match status, winner, and reset.

BoardClass

Owns cells, placement checks, and winning lines.

PlayerRecord

Carries one player's validated name and fixed mark.

Enums

Enums name a small, fixed set of valid choices or outcomes.

Mark

Limits a cell to X, O, or empty.

GameStatus

Names whether the match is active, won, or drawn.

MoveResult

Explains why a move succeeded or was rejected.

Fields

Fields are simple values stored inside another type; they do not need independent behavior in this version.

cells

Stores the current Mark grid inside Board.

row

Identifies the selected horizontal position.

column

Identifies the selected vertical position.

See one move flow through the objects

Swipe to follow the flow →

Players send makeMove to Game, Game asks Board to place a mark, and Board returns whether the placement was accepted or rejected
Game protects the match flow; Board protects the cells. The return result lets Game update the turn only after a mark was accepted.

Read the arrows in order. A player sends makeMove to Game. Game first checks the current player and match status. It then asks Board to place the mark. Board checks the coordinate and cell, stores the mark only when valid, and returns accepted or rejected. Game changes the turn only after an accepted placement. That is why the UI talks to Game instead of editing Board directly.

3. Class design (10–15 minutes)

The entity step told us which objects we need. Class design turns each object into a clear promise: the state it protects, the behavior it owns, and the small public API other objects may use.

Use the same path for every class:

  1. Start with one responsibility from the requirements.
  2. Add only the state needed to perform that responsibility.
  3. Put behavior beside the state it reads or changes.
  4. Expose a public method only when another object or caller needs it.
  5. Check relationships and protect the rules that must never be bypassed.

State is information an object remembers. Behavior is a decision or change it can perform using that information. An invariant is a rule that must remain true—for example, a cell cannot contain two marks and turns cannot change after a rejected move.

One class at a time

Design the state owners

Start with the coordinator, then follow the request into the classes that protect changing state. Select a class to see only the fields and behavior needed for its responsibility.

Class 1 of 2

Game

Coordinates one match without duplicating Board's grid rules.

Requirement behind this class

Players alternate, moves stop after completion, the winner and status are readable, and reset starts a clean match.

State it remembers

Private information required to perform this class's job.

Board board

Composes the grid owner used by this match.

Player playerX, playerO

Keeps the two fixed participants.

Player currentPlayer

Remembers whose move is allowed.

Player winner

Records the winner when the match completes.

GameStatus status

Rejects moves after a win or draw.

Behavior it owns

Public use cases and internal rules kept beside that state.

makeMove(player, row, column)Public API

Validates match flow, delegates placement, and resolves the result.

reset()Public API

Clears Board and restores the initial turn and status.

getMark(...) and match readsPublic API

Expose required state without returning mutable Board.

switchPlayer() / finishMatch()Internal

Update turn, winner, and status only after an accepted move.

Invariant it protects
Only an in-progress match accepts a move, and rejected moves change no match state.
Works with
Two Player records and one Board.
Does not own
Cell storage, coordinate checks, placement, or winning-line calculations.
Principle demonstrated
Single responsibility and composition keep match flow separate from grid rules.

Supporting types

These types carry stable data or define replaceable behavior. They support the state owners without becoming another place to store live workflow state.

PlayerRecord

Carries a validated name and fixed Mark.

Its compact constructor rejects incomplete identity; generated accessors expose stable data with value equality.

Decide whether a pattern helps

Before choosing a pattern, ask what rule has multiple versions right now. The answer is none: there is one fixed board, one winning rule, and two human players. Direct composition is easier to explain and test.

Do not force: Strategy for the basic game

The requirements contain one fixed winning rule and two human players. A WinningStrategy or MoveStrategy interface would create extra code without any second implementation. Write the direct rule first.

Do not force: Singleton for Game

We may want several games in tests or in a future tournament. A normal object supports that naturally. Global singleton state would make this harder.

Do not force: Factory and Observer

Constructors are enough for one simple setup, and the confirmed scope has no UI subscribers. Add factories or observers only when creation paths or notifications actually vary.

Use: Strategy when several computer players are added

If an extension requires easy, medium, and hard computer players, those are several ways to choose a move. At that point a MoveStrategy interface has a real purpose. Do not add it before that requirement arrives.

Review the final class blueprint

The final model is deliberately small. Every field pays for a requirement, every rule sits beside the state it needs, and callers cannot mutate the active board without passing through Game.makeMove.

Use the tabs to inspect each class on its own. The arrows and field types show how the three small responsibilities combine without exposing Board's mutable grid.

Sketch UML diagram of the immutable Player class

How to read it: Player keeps a name and one fixed Mark together as stable identity.

Design principle: Immutability and cohesion prevent a participant's identity changing during a match.

4. Implementation (~10 minutes)

This is the complete Java version. The main method changes state only after validation succeeds.

What the code does

It creates two players, stores marks on a three-by-three board, accepts or rejects moves, alternates turns, and ends the match with a win or draw.

Why it is structured this way

Board owns cell and winning-line rules. Game owns the match workflow: whose turn it is, whether play may continue, and who won. Keeping these jobs separate makes invalid-state changes easier to prevent.

Follow one request through the code

  1. 1A caller sends Game.makeMove with a player, row, and column.
  2. 2Game checks match status and turn order before touching the board.
  3. 3Board validates the cell and places the mark only when it is empty.
  4. 4Game checks win, then draw, otherwise changes the current player.

Code concepts to understand

Validate first, change state secondA rejected move must leave the board and current player exactly as they were.

Game.makeMove checks completion and turn order first. Board.place then checks the coordinates and cell. Only after placement succeeds does Game calculate the result or switch players.

This order is important. If the turn changed before the board accepted the move, clicking an occupied cell would incorrectly give the other player a turn.

Board rules versus game workflowBoard answers questions about cells; Game controls the match.

Board knows whether a coordinate is valid, whether a cell is empty, whether a mark has a winning line, and whether the board is full. It does not know player names or whose turn it is.

Game knows the current player, status, and winner. It calls Board instead of reading or changing the cell array directly. This gives both classes one clear reason to change.

Enums instead of strings and booleansMark, GameStatus, and MoveResult give every meaningful state a clear name.

An enum prevents invalid values such as "FINSHED" and avoids combinations of booleans such as isWon=true and isDraw=true. MoveResult also tells the caller why a move failed without throwing an exception for an expected user action.

Why no Strategy pattern in the basic gameThere is only one fixed human move flow and one fixed winning rule.

A pattern should solve a current source of change. The basic requirements do not include bots, several board sizes, or selectable winning rules, so extra interfaces would make the first design harder to read.

If the interviewer adds computer players, introduce MoveStrategy. If they add different winning rules, introduce WinningRule. The extension creates the need for the pattern.

Enums

These enums define the complete vocabulary used by the rest of the program. They remove magic strings and make method results readable at the call site.

Mark.java
The two marks a player can own
public enum Mark {
    X,
    O
}
GameStatus.java
The complete state of a match
public enum GameStatus {
    IN_PROGRESS,
    X_WON,
    O_WON,
    DRAW
}
MoveResult.java
Why a requested move was accepted or rejected
public enum MoveResult {
    ACCEPTED,
    NOT_YOUR_TURN,
    OUT_OF_BOUNDS,
    CELL_OCCUPIED,
    GAME_ALREADY_OVER
}

Player and board

Player is stable identity data: a name and one fixed mark. Board owns the mutable cell array and all rules that can be answered by looking only at that array. Notice that callers cannot receive and modify the array directly.

Player.java
A player's identity and fixed mark
import java.util.Objects;

public record Player(String name, Mark mark) {
    public Player {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name is required");
        }
        mark = Objects.requireNonNull(mark);
    }

    @Override
    public String toString() {
        return name + " (" + mark + ")";
    }
}
Board.java
Owns cells, placement checks, and fixed winning rules
import java.util.Arrays;

public final class Board {
    public static final int SIZE = 3;
    private final Mark[][] cells = new Mark[SIZE][SIZE];

    public boolean place(int row, int column, Mark mark) {
        if (!isInside(row, column) || mark == null) return false;
        if (cells[row][column] != null) return false;
        cells[row][column] = mark;
        return true;
    }

    public boolean isInside(int row, int column) {
        return row >= 0 && row < SIZE && column >= 0 && column < SIZE;
    }

    public boolean hasWinningLine(Mark mark) {
        for (int index = 0; index < SIZE; index++) {
            if (cells[index][0] == mark
                    && cells[index][1] == mark
                    && cells[index][2] == mark) {
                return true;
            }
            if (cells[0][index] == mark
                    && cells[1][index] == mark
                    && cells[2][index] == mark) {
                return true;
            }
        }

        boolean mainDiagonal = cells[0][0] == mark
                && cells[1][1] == mark
                && cells[2][2] == mark;
        boolean otherDiagonal = cells[0][2] == mark
                && cells[1][1] == mark
                && cells[2][0] == mark;

        return mainDiagonal || otherDiagonal;
    }

    public boolean isFull() {
        for (Mark[] row : cells) {
            for (Mark cell : row) {
                if (cell == null) return false;
            }
        }
        return true;
    }

    public Mark getMark(int row, int column) {
        if (!isInside(row, column)) {
            throw new IndexOutOfBoundsException("Cell is outside the board");
        }
        return cells[row][column];
    }

    public void clear() {
        for (Mark[] row : cells) {
            Arrays.fill(row, null);
        }
    }

    @Override
    public String toString() {
        StringBuilder output = new StringBuilder();
        for (int row = 0; row < SIZE; row++) {
            for (int column = 0; column < SIZE; column++) {
                Mark mark = cells[row][column];
                output.append(mark == null ? "." : mark);
                if (column < SIZE - 1) output.append(" ");
            }
            output.append(System.lineSeparator());
        }
        return output.toString();
    }
}

Game controller

Game.makeMove is the central workflow and should be read from top to bottom: reject illegal game state, reject the wrong player, validate the cell, place the mark, calculate the result, and switch the turn only if play continues. TicTacToeDemo proves how the public API is meant to be used.

Game.java
Controls turns, status, and the move workflow
import java.util.Objects;

public final class Game {
    private final Board board;
    private final Player playerX;
    private final Player playerO;
    private Player currentPlayer;
    private Player winner;
    private GameStatus status;

    public Game(Player first, Player second) {
        Objects.requireNonNull(first);
        Objects.requireNonNull(second);
        if (first.mark() == second.mark()) {
            throw new IllegalArgumentException("Players need different marks");
        }

        this.playerX = first.mark() == Mark.X ? first : second;
        this.playerO = first.mark() == Mark.O ? first : second;
        this.board = new Board();
        reset();
    }

    public MoveResult makeMove(Player player, int row, int column) {
        if (status != GameStatus.IN_PROGRESS) {
            return MoveResult.GAME_ALREADY_OVER;
        }
        if (player != currentPlayer) {
            return MoveResult.NOT_YOUR_TURN;
        }
        if (!board.isInside(row, column)) {
            return MoveResult.OUT_OF_BOUNDS;
        }
        if (!board.place(row, column, player.mark())) {
            return MoveResult.CELL_OCCUPIED;
        }

        if (board.hasWinningLine(player.mark())) {
            winner = player;
            status = player.mark() == Mark.X
                    ? GameStatus.X_WON
                    : GameStatus.O_WON;
        } else if (board.isFull()) {
            status = GameStatus.DRAW;
        } else {
            currentPlayer = currentPlayer == playerX ? playerO : playerX;
        }

        return MoveResult.ACCEPTED;
    }

    public void reset() {
        board.clear();
        currentPlayer = playerX;
        winner = null;
        status = GameStatus.IN_PROGRESS;
    }

    public Mark getMark(int row, int column) {
        return board.getMark(row, column);
    }

    public Player getCurrentPlayer() {
        return currentPlayer;
    }

    public Player getWinner() {
        return winner;
    }

    public GameStatus getStatus() {
        return status;
    }
}
TicTacToeDemo.java
Runs a complete winning game
public final class TicTacToeDemo {
    public static void main(String[] args) {
        Player alice = new Player("Alice", Mark.X);
        Player bob = new Player("Bob", Mark.O);
        Game game = new Game(alice, bob);

        play(game, alice, 0, 0);
        play(game, bob,   1, 0);
        play(game, alice, 0, 1);
        play(game, bob,   1, 1);
        play(game, alice, 0, 2);

        printBoard(game);
        System.out.println("Status: " + game.getStatus());
        System.out.println("Winner: " + game.getWinner());
    }

    private static void play(Game game, Player player, int row, int column) {
        MoveResult result = game.makeMove(player, row, column);
        System.out.printf("%s -> (%d,%d): %s%n",
                player.name(), row, column, result);
    }

    private static void printBoard(Game game) {
        for (int row = 0; row < Board.SIZE; row++) {
            for (int column = 0; column < Board.SIZE; column++) {
                Mark mark = game.getMark(row, column);
                System.out.print((mark == null ? "." : mark)
                        + (column == Board.SIZE - 1 ? "" : " "));
            }
            System.out.println();
        }
    }
}

Verify the design with a real scenario

  1. 1Initial: the board is empty, status is IN_PROGRESS, and Alice (X) is current.
  2. 2Alice plays (0,0). Board accepts X and Game changes current player to Bob.
  3. 3Alice tries another move immediately. Game returns NOT_YOUR_TURN and the board does not change.
  4. 4Bob plays (1,0), then turns continue until Alice completes row 0.
  5. 5After Alice plays (0,2), Board finds X X X, Game sets X_WON, stores Alice as winner, and does not switch turns.
  6. 6Any later move returns GAME_ALREADY_OVER and the winning board stays unchanged.

Try the flow below. Click the same cell twice to confirm that a rejected move does not switch the player.

Try it yourself

Play and inspect each move

Move 0X's turn

Choose an empty cell. Try clicking an occupied cell too.

Live object state

Game.status

IN_PROGRESS

Game.current

Player X

Board.moves

0 / 9

What happened

  1. Game created. Player X owns the first turn.
1UI sends intent
2Game checks status
3Board validates cell
4Rule evaluates result

5. Extensions (~5 minutes)

“Add computer players with several difficulty levels”

Now introduce a MoveStrategy interface with chooseMove(Board). Easy, medium, and hard bots implement different selection rules. Human players still receive moves from input, so do not put minimax logic inside Board or Game.

“Support an N by N board and K marks in a row”

Pass size into Board and move win checking behind a WinningRule interface. The fixed eight-line implementation can then be replaced without changing the turn workflow in Game.

“Add undo”

Store each accepted move in a stack. undo() removes the most recent mark, restores the previous player, and changes status back to IN_PROGRESS. Rejected moves never enter the history.

“Play over a network”

Keep this domain model. Add a service around Game that identifies the remote player, serializes commands, and ensures that two moves cannot update one game at the same time.