LLD-005 · DESIGN PRACTICE
Tic-Tac-Toe Engine
Build a small complete engine, then defend configurable rules, atomic moves and historical replay.
Local Java 21 reference verified: 10975 checks. Learner drafts are not executed. Author self-review; independent review pending.
Existing introductory lesson · Version 1
Understand the problem
Design a two-player board-game engine with N×N cells and K consecutive marks needed to win. Implement legal turns, occupied-cell validation, terminal outcomes and deterministic replay. Show how the same simple model can expose Senior-level concurrency and Staff-level compatibility decisions without inventing an unnecessary game framework.
Before you start
- Encapsulation and immutable snapshots
- Matrix indexing and directional scans
- Optimistic version checks
- Command logs and deterministic replay
Scope and explicit decisions
- Two fixed marks X/O; X always starts and turns alternate. Board size N is 1..100 and target K is 1..N. Four undirected win axes: horizontal, vertical and both diagonals.
- A win requires at least K consecutive matching marks. Marks elsewhere in the same row do not count toward a disconnected run. A full board without a win is a draw.
- Each call supplies player, row, column and expectedVersion equal to accepted move count. Wrong player, stale version, out-of-bounds, occupied cell or terminal game rejects without mutation.
- Duplicate move requests are rejected via stale expectedVersion, not automatically answered from a deduplication ledger. A caller recovering from timeout must read snapshot/history; the API does not promise idempotent success responses.
- Snapshot returns immutable row strings, status, next mark, version and rule version. next indicates the next scheduled mark; it is actionable only while status=PLAYING.
- History contains accepted commands only. Replay validates every command through the same play method and requires an exact declared rule-version match. A version label is not a full rule registry: production must ensure a label maps to immutable rule semantics.
- No minimax/AI, networking, matchmaking, ranking, undo, draw-by-agreement or persistence is implemented. Authorization that a caller controls X/O is an adapter responsibility, not proved by passing an enum.
- The engine owns one board under one monitor. Independent games can be separate instances. Board snapshots allocate O(N²) data, so even a fast win detector does not make the public play operation O(K).
Worked examples
Input: Classic 3×3: X(0,0), O(1,0), X(0,1), O(1,1), X(0,2).
Outcome: XXX / OO. / ...; X_WON, version 5. A later O move is rejected.
The last move closes a row. Terminal state is immutable even though empty cells remain.
Input: N=4, K=3: X at (2,0),(2,2), then (2,1), with valid O turns between.
Outcome: The first two X cells do not win. Filling the gap produces a consecutive three-cell run.
Row counts do not capture adjacency when K<N.
Your design attempt
Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.
Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.
Run program · Java 21
No unsaved changes · 208/20000
Self-review
Checks apply to this visit. Self-review is not a passed submission or verified mastery.
Try before revealing
Hint 1
What must remain true before and after every accepted move?
Hint 2
If there was no winner before, which cell must a new winning line pass through?
Hint 3
Store only accepted commands, and make replay obey exactly the live transition contract.
Requirements and invariants
R1 — Only the expected player and current version can claim an empty cell. play protects I1.
R2 — Derive win/draw after the accepted move and reject all later moves. wins protects I2.
R3 — External snapshots and histories cannot mutate the authoritative board. snapshot protects I3.
R4 — Replay retains the declared rule identity and validates every transition. replay protects I4.
I1: Accepted move count equals occupied cell count; turns alternate from X and each accepted move advances version exactly once.
I2: Terminal status is stable. PLAYING has no winning run and at least one free cell; win is tested before draw after a move.
I3: Only Game.play mutates board/history; returned snapshots and history lists are immutable copies.
I4: Replaying accepted commands under the same immutable rules produces the same board, status and version.
Derive the design
Scan the whole board first, then exploit the last move
A correct baseline scans every potential K-length line after each move and declares a win if any line is complete. For N×N and four axes a straightforward implementation can inspect O(N²K) cells. The key improvement is that before a legal move there was no winner; therefore any new winning run must pass through the cell just placed. Inspect four axes around that cell rather than rescan unrelated lines.
Alternative: For classic K=N games, row/column/diagonal counters can detect completed lines in O(1), but they do not encode consecutive runs when K<N. The reference uses the more general last-move scan. The test oracle independently scans all rows/columns/diagonals for classic 3×3, avoiding reuse of the production detector.
Freeze the rules and rejection policy
Decide board limits, K semantics, first player, terminal behavior and duplicate request behavior. The reference rejects stale versions instead of guessing whether a repeated command is a duplicate or a changed user intention.
Separate input validation from state mutation
Validate expected version, turn, lifecycle, coordinates and occupancy before changing board/history. This gives a simple guarantee: every rejected operation leaves the game unchanged.
Derive win detection from the previous valid state
No earlier winner exists because the engine stops at terminal state. Any new win must pass through the newly marked cell. Four undirected axes cover all permitted lines; counting both directions handles a new mark joining two existing segments.
Treat history as accepted commands, not all requests
Rejected requests do not become legal moves or increment the version. Replay invokes the same public guards, so it cannot bypass turn order or terminal rules just because the caller supplied a log.
Explain total public-operation cost
The local win detector is O(K), but play returns an O(N²) snapshot. Distinguish algorithmic subroutine cost from the actual API contract. If scaling matters, change the return type to a move delta and offer snapshots separately.
Responsibilities and ownership
Rules
Owns: Version, board size and target run length
Methods: Validated immutable constructor
No arbitrary callbacks or mutable global rules during a game.
Game
Owns: Board, next player, status and accepted history
Methods: play, snapshot, history, replay
All mutation and observed snapshots share one monitor.
Move
Owns: Player, coordinates and expected prior version
Methods: Immutable record
A requested move becomes history only after validation.
Snapshot
Owns: Copied row strings and scalar outcome metadata
Methods: Immutable record accessors
Never exposes char[][] or the mutable internal history.
A caller-supplied Mark does not authenticate a player. Network authorization belongs to a trusted adapter.
- Player intent + expected version
↓ Check version, turn, bounds and empty cell
Game monitor validates↓ Place mark and evaluate terminal state
Board + accepted history↓ Return copied rows and current outcome
Immutable snapshot
Win is evaluated before draw. Any terminal result rejects future moves without changing board/history.
At least K consecutive marks
Otherwise inspect free-cell count
Data model, schema and relationships
A game owns immutable rules, players and ordered accepted moves. Board cells are a projection of those moves; a rejected move never advances the version.
Implementation boundary: Game/Rules/Move correspond to the Java reference. Persistent player identities and replay tables are optional; the board itself is private local state.
These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Entity definitions and field contracts
rules
Immutable rule interpretation.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | text · PK | Rule version |
size | integer · VALUE | Board dimension |
win_length | integer · VALUE | Required consecutive run |
Code mapping: Rules record.
- Do not reuse a version for changed rules.
game
Aggregate state and version.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Game identity |
rules_id | text · FK | Pinned rules · rules.id |
version | bigint · VALUE | Accepted move count |
status | enum · VALUE | Active win or draw |
Code mapping: Game object.
- Version equals accepted history length; no post-terminal moves.
player
Participant identity.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Player identity |
name | text · VALUE | Display label |
Code mapping: X/O caller identities; persistent account extension.
- Authentication/turn authorization is adapter work.
move
One accepted versioned command.
| Field | Type / key | Meaning and reference |
|---|---|---|
game_id | uuid · PK,FK | Game owner · game.id |
number | integer · PK | Accepted move position |
player_id | uuid · FK | Actor · player.id |
row | integer · VALUE | Row coordinate |
column | integer · VALUE | Column coordinate |
Code mapping: Move history.
- PK(game_id,number); unique(game_id,row,column); validate turn and contiguous version.
Relationship cardinalities and their reasons
| Parent → child | Multiplicity | FK / why |
|---|---|---|
| rules → game governs | 1 parent per child; 0..* children per parent | game.rules_id → rules.idMany games may pin the same immutable configuration. |
| game → move accepts | 1 parent per child; 0..* children per parent | move.game_id → game.idAccepted command order reconstructs the board. |
| player → move plays | 1 parent per child; 0..* children per parent | move.player_id → player.idA player participates in many games; authorization must enforce seat/mark ownership. |
Transaction boundaries
- play: compare expected version, validate cell/turn/status and append move plus new version/status atomically.
- Replay loads pinned rules and ordered moves; reject malformed command sequence before trusting a cached board.
Indexes and uniqueness
- PK(game_id,number) supports ordered replay; unique cell key prevents duplicate placements.
- Index game(status,updated_at) for active game lists; it is not needed by the local detector.
Schema traps and failure cases
- A rule version string without immutable registry content cannot prove replay compatibility.
- Persisting board and history independently can produce impossible replay divergence.
Trace the records through one operation
- Game G pins N=3,K=3 rules.
- Move number 1 claims an empty cell at expected version 0.
- A concurrent version-0 command loses; replay number order reproduces the accepted board.
Method contracts
new Game(Rules(version,N,K))
Validate 1<=K<=N<=100. Initialize every cell empty, X to play, PLAYING status and version zero.
play(player,row,column,expectedVersion) → Snapshot
Validate version, lifecycle, turn, bounds and occupancy before mutation. Place mark, append command, evaluate win then draw, advance turn and return immutable state.
snapshot() → Snapshot; history() → immutable move list
Serialize reads with mutation. Returned values are safe to retain; future moves cannot alter prior snapshots.
Game.replay(rules, recordedRuleVersion, moves) → Game
Reject mismatched version before construction. Reapply a copied command list through public play so illegal coordinates, stale order and moves after termination are rejected.
What, when, why and how
Each concept is attached to the requirement it protects, its implementation, an alternative and a test.
Encapsulation and immutable snapshots
- what
- Expose observations without exposing the mutable game representation.
- when
- UI or callers retain state while further moves occur.
- why
- Returning char[][] would let callers overwrite cells without turn, version or terminal checks.
- how
- Game owns private board/history. snapshot constructs row Strings and Snapshot copies its list. history returns List.copyOf; Move and Rules are immutable records.
Code: snapshot, history, Snapshot · Invariants: I1, I3
Alternatives, cost and failure example
Alternative: A mutable DTO can be used for transport only if converted/copied at the ownership boundary.
Cost: Creating a board snapshot costs O(N²) time and space; repeated rendering can dominate the O(K) win scan.
Counterexample: Caller stores a board array from turn one and later writes an O into an empty cell.
Test: Verify rejected moves leave snapshots equal and exposed lists reject mutation; replay reads an immutable history.
Last-move locality and correctness invariant
- what
- A newly completed win must include the latest accepted move.
- when
- The game rejects post-win moves and the board was valid before this move.
- why
- Checking unrelated rows repeats work; checking only a row total is incorrect for configurable K.
- how
- For each of four axes, count contiguous same-mark cells in both directions up to K-1, add one for the new cell and compare with K. Check win before full-board draw.
Code: wins, count · Invariants: I2
Alternatives, cost and failure example
Alternative: Classic fixed K=N counters are smaller/faster but cannot represent arbitrary disconnected segments for K<N.
Cost: Detection is O(K) across a constant number of directions, excluding snapshot creation and history cost.
Counterexample: On a 4×4 board with K=3, X at columns 0 and 2 is not a run; filling column 1 creates the win.
Test: Test a gapped row and completion in its middle; compare every reachable classic board with a separate full-board evaluator.
Optimistic command version plus atomic mutation
- what
- A request states which board version it intends to modify, and validation/mutation occur indivisibly.
- when
- Two clients act on the same snapshot or retry after a timeout.
- why
- Checking the turn alone does not detect all stale intent, and separate validation/write can accept competing moves.
- how
- play is synchronized; expectedVersion must equal history.size. All validation happens before board assignment. One contender commits and advances the version; another with the old version is rejected.
Code: play, history · Invariants: I1
Alternatives, cost and failure example
Alternative: A single-writer game event loop is another owner. Durable compare-and-set is required for multiple server processes sharing one game.
Cost: Stale retries require explicit recovery; a version guard prevents duplicate effects but does not return the original successful response.
Counterexample: Two X clients at version zero place different cells concurrently without a common atomic boundary.
Test: Start two workers together; exactly one accepted move and version one must remain.
Command log and rule-version compatibility
- what
- Record accepted intent and replay it under a defined immutable rule set.
- when
- You need deterministic history, debugging, migration or future spectator state.
- why
- A board snapshot alone loses causal history; a log without rule identity may be reinterpreted by new behavior.
- how
- Move stores coordinates, player and expected version. replay requires recordedRuleVersion==rules.version, then invokes play for every move. The current reference relies on callers not reusing a version string for changed semantics.
Code: Move, replay, Rules · Invariants: I4
Alternatives, cost and failure example
Alternative: Store both snapshots and a compact tail for large games; include a schema/rules fingerprint if version labels are not centrally governed.
Cost: Replay calls play, which snapshots each step: O(M×N²) plus move detection for M commands. It is simple, not optimized event sourcing.
Counterexample: Change K from 3 to 4 while keeping the same version label and replay old games as if semantics were unchanged.
Test: Same-version replay equals final snapshot; mismatched label and malformed transition order must fail.
Read and run the Java reference
Reveal complete Solution.java
Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.
play validates everything before writing
Version and terminal checks occur before bounds and occupancy; error ordering is documented by implementation, while the meaningful contract is no mutation on any rejection. The board and history change within one monitor.
wins examines axes, count walks rays
Each axis is counted in both directions from the new cell. count stops at boundary, different mark or K-1 steps, so it never skips a gap. At least K permits longer runs without special handling.
snapshot copies the representation
Each mutable char row becomes a new String. Snapshot copies the list. Keeping old snapshots cannot reveal later mutation, and callers cannot modify the authoritative board through a reference.
replay validates rather than trusting logs
It rejects mismatched version first and feeds each stored command through play. A forged command with wrong expectedVersion, player or terminal ordering fails exactly like a live request.
Complexity and limits
- Win detection scans at most four pairs of K-1 rays: O(K) time and O(1) extra detection space.
- play returns a full snapshot, so total time and returned space are O(N²+K), normally O(N²) because K<=N. Accepted history append is amortized O(1).
- Game storage is O(N²+M), with M<=N² accepted moves. history() copies O(M) records by reference; records themselves are immutable.
- Replay of M accepted moves currently calls play M times and therefore costs O(M×N²+M×K), with transient snapshots. An internal validated delta path could avoid this cost but needs equivalence tests.
- The exhaustive test traverses 5,478 reachable classic board states and compares an independent evaluator. It validates classic rules extensively; it is not an exhaustive proof for all N<=100/K variants.
Walk through the actual reference
These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.
A legal win and rejected late move
Step 1 of 3: X(0,0), O(1,0), X(0,1), O(1,1)
XX. / OO. / ...; version 4
Invariant: Turns alternate; each cell claimed once
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| X(0,0), O(1,0), X(0,1), O(1,1) | XX. / OO. / ...; version 4 | Turns alternate; each cell claimed once |
| X(0,2) at expectedVersion 4 | XXX / OO. / ...; X_WON; version 5 | Terminal result follows last legal move |
| O attempts (2,2) | Rejected; board and version unchanged | No post-terminal mutation |
Replay identity and rule version
Step 1 of 2: Replay the five validated moves with classic-v1
Same board, status and version
Invariant: Deterministic history reconstruction
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Replay the five validated moves with classic-v1 | Same board, status and version | Deterministic history reconstruction |
| Replay with recorded version classic-v2 | Rejected before applying moves | Never reinterpret history under unknown rules |
Mistakes and counterexamples
Use row totals for every K-in-a-row variant
Failing case: Three marks are scattered across a larger row with gaps.
Repair: Require consecutive runs; use a last-move directional scan for configurable K.
Check draw before win
Failing case: The final empty cell completes a winning line.
Repair: Evaluate win first; only a full nonwinning board is DRAW.
Return the internal board array
Failing case: UI code writes a mark without a validated turn.
Repair: Return copied immutable row strings or an immutable view backed by safe snapshots.
Record invalid requests in accepted history
Failing case: An occupied-cell attempt increments version and breaks replay turn order.
Repair: Append only after all command validation succeeds.
Reuse a rule version for different semantics
Failing case: classic-v1 changes from K=3 to K=4 while old history keeps the label.
Repair: Govern versions as immutable definitions; use a registry/fingerprint in a durable system.
Engineer → Senior → Staff
Complete a small deterministic engine.
Executed: configurable N/K, turn checks, wins, draws, bounds and terminal rejection.
Change exercise
Explain why the last move only needs four axes, then test a win formed by filling the middle of a gap.
Worked answer guidance
Every legal win is horizontal, vertical or diagonal and must pass through the new cell. Count contiguous marks in both directions; disconnected row totals cannot prove a run.
Interview follow-ups
Why test win before draw?
The final board-filling move may also win. DRAW requires no winner, so winning status takes precedence.
What does next mean after termination?
It is the next scheduled mark after the last move, but no action is allowed. Consumers must inspect status, not infer legality solely from next.
Provenance and scope of evidence
Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.
Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.