Small enough to finish · easy to ruin
Tic-tac-toe
Interviewers love this because it fits in one session. The trap is modeling the grid as the product: printing cells, reading stdin, and subclassing XPlayer / OPlayer until the domain is untestable.
The real design is a small game loop. A owns cells and legality. Game owns whose turn it is and delegates play to . Win and draw detection sit behind a swappable so N-in-a-row does not rewrite Game. Keep printing and Scanner (Java stdin) out of the domain so tests call play(Cell) directly.
Not the design
Printing this grid is a toString. The design is play(Cell), reject illegal moves, and ask a WinStrategy.
Place, bounds, filled-cell rejection, and isFull live here, not in Game.
Three-in-a-row today; swap to N-in-a-row tomorrow without editing Game.play.
Bots pick a Cell; humans live in a UI adapter. Scanner stays outside.
InProgress accepts; Won and Draw reject so a finished game stays finished.
What actually happens in the modeling journey
Do not model X and O as subclasses of Player with different move() if the only difference is a symbol. A player has a . A bot has a . Human input is a different strategy, or it lives outside the domain in a UI adapter.
After each legal move, Game asks the win strategy and the draw check, then transitions. setStatus(WON) from the outside is how a finished game accepts another mark. Commands: play(cell). Queries: winner(), isOver(). That is commands vs queries on a tiny API.
The follow-up is usually N-in-a-row or an AI. That is why win detection is a strategy, not a nest of for-loops inside Game.play(). A 3×3 hardcoded scan cannot survive N. Print and Scanner are not the design. If you need a demo, a thin main loop calls play() and prints Board.toString(). Tests never touch stdin.
How to keep play(Cell) as the product
The hero trap is treating the printed grid as the design. Three questions tell you whether the domain is still testable before you talk about bots or N-in-a-row.
1. Can tests call play(Cell) with no stdin? If Game reads Scanner, you cannot prove a win without faking the console. Keep IO in main.
2. Is Mark a field, not a subclass tree? XPlayer / OPlayer only differ by symbol. Use a ; bots get a .
3. Does a finished game reject play? Use a for InProgress / Won / Draw when those states reject play differently. A status enum plus guards is enough if you are honest about it; see state vs status. The point is: a finished game must not accept another mark.
Simple rule of thumb
owns occupancy. Game owns turn and . owns three-in-a-row. Scanner lives in main. Tests never touch stdin. If you need a public setStatus to force a win in a test, the seam is already wrong.
From problem to mechanism
The rule of thumb above is the grade bar. Here is the same story slowed down: where the naive model fails, and what Board, Game, and the strategies change.
Problem. Alternate X and O on a grid, reject illegal moves, detect win or draw, and stop accepting marks when the match ends.
Naive system. Hardcode a 3×3 scan inside Game.play, subclass XPlayer / OPlayer, and read moves from Scanner in the domain.
Break it. N-in-a-row rewrites Game. Tests cannot win without faking stdin. A finished game still accepts another mark via setStatus.
Mechanism. Game.play delegates to . InProgress places on the , asks , then becomes Won, becomes Draw, or flips the turn. The diagram below is that match lifecycle.
State of the match
Use the State pattern () when those states reject play differently. The diagram is the mechanism above, drawn as transitions.
Read the diagram definition
stateDiagram-v2
[*] --> InProgress
InProgress --> Won: WinStrategy finds a mark
InProgress --> Draw: board full, no winner
Won --> [*]
Draw --> [*]
note right of Won: play(cell) rejected
note right of Draw: play(cell) rejectedResponsibility walk
Those types in the sketch each own one job. If Game starts checking occupancy or scanning lines, the seam is already leaking.
| Type | Responsibility |
|---|---|
| Board | Owns cells, bounds checks, "filled cell cannot be played," and isFull. |
| Game | Owns whose turn it is, delegates play to GameState, and flips the mark. |
| WinStrategy | Three-in-a-row today; can become N-in-a-row tomorrow without editing Game. |
| MoveStrategy | Player can be bot or human. Game asks for a Cell; it does not import Scanner. |
| GameState | InProgress accepts moves; Won/Draw reject them. Tests must not call a public setStatus. |
Java API sketch
The same types as the table, written as Java. InProgress places, asks WinStrategy, then becomes Won, becomes Draw, or flips the turn.
enum Mark { X, O }
final class Cell {
final int row;
final int col;
}
interface WinStrategy {
Optional<Mark> winner(Board board);
}
interface MoveStrategy {
Cell choose(Board board, Mark mark);
}
class Board {
void place(Cell cell, Mark mark) { /* reject if filled or out of bounds */ }
boolean isFull();
}
class Game {
private GameState state;
private final Board board;
private final WinStrategy wins;
private Mark turn = Mark.X;
void play(Cell cell) {
state.play(this, cell);
}
}
class InProgress implements GameState {
public void play(Game game, Cell cell) {
game.board().place(cell, game.turn());
Optional<Mark> w = game.wins().winner(game.board());
if (w.isPresent()) game.becomeWon(w.get());
else if (game.board().isFull()) game.becomeDraw();
else game.flipTurn();
}
}Three plays, then N, and a forty-five minute plan
The sketch above is the API. This is the grade path: five moves, assert a win, reject the sixth, then swap WinStrategy for N without editing Game.play.
play(0,0) X, play(1,1) O, play(0,1) X, play(2,2) O, play(0,2) X. Winner X via . Sixth play throws. Occupied cell throws. Tests call play(Cell), never Scanner. Swap WinStrategy to N-in-a-row on a 4×4 board without editing Game.play. A bot is .choose then play. XPlayer subclasses never appear.
Tests that prove the seams
@Test
void threeInARowWinsThenRejects() {
Game g = new Game(new ThreeInARow());
g.play(new Cell(0,0)); g.play(new Cell(1,1));
g.play(new Cell(0,1)); g.play(new Cell(2,2));
g.play(new Cell(0,2));
assertEquals(Mark.X, g.winner().orElseThrow());
assertThrows(GameOver.class, () -> g.play(new Cell(2,0)));
}Forty-five minutes
- 0–8Play five moves so X wins, then assert the sixth play throws.
- 8–20Reject occupied cells and prove turn flip places the other mark.
- 20–32Name the WinStrategy seam so N-in-a-row does not rewrite Game.
- 32–40Keep Scanner out of the domain; tests call play(Cell) only.
- 40–45If time remains, add a bot as MoveStrategy.choose then play.
Invalid cases, concurrency, and what the follow-up changes
The walkthrough assumes a clean seam. These are the shapes that destroy it, the in-process concurrency note that still applies, and the follow-ups that stay behind the same strategies.
Invalid cases
Play on an occupied cell
Board.place throws or returns a domain error so Game never overwrites.
Play out of bounds
Validate the cell at the board edge; do not try/catch an array dump.
Play after Won or Draw
GameState rejects the move so a finished game stays finished.
Same mark twice in a row
Turn flip is Game's job; InProgress must not place O when turn is X.
XPlayer / OPlayer subclasses
The only difference is a mark; use a Mark field and give bots MoveStrategy.
Concurrency
Two clicks on the same empty cell from a UI thread and a bot thread: serialize play() on Game. Occupancy is the board invariant. This is not a distributed match server.
What the follow-up changes
- N×N, K-in-a-row. Size the board in the constructor; scans k while Game.play stays unchanged.
- Undo. Closer to chess Command; for tic-tac-toe a history stack of cells is enough if they ask.
- IO demo. Thin main loop only; tests call play(new Cell(0,0)) and assert winner.
- Bot. RandomMove, then Minimax, via without rewriting Game.play.
Wrap-up
You have the seam, the sketch, and the cases that break it. play(Cell) is the product; Scanner is not. Related notes below are the patterns this page used; the takeaway is the grade bar.
Read next
You're done when
Moves validate empty cells and turn order, win/draw detection is correct for all lines, and the game rejects moves after a terminal state. A 4×4 board should not require rewriting the core loop if sizes are parameterized. Undo is a cell history if they ask; print is toString. The exercise is small enough to finish if you do not drown it in IO.