What this prompt is really asking
Tic Tac Toe is a two-player game on a 3×3 grid. Players alternate placing X and O on empty cells. First to get three of their mark in a row — horizontal, vertical, or diagonal — wins. A full board with no winner is a draw.
You’ll hear: “Design Tic Tac Toe.” Interviewers are checking clean ownership between Game and Board, clear rejection of illegal moves, and whether you stop at a simple win table instead of inventing Strategy hierarchies for a nine-cell board.
01Clarify
Actions · errors · scope
02Model
Game · Board · Player
03Code
place + 8 lines
04Extend
N×N · undo · bot
Clarifying questions → locked requirements
Structure clarifying questions around core actions, errors, boundaries, and extensions:
- You: Players pick a cell (row, col) and place their mark? They: Yes — only empty cells.
- You: Win only, or draws too? They: Three in a row wins; full board = draw.
- You: Occupied cell / wrong turn / move after game over? They: Reject; don’t corrupt state.
- You: UI or backend only? One game? They: Backend logic only; one game is fine.
Entities: Game, Board, Player
Pull nouns that own rules. The board owns placement and “three in a row.” The game owns turns and terminal state. A player is identity + mark — no AI inside Player.
- Game — Board + two Players,
current_player,state(IN_PROGRESS / WON / DRAW), optionalwinner. Entry:make_move(player, row, col). - Board — 3×3 of marks / empty.
place,check_win,is_full,is_empty. No idea whose turn it is. - Player — name + mark (X / O). Pure data.
- Mark — enum, not a class hierarchy. Two values; don’t invent MarkStrategy.
Class design from the requirements
Derive state top-down. Game needs players, turn, board, outcome. Board needs the grid. Player needs name and mark.
from enum import Enum, auto
from typing import Optional
class Mark(Enum):
X = auto()
O = auto()
class GameState(Enum):
IN_PROGRESS = auto()
WON = auto()
DRAW = auto()
class Player:
def __init__(self, name: str, mark: Mark):
self.name, self.mark = name, mark
class Board:
SIZE = 3
# flat index 0..8 or grid[r][c] — pick one and stay consistent
WIN_LINES = (
(0, 1, 2), (3, 4, 5), (6, 7, 8), # rows
(0, 3, 6), (1, 4, 7), (2, 5, 8), # cols
(0, 4, 8), (2, 4, 6), # diags
)
def __init__(self):
self.cells: list[Optional[Mark]] = [None] * (self.SIZE * self.SIZE)
# place / check_win / is_full — see implementation
class Game:
def __init__(self, p1: Player, p2: Player):
self.board = Board()
self.p1, self.p2 = p1, p2
self.current = p1 # X starts
self.state = GameState.IN_PROGRESS
self.winner: Optional[Player] = None
def make_move(self, player: Player, row: int, col: int) -> bool: ...
def get_current_player(self) -> Player: ...
def get_game_state(self) -> GameState: ...
def get_winner(self) -> Optional[Player]: ...
import java.util.*;
enum Mark { X, O }
enum GameState { IN_PROGRESS, WON, DRAW }
class Player {
String name;
Mark mark;
Player(String name, Mark mark) { this.name = name; this.mark = mark; }
}
class Board {
static final int SIZE = 3;
// flat index 0..8 or grid[r][c] — pick one and stay consistent
static final int[][] WIN_LINES = {
{0, 1, 2}, {3, 4, 5}, {6, 7, 8}, // rows
{0, 3, 6}, {1, 4, 7}, {2, 5, 8}, // cols
{0, 4, 8}, {2, 4, 6}, // diags
};
Mark[] cells = new Mark[SIZE * SIZE]; // null = empty
// place / checkWin / isFull — see implementation
}
class Game {
Board board = new Board();
Player p1, p2, current;
GameState state = GameState.IN_PROGRESS;
Player winner; // nullable
Game(Player p1, Player p2) {
this.p1 = p1; this.p2 = p2;
this.current = p1; // X starts
}
boolean makeMove(Player player, int row, int col) { /* ... */ return false; }
Player getCurrentPlayer() { return current; }
GameState getGameState() { return state; }
Player getWinner() { return winner; }
}
Implementation highlights
Interviewers usually want
make_move, place, and check_win in real code.def make_move(self, player: Player, row: int, col: int) -> bool:
if self.state is not GameState.IN_PROGRESS:
return False
if player is not self.current:
return False
if not self.board.place(row, col, player.mark):
return False
if self.board.check_win(player.mark):
self.state = GameState.WON
self.winner = player
elif self.board.is_full():
self.state = GameState.DRAW
else:
self.current = self.p2 if player is self.p1 else self.p1
return True
def place(self, row: int, col: int, mark: Mark) -> bool:
if not (0 <= row < self.SIZE and 0 <= col < self.SIZE):
return False
i = row * self.SIZE + col
if self.cells[i] is not None:
return False
self.cells[i] = mark
return True
def check_win(self, mark: Mark) -> bool:
for a, b, c in self.WIN_LINES:
if self.cells[a] is mark and self.cells[b] is mark and self.cells[c] is mark:
return True
return False
def is_full(self) -> bool:
return all(c is not None for c in self.cells)
boolean makeMove(Player player, int row, int col) {
if (state != GameState.IN_PROGRESS) return false;
if (player != current) return false;
if (!board.place(row, col, player.mark)) return false;
if (board.checkWin(player.mark)) {
state = GameState.WON;
winner = player;
} else if (board.isFull()) {
state = GameState.DRAW;
} else {
current = (player == p1) ? p2 : p1;
}
return true;
}
boolean place(int row, int col, Mark mark) {
if (!(0 <= row && row < SIZE && 0 <= col && col < SIZE)) return false;
int i = row * SIZE + col;
if (cells[i] != null) return false;
cells[i] = mark;
return true;
}
boolean checkWin(Mark mark) {
for (int[] line : WIN_LINES) {
if (cells[line[0]] == mark && cells[line[1]] == mark && cells[line[2]] == mark) {
return true;
}
}
return false;
}
boolean isFull() {
for (Mark c : cells) if (c == null) return false;
return true;
}
Verification trace
Trace a short diagonal win out loud:
- Empty board; current = P1 (X).
- P1 → (0,0); P2 → (0,1); P1 → (1,1); P2 → (0,2).
- P1 → (2,2) completes (0,0)/(1,1)/(2,2) → state=WON, winner=P1.
- P2 tries (1,0) → rejected because state ≠ IN_PROGRESS.
- Also narrate: occupied (0,0) rejected earlier; wrong-turn attempt rejected.
You don’t need a full whiteboard transcript. Narrate place, line scan, turn flip, and post-game rejection.
- Happy: X places (0,0),(1,1),(2,2) with O elsewhere → diagonal win.
- Failure: Move on occupied cell rejected; move after WIN rejected.
- Concurrency: Two clients click — Game lock around make_move preserves alternating turns.
Extensibility follow-ups
- N×N / K-in-a-row —
Board(n, k). For N=3,K=3 keep the line table; for larger N, scan from the last move along H/V/diag like Connect Four instead of enumerating every line. - Undo / history — choke point is
Game.make_move. Push(player, row, col); undo clears the cell, restores turn, recomputes state (or store prior state on the stack). - Bot opponent — keep Player as data;
BotEngine.choose_move(game) -> (row, col)(random empty cell, or minimax — the state space is tiny). Game loop passes the choice intomake_move. - Best-of-N match — Match object owns score + creates a fresh Game per round; don’t stuff rematch into Board.
Common interview pitfalls
These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.
- Checking win but not draw — full board labeled wrongly.
- Allowing overwrite of occupied cells.
- Not validating turn order (X moves twice).
- Hardcoding only rows — forgetting columns/diagonals (or vice versa).
- Giant if-else for eight lines without a lines list — brittle extensions.
- Restart that doesn’t clear board + status + turn.
Interview script (say this)
Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.
- 3×3, X and O, alternate, eight win lines, draw if full with no winner.
- Game owns Board and current player; make_move(r,c).
- Reject occupied / out of bounds / game over.
- After move: check win lines through cell; else draw; else swap.
- Trace: X wins on diagonal; draw sequence; illegal second move on cell.
Extra verification traces
Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.
WIN_LINES = [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)]
def make_move(self, idx):
if self.status != "PLAY" or self.board[idx] is not None:
raise RuntimeError("illegal")
self.board[idx] = self.turn
if any(all(self.board[i]==self.turn for i in line) for line in WIN_LINES):
self.status = "WIN"; return
if all(c is not None for c in self.board):
self.status = "DRAW"; return
self.turn = "O" if self.turn == "X" else "X"
static final int[][] WIN_LINES = {
{0,1,2},{3,4,5},{6,7,8},{0,3,6},{1,4,7},{2,5,8},{0,4,8},{2,4,6}
};
void makeMove(int idx) {
if (!"PLAY".equals(status) || board[idx] != null) {
throw new RuntimeException("illegal");
}
board[idx] = turn;
for (int[] line : WIN_LINES) {
if (board[line[0]] == turn && board[line[1]] == turn && board[line[2]] == turn) {
status = "WIN";
return;
}
}
boolean full = true;
for (Object c : board) if (c == null) { full = false; break; }
if (full) { status = "DRAW"; return; }
turn = "X".equals(turn) ? "O" : "X";
}
Staff-level follow-ups
At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.
- NxN / K-in-a-row? — Generalize board size + win scanner.
- Undo? — Stack of moves; pop restores cell and turn.
- Online ratings? — MatchResult emitter; Game stays pure rules.
- Misère variant? — OutcomePolicy plug-in.
Complete solution notes (Tic Tac Toe)
Tic Tac Toe is usually a single-threaded rules engine in LLD. If the prompt adds a multiplayer HTTP API, wrap each game id with a lock so two moves can’t interleave on the same board.
from threading import Lock
class GameService:
def __init__(self):
self.games: dict[str, object] = {}
self._locks: dict[str, Lock] = {}
self._map_lock = Lock()
def _lock_for(self, game_id: str) -> Lock:
with self._map_lock:
return self._locks.setdefault(game_id, Lock())
def make_move(self, game_id: str, *args):
with self._lock_for(game_id):
return self.games[game_id].make_move(*args)
# Different game ids → different locks → parallel matches.
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
class GameService {
Map<String, Object> games = new HashMap<>();
Map<String, ReentrantLock> locks = new HashMap<>();
private final ReentrantLock mapLock = new ReentrantLock();
ReentrantLock lockFor(String gameId) {
mapLock.lock();
try {
return locks.computeIfAbsent(gameId, k -> new ReentrantLock());
} finally {
mapLock.unlock();
}
}
Object makeMove(String gameId, Object... args) {
ReentrantLock lock = lockFor(gameId);
lock.lock();
try {
// return games.get(gameId).makeMove(...);
return null;
} finally {
lock.unlock();
}
}
}
// Different game ids → different locks → parallel matches.
Concurrency cases
- Two HTTP moves on one game — without per-game lock, turn checks race.
- Spectators reading board — copy board under lock or accept briefly stale reads.
- Matchmaking — separate lock from game rules lock.