LLD for Tic-Tac-Toe: Designing an Extensible OOP Game
A classic interview problem. We design an extensible Tic-Tac-Toe game that supports N*N grids, multiple players, and different winning strategies.
Abstract Algorithms
TLDR: Tic-Tac-Toe looks trivial โ until the interviewer says "make it NรN with P players and pluggable winning rules." The key design decisions: a Board abstracted from piece identity, a Strategy Pattern for win conditions, and a Factory for player creation. Getting these right is the interview objective.
๐ The Basics: Object-Oriented Design for Games
A common interview mistake is designing Tic-Tac-Toe as a 3ร3 hardcoded grid. The interviewer immediately asks: "What if I want a 10ร10 board? Or 4-in-a-row instead of 3?" The design that handles this is what we're building.
Low-level design interviews test whether you can decompose a system into clean, extensible objects. The naive solution to Tic-Tac-Toe is always obvious โ a fixed 3ร3 array and two hardcoded players. The interview value lies entirely in the extensions.
OOP principles that matter for this design:
| Principle | How it applies |
| Single Responsibility | Board manages grid state; Game manages turns; WinningStrategy manages win logic |
| Open/Closed | New win strategies extend without modifying Game |
| Dependency Inversion | Game depends on WinningStrategy interface, not a concrete implementation |
| Encapsulation | Board hides its internal grid; clients use placePiece() and getPiece() |
The interview trap: Interviewers will extend the requirements immediately. Expect:
- "Now support NรN boards"
- "Add a third player"
- "Support different winning conditions โ like needing 4 in a row"
- "How would you add an undo feature?"
Each of these requirements is handled cleanly by a different design decision. Understanding why each design choice was made helps you answer extension questions confidently.
๐ From 3ร3 Toy to OOP Interview Test
Every LLD interview question hides the same trap: the naive solution is obvious, so the interviewer immediately extends it. Tic-Tac-Toe is no different.
Naive version: a fixed 3ร3 grid, two hardcoded players, if-else win check. Works in 30 lines.
Extended version: NรN board, P players with distinct symbols, swappable winning strategies (row, column, diagonal, custom), and clean state transitions. Requires deliberate OOP.
This post builds the extensible version step by step.
๐ข Core Entities โ Board, Piece, Player, Game
A clean OOP model needs exactly four entities:
classDiagram
class PlayingPiece {
+PieceType type
}
class Player {
+String name
+PlayingPiece piece
}
class Board {
+int size
+PlayingPiece[][] grid
+placePiece(row, col, piece)
+isFull()
}
class Game {
+Board board
+Deque players
+WinningStrategy strategy
+startGame()
+checkWinner(row, col, piece)
}
Player --> PlayingPiece
Game --> Board
Game --> Player
Game --> WinningStrategy
Enums and value objects first:
enum PieceType { X, O, STAR, HASH } // extensible โ add symbols without changing Board
class PlayingPiece {
final PieceType type;
PlayingPiece(PieceType t) { this.type = t; }
}
class Player {
final String name;
final PlayingPiece piece;
Player(String name, PlayingPiece piece) {
this.name = name;
this.piece = piece;
}
}
โ๏ธ Extending to NรN: Board with Variable Size
The Board must not hardcode 3. Size is a constructor parameter; the grid is a 2D array of nullable PlayingPiece.
class Board {
final int size;
private final PlayingPiece[][] grid;
Board(int size) {
this.size = size;
this.grid = new PlayingPiece[size][size];
}
public boolean placePiece(int row, int col, PlayingPiece piece) {
if (row < 0 || row >= size || col < 0 || col >= size) return false;
if (grid[row][col] != null) return false; // cell occupied
grid[row][col] = piece;
return true;
}
public PlayingPiece getPiece(int row, int col) { return grid[row][col]; }
public boolean isFull() {
for (PlayingPiece[] row : grid)
for (PlayingPiece p : row)
if (p == null) return false;
return true;
}
}
Multiple player support โ use a Deque for round-robin turns, regardless of player count:
Deque<Player> players = new LinkedList<>();
players.add(new Player("Alice", new PlayingPiece(PieceType.X)));
players.add(new Player("Bob", new PlayingPiece(PieceType.O)));
players.add(new Player("Carol", new PlayingPiece(PieceType.STAR)));
Each turn: poll() from front, play, then offerLast() to back.
๐งฑ OOP Pillars Applied in the Game Design
Each of the four OOP pillars maps directly onto a specific class or interface in this design โ not as a textbook exercise, but as a concrete decision that makes the code extensible.
classDiagram
direction LR
class Player {
<>
+Piece piece
+makeMove(Board) Move*
}
class HumanPlayer {
+makeMove(Board) Move
}
class AiPlayer {
+makeMove(Board) Move
}
class WinStrategy {
<>
+checkWin(Board, Piece) bool
}
class RowColWinStrategy
class DiagonalWinStrategy
HumanPlayer --|> Player : extends
AiPlayer --|> Player : extends
RowColWinStrategy ..|> WinStrategy : implements
DiagonalWinStrategy ..|> WinStrategy : implements
Game --> Player : uses
Game --> WinStrategy : depends on
| Pillar | Class / Interface | How it applies |
| Encapsulation | Board | grid[][] is private; external code calls placePiece() and getPiece() only. isValidMove() stays internal โ callers never inspect the raw array. |
| Abstraction | WinStrategy interface | Game calls strategy.checkWin(board, piece) โ the scan logic (rows, columns, diagonals) is hidden behind the interface. |
| Inheritance | HumanPlayer, AiPlayer extend Player | Both share the same makeMove(Board) contract. Swap HumanPlayer for AiPlayer without touching Game. |
| Polymorphism | Deque<Player> players | players.peekFirst().makeMove(board) dispatches correctly to human or AI โ zero instanceof checks needed. |
Encapsulation in practice: Board's constructor allocates grid as a private field. The only public surface is placePiece(row, col, piece), getPiece(row, col), and isFull(). If the internal representation changes from a 2D array to a Map<Position, Piece>, no external class breaks โ callers never touch grid[row][col] directly.
Polymorphism in practice: the turn loop calls current.makeMove(board) on whichever Player is at the front of the deque. Whether that player reads from stdin, calls a REST endpoint, or runs a minimax tree search is completely invisible to Game.
โ SOLID Principles in the Tic-Tac-Toe Design
Every SOLID principle surfaces in a specific, traceable design decision here โ not as abstract advice:
| Principle | Class(es) involved | Design decision |
| SRP โ Single Responsibility | Board, WinChecker, Game | Board manages cell state; WinChecker detects wins; Game orchestrates the turn loop. Three classes, three focused responsibilities โ none bleeds into another's domain. |
| OCP โ Open/Closed | WinStrategy interface | A new win rule (diagonal-only, "4-in-a-row") = a new class implementing WinStrategy. Zero changes to Game. |
| LSP โ Liskov Substitution | HumanPlayer, AiPlayer | Both honour Player.makeMove(Board) โ fully substitutable in Deque<Player> without causing unexpected behaviour. |
| ISP โ Interface Segregation | WinStrategy, MoveValidator | WinStrategy is a single-method interface. MoveValidator is separate. AiPlayer can implement MoveValidator to pre-screen candidate moves without being forced to also implement win detection. |
| DIP โ Dependency Inversion | Game โ WinStrategy | Game depends on the WinStrategy abstraction injected at construction โ not on any concrete checker class. Swap strategies at the call site; Game never changes. |
OCP in practice: the product team wants a "diagonal-only" win rule for a new game variant. You write DiagonalOnlyWinStrategy implements WinStrategy. Existing Game, Board, and RowColWinStrategy are untouched. The open/closed boundary is the WinStrategy interface.
DIP in practice:
// Game depends on the abstraction โ never on a concrete class
class Game {
private final WinStrategy strategy;
Game(Board board, List<Player> players, WinStrategy strategy) {
this.strategy = strategy; // injected โ any WinStrategy works here
}
}
// The call site decides which concrete strategy is used โ Game never knows
Game standard = new Game(board, players, new RowColWinStrategy());
Game variant = new Game(board, players, new DiagonalOnlyWinStrategy());
๐ Interface Contracts and the Extension Model
Three interfaces define the extension seams of the design. Each is narrow by design โ a single responsibility, a single method:
// Win condition โ injectable; Game calls this, never implements it
interface WinStrategy {
boolean checkWin(Board board, Piece piece);
}
// Move validation โ composable into Board or Game
interface MoveValidator {
boolean isValid(Board board, int row, int col);
}
// Player contract โ template method pattern; subclasses override the decision hook
abstract class Player {
final Piece piece;
Player(Piece piece) { this.piece = piece; }
abstract Move makeMove(Board board); // subclasses provide the decision algorithm
}
WinStrategy is the Strategy Pattern's policy interface. Game is the Context; RowColWinStrategy and DiagonalOnlyWinStrategy are ConcreteStrategy objects. Passing a different implementation at construction time changes the win rules โ no other class is aware of the swap.
MoveValidator is intentionally separate from WinStrategy (ISP). An AI player that screens all candidate moves before picking the best one needs MoveValidator, but has no reason to implement win detection. Merging the two interfaces would force a no-op checkWin on any class that only validates moves.
Player as a Template Method: final Piece piece is set once at construction โ no subclass can change which symbol a player owns. The abstract makeMove(Board) hook is the only variation point: HumanPlayer reads input; AiPlayer runs minimax.
Adding an AI player in pure OOP terms โ no changes to Game needed:
// MinimaxPlayer extends Player and overrides only makeMove()
Player ai = new MinimaxPlayer(Piece.O, /* depth= */ 5);
// Drop into the existing Deque โ polymorphism dispatches correctly
Deque<Player> players = new LinkedList<>();
players.add(new HumanPlayer("Alice", Piece.X));
players.add(ai);
// Game loop is identical โ current.makeMove(board) works for both
MinimaxPlayer.makeMove(Board board) internally scores all legal moves, builds the minimax tree to the given depth, and returns the optimal Move. The Game class calls current.makeMove(board) โ the same single-line dispatch it uses for a human player.
๐ Complete Tic-Tac-Toe Class Diagram
classDiagram
class Game {
-Board board
-Deque~Player~ players
-WinStrategy strategy
+startGame(Scanner) String
+checkWinner(Board, int, int, PlayingPiece) boolean
}
class Board {
+int size
-PlayingPiece[][] grid
+placePiece(int, int, PlayingPiece) boolean
+getPiece(int, int) PlayingPiece
+isFull() boolean
}
class Player {
<>
+String name
+PlayingPiece piece
+makeMove(Board) Move*
}
class HumanPlayer {
+makeMove(Board) Move
}
class AiPlayer {
+makeMove(Board) Move
}
class PlayingPiece {
+PieceType type
}
class PieceType {
<>
X
O
STAR
HASH
}
class WinStrategy {
<>
+checkWin(Board, PlayingPiece) boolean
}
class MoveValidator {
<>
+isValid(Board, int, int) boolean
}
class RowColWinStrategy {
+checkWin(Board, PlayingPiece) boolean
}
class DiagonalWinStrategy {
+checkWin(Board, PlayingPiece) boolean
}
Game --> Board : owns
Game --> Player : manages via Deque
Game --> WinStrategy : uses (injected)
Player --> PlayingPiece : has
PlayingPiece --> PieceType : uses
HumanPlayer --|> Player : extends
AiPlayer --|> Player : extends
RowColWinStrategy ..|> WinStrategy : implements
DiagonalWinStrategy ..|> WinStrategy : implements
Game --> MoveValidator : optional
๐ง Deep Dive: Why the Deque Is the Right Structure for Turn Management
A fixed two-player array breaks the moment a third player is added. A Deque<Player> implements round-robin rotation with two operations: pollFirst() takes the current player and offerLast() returns them after their turn. This scales to N players without index arithmetic. The same pattern applies to scheduler queues, connection pools, and multiplayer server loops โ recognizing it is a transferable skill beyond this problem.
๐ Deque-Based Turn Rotation
sequenceDiagram
participant G as Game
participant Q as Deque~Player~
participant B as Board
participant W as WinStrategy
G->>Q: pollFirst() โ currentPlayer
G->>B: placePiece(row, col, piece)
B-->>G: true (valid move)
G->>W: checkWin(board, piece)
W-->>G: false (game continues)
G->>B: isFull()
B-->>G: false
G->>Q: offerLast(currentPlayer)
Note over Q: Player rejoins queue tail โ round-robin continues
๐ฏ Win Detection: O(N) Check Without Scanning the Whole Board
Naively rechecking every cell after each move is wasteful. A smarter pattern: only check the row, column, and (if applicable) diagonals that the most recent move touched.
public boolean checkWinner(Board board, int row, int col, PlayingPiece piece) {
int N = board.size;
PieceType t = piece.type;
// Check row
boolean rowWin = true;
for (int c = 0; c < N; c++) {
PlayingPiece p = board.getPiece(row, c);
if (p == null || p.type != t) { rowWin = false; break; }
}
if (rowWin) return true;
// Check column
boolean colWin = true;
for (int r = 0; r < N; r++) {
PlayingPiece p = board.getPiece(r, col);
if (p == null || p.type != t) { colWin = false; break; }
}
if (colWin) return true;
// Check main diagonal (only if on it)
if (row == col) {
boolean diagWin = true;
for (int i = 0; i < N; i++) {
PlayingPiece p = board.getPiece(i, i);
if (p == null || p.type != t) { diagWin = false; break; }
}
if (diagWin) return true;
}
// Check anti-diagonal
if (row + col == N - 1) {
boolean antiWin = true;
for (int i = 0; i < N; i++) {
PlayingPiece p = board.getPiece(i, N - 1 - i);
if (p == null || p.type != t) { antiWin = false; break; }
}
if (antiWin) return true;
}
return false;
}
Complexity: O(N) per move (not O(Nยฒ)).
๐ Game Turn: Player Move Sequence
sequenceDiagram
participant G as Game
participant P as Player
participant B as Board
participant W as WinStrategy
G->>G: pollFirst() โ dequeue current player
G->>P: prompt for row, col
P-->>G: row=1, col=2
G->>B: placePiece(1, 2, piece)
B-->>G: true (cell was empty)
G->>W: checkWin(board, piece)
W->>W: scan row, col, diagonals O(N)
W-->>G: false (no winner yet)
G->>B: isFull()
B-->>G: false
G->>G: offerLast(player) โ requeue
Note over G: next iteration โ next player's turn
๐ Game Loop: Turns, Validation, and Termination
class Game {
private final Board board;
private final Deque<Player> players;
Game(int boardSize, List<Player> playerList) {
this.board = new Board(boardSize);
this.players = new LinkedList<>(playerList);
}
public String startGame(Scanner scanner) {
while (true) {
Player current = players.pollFirst();
System.out.println(current.name + "'s turn (piece: " + current.piece.type + ")");
int row, col;
do {
System.out.print("Enter row and col: ");
row = scanner.nextInt();
col = scanner.nextInt();
} while (!board.placePiece(row, col, current.piece));
if (checkWinner(board, row, col, current.piece)) {
return current.name + " wins!";
}
if (board.isFull()) {
return "Draw!";
}
players.offerLast(current); // send to back of queue
}
}
}
โ๏ธ Trade-offs & Failure Modes: Trade-offs and Design Decisions
| Decision | Simple approach | Extensible approach | Why it matters |
| Board size | Hardcode 3 | Constructor parameter N | Interview extension requirement |
| Player count | Two hardcoded players | Deque<Player> with round-robin | Scales to P players |
| Win check scope | Scan entire board | Check row/col/diag of last move | O(N) vs O(Nยฒ) |
| Win rules | if-else for rows/cols/diags | WinningStrategy interface | Add custom strategies without changing Game |
| Piece types | Char X/O | PieceType enum + PlayingPiece value object | Type safety and extensibility |
Strategy Pattern for win conditions:
interface WinningStrategy {
boolean hasWinner(Board board, int lastRow, int lastCol, PlayingPiece piece);
}
class OrderWinningStrategy implements WinningStrategy {
// standard row/col/diag check
}
class CustomWinningStrategy implements WinningStrategy {
// any arbitrary win condition
}
Passing WinningStrategy into Game via constructor injection means adding a new win rule is a new class โ zero changes to Game.
๐งญ Decision Guide: When to Apply This OOP Pattern
| Situation | Recommendation |
| Any NรN turn-based board game | Use this skeleton: Board, Piece, Player, WinningStrategy |
| Adding a third or fourth player | Use Deque<Player> โ no code change needed |
| Needing custom win rules | Add a new WinningStrategy class; don't modify Game |
| Adding undo/redo | Add a MoveHistory stack; undo() removes from Board and returns player to front of Deque |
| Adding an AI opponent | Create MinimaxPlayer extends Player โ makeMove(Board) runs minimax. No changes to Game, Board, or WinStrategy. |
๐ Game State Flow
The game progresses through a well-defined state machine. Representing this flow makes the Game.startGame() logic easy to reason about and test:
flowchart TD
Start([Game Created]) --> P[Poll next player from Deque]
P --> Input[Prompt player for row and col]
Input --> Valid{Cell valid\nand empty?}
Valid -->|No| Input
Valid -->|Yes| Place[Place piece on Board]
Place --> Win{Check row, col,\ndiagonals for win}
Win -->|Winner found| W([Announce winner โ game over])
Win -->|No winner| Full{Board full?}
Full -->|Yes| D([Announce draw โ game over])
Full -->|No| Return[OfferLast current player to Deque]
Return --> P
The deque is the heart of the turn management. pollFirst() takes the current player; offerLast() puts them back at the end. This works for 2, 3, or N players with no code change.
State transitions summary:
| State | Trigger | Next state |
| Waiting for input | Player prompted | Validating input |
| Validating input | Valid cell selected | Placing piece |
| Validating input | Invalid cell selected | Waiting for input |
| Placing piece | Piece placed | Checking win |
| Checking win | Winner found | Game over (win) |
| Checking win | Board full | Game over (draw) |
| Checking win | Game continues | Next player turn |
๐ Real-World Applications: Where This Design Pattern Applies
The Tic-Tac-Toe OOP model is a template for any turn-based game or system with pluggable rules:
Turn-based game engines: Chess, Checkers, Go, Connect Four all share the same skeleton โ Board, Piece, Player, turn loop, win condition. The WinStrategy interface and Deque<Player> turn manager carry across every one of them with minimal adaptation.
Card games: Replace PlayingPiece with Card, Board with Hand/Deck, and WinningStrategy with scoring logic. The Deque<Player> turn management is identical.
Interview follow-ups you can answer with this design:
- "How would you support undo?" โ Add a
MoveHistorystack;undo()callsboard.removePiece()and returns the player to the front of the deque. - "How would you add an AI player?" โ Create
AIPlayer extends Playerwith agetMove(Board board)method using minimax. No changes toGame. - "How would you support online multiplayer?" โ Serialize
Boardstate to JSON; store in Redis; accept moves over REST or WebSocket; rerun win check on each move.
๐งช Hands-On: Implementing and Testing the Design
Step 1 โ Wire up a complete 3ร3 game:
public static void main(String[] args) {
List<Player> players = List.of(
new Player("Alice", new PlayingPiece(PieceType.X)),
new Player("Bob", new PlayingPiece(PieceType.O))
);
Game game = new Game(3, players);
String result = game.startGame(new Scanner(System.in));
System.out.println(result);
}
Step 2 โ Write a unit test for the win checker:
@Test
public void testRowWin() {
Board board = new Board(3);
PlayingPiece x = new PlayingPiece(PieceType.X);
board.placePiece(0, 0, x);
board.placePiece(0, 1, x);
board.placePiece(0, 2, x);
WinningStrategy strategy = new OrderWinningStrategy();
assertTrue(strategy.hasWinner(board, 0, 2, x));
}
@Test
public void testNoWinWithMixedRow() {
Board board = new Board(3);
board.placePiece(0, 0, new PlayingPiece(PieceType.X));
board.placePiece(0, 1, new PlayingPiece(PieceType.O));
board.placePiece(0, 2, new PlayingPiece(PieceType.X));
assertFalse(new OrderWinningStrategy().hasWinner(board, 0, 2, new PlayingPiece(PieceType.X)));
}
Step 3 โ Test the deque-based turn rotation:
@Test
public void testThreePlayerRotation() {
Deque<Player> deque = new LinkedList<>(List.of(alice, bob, carol));
Player first = deque.pollFirst(); // alice
deque.offerLast(first);
assertEquals("Bob", deque.peekFirst().name);
deque.pollFirst(); deque.offerLast(bob);
assertEquals("Carol", deque.peekFirst().name);
}
๐ฏ What to Learn Next
- Strategy Design Pattern Explained
- Low-Level Design Guide for Ride Booking
- The Ultimate Data Structures Cheat Sheet
๐ ๏ธ Spring Boot REST: Exposing the Tic-Tac-Toe Game as a Playable API
Spring Boot is an opinionated Java framework for building standalone REST services; its @RestController, @Service, and dependency injection eliminate HTTP boilerplate so the game logic classes (Board, Player, WinningStrategy) stay clean and the controller only handles HTTP concerns.
The OOP model maps directly to REST: POST /games creates a session, POST /games/{id}/move applies a move and returns the outcome, GET /games/{id} returns current board state. Game state is stored per-session in a ConcurrentHashMap โ a production deployment would replace this with Redis.
// โโโ DTOs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
public record CreateGameRequest(int boardSize, List<PlayerRequest> players) {}
public record PlayerRequest(String name, String piece) {} // piece: "X" | "O" | "STAR"
public record MoveRequest(int row, int col) {}
public record MoveResponse(String gameId, String outcome, String boardSnapshot) {}
// โโโ Game session holder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@Data @AllArgsConstructor
public class GameSession {
private final String gameId;
private final Game game;
private String result; // null while in progress
}
// โโโ REST controller โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@RestController
@RequestMapping("/api/games")
@RequiredArgsConstructor
public class GameController {
// In production: inject a GameSessionRepository backed by Redis
private final ConcurrentHashMap<String, GameSession> sessions = new ConcurrentHashMap<>();
/** POST /api/games โ creates a new game session */
@PostMapping
public ResponseEntity<Map<String, String>> createGame(
@RequestBody CreateGameRequest req) {
List<Player> players = req.players().stream()
.map(p -> new Player(p.name(), new PlayingPiece(PieceType.valueOf(p.piece()))))
.toList();
String gameId = UUID.randomUUID().toString();
Game game = new Game(req.boardSize(), players);
sessions.put(gameId, new GameSession(gameId, game, null));
return ResponseEntity.ok(Map.of("gameId", gameId, "status", "created"));
}
/** POST /api/games/{gameId}/move โ applies a player's move */
@PostMapping("/{gameId}/move")
public ResponseEntity<MoveResponse> makeMove(
@PathVariable String gameId,
@RequestBody MoveRequest move) {
GameSession session = sessions.get(gameId);
if (session == null) return ResponseEntity.notFound().build();
String outcome = session.getGame().applyMove(move.row(), move.col());
if (outcome != null) session.setResult(outcome); // win or draw detected
return ResponseEntity.ok(new MoveResponse(
gameId, outcome != null ? outcome : "continue",
session.getGame().boardSnapshot() // simple string representation
));
}
/** GET /api/games/{gameId} โ returns current game state */
@GetMapping("/{gameId}")
public ResponseEntity<GameSession> getGame(@PathVariable String gameId) {
return Optional.ofNullable(sessions.get(gameId))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
Game.applyMove(row, col) wraps the validation, Board.placePiece(), checkWinner(), and Deque rotation from the earlier sections โ the controller only touches HTTP status codes and JSON serialization. Adding a WebSocket endpoint for real-time multiplayer notifications sits on top of the same GameSession state without modifying the game logic.
For a full deep-dive on Spring Boot WebSocket multiplayer game servers and Redis-backed session storage, a dedicated follow-up post is planned.
๐ Design Lessons from the Tic-Tac-Toe Problem
Lesson 1: The Deque pattern scales beyond this problem.
Round-robin turn management with pollFirst + offerLast works for any number of players or agents. The same pattern applies to scheduler queues, connection pools, and multiplayer server loops. Recognize it โ you will use it repeatedly.
Lesson 2: O(N) win detection is a general technique. The "only check the row, column, and diagonal of the last move" pattern applies to any NรN game. Avoid full-board scans whenever you can localize the check to the affected region. This shows interviewers you think about algorithmic efficiency, not just correctness.
Lesson 3: The Strategy Pattern turns extension requests into new files.
Without the Strategy Pattern, adding a "four-in-a-row" win rule requires modifying Game. With it, you create FourInARowStrategy implements WinningStrategy โ zero changes to existing code. Every interview extension question answered by "I'd add a new Strategy class" is a strong signal to the interviewer.
Lesson 4: Separate state from UI from rules.
Board holds state. WinningStrategy holds rules. Game coordinates the loop. Input/output belongs in main or a separate GameRunner. This separation makes every component independently testable โ a sign of professional OOP thinking.
๐ TLDR: Summary & Key Takeaways
- Four entities drive the design:
PlayingPiece,Player,Board,Game. - Use a
Deque<Player>for round-robin turns โ scales to any player count. - Win detection should check only the row, column, and diagonals touched by the last move โ O(N), not O(Nยฒ).
- The Strategy Pattern makes win conditions swappable at construction time.
- Factory or Builder patterns handle diverse player configurations cleanly.
๐ Practice Quiz
Why is
Deque<Player>a better choice than a two-element array for managing player turns?- A) Arrays are slower than deques in Java
- B) A deque scales to any number of players using poll/offerLast without index arithmetic
- C) Players must always be sorted alphabetically
- D) Arrays cannot store objects in Java
Correct Answer: B โ the deque's poll + offerLast idiom implements round-robin rotation for P players with no changes when the player count changes.
After placing a piece at (row=2, col=2) on a 3ร3 board, what is the minimum check needed to determine a win?
- A) Scan all 9 cells
- B) Check only row 2, column 2, and both diagonals (row==col and row+col==N-1)
- C) Only check the main diagonal
- D) Check all rows only
Correct Answer: B โ checking only the row, column, and applicable diagonals of the last move is O(N) per move rather than O(Nยฒ) for a full board scan.
Which SOLID principle does injecting a
WinningStrategyintoGamesupport?- A) Single Responsibility
- B) Open/Closed โ new strategies extend without modifying
Game - C) Liskov Substitution
- D) Interface Segregation
Correct Answer: B โ the Game class is open for extension (new WinningStrategy implementations) and closed for modification (Game code does not change when new strategies are added).
๐ Related Posts
- Strategy Design Pattern Explained
- Low-Level Design Guide for Ride Booking
- The Ultimate Data Structures Cheat Sheet

Written by
Abstract Algorithms
@abstractalgorithms
More Posts

Dirty Write Explained: When Uncommitted Data Gets Overwritten
TLDR: A dirty write occurs when Transaction B overwrites data that Transaction A has written but not yet committed. The result is not a rollback or an error โ it is silently inconsistent committed dat

Read Skew Explained: Inconsistent Snapshots Across Multiple Objects
TLDR: Read skew occurs when a transaction reads two logically related objects at different points in time โ one before and one after a concurrent transaction commits โ producing a view that never exis

Lost Update Explained: When Two Writes Become One
TLDR: A lost update occurs when two concurrent read-modify-write transactions both read the same committed value, both compute a new value from it, and both write back โ with the second write silently
Phantom Read Explained: When New Rows Appear Mid-Transaction
TLDR: A phantom read occurs when a transaction runs the same range query twice and gets a different set of rows โ because a concurrent transaction inserted or deleted matching rows and committed in between. Row locks cannot stop this because the phan...
