Low-Level Design · lesson 3 of 18
Beginner
4Intermediate
10- 5 Design a Parking Lot: Allocating and Freeing Spots
- 6 Design an Elevator System: Scheduling Requests
- 7 Design a Library Management System: Borrowing and Returning Books
- 8 Design an Expense-Splitting App: Balances and Minimal Settlements
- 9 Design a URL Shortener: Sequential Codes vs Real Hash Collisions
- 10 Wrapping Objects: Adapter, Facade, and Proxy in Python
- 11 The Composite Pattern in Python: Sizing a File Tree Without isinstance
- 12 The State Pattern in Python: Refactoring a Vending Machine Into State Classes
- 13 Undo and Redo in Python: the Memento and Command Patterns Compared
- 14 Chain of Responsibility in Python: Expense Approvals Without an if/elif Ladder
Advanced
4- 15 Concurrency in LLD: A Real Race Condition in the Parking Lot, Then the Fix
- 16 The Observer Pattern in Practice: A Stock Ticker Notifying Multiple Displays
- 17 Design Patterns That Are Just Language Features: Strategy, Command, Iterator
- 18 Abstract Factory and Prototype in Python: Matching Families and Cloning Objects
Also part of Low-Level Design
12- + SOLID Principles in Java from Java
- + Composition vs Inheritance in Java from Java
- + Design Patterns in Java from Java
- + Python Design Patterns from Python
- + Design Patterns in TypeScript from TypeScript
- + JavaScript Design Patterns from JavaScript
- + Design Patterns in C# from C#
- + Design Patterns in C++ from C++
- + Design Patterns in Go from Go
- + Design Patterns in Rust from Rust
- + Abstract Classes in Java from Java
- + Sealed Classes and Interfaces in Java from Java
Design Tic-Tac-Toe: Board, Players, and Win Detection
Split a game into a Board that only knows about cells and a Game that only knows about turns, then check all eight winning lines after every move.
By SkillByExample · Published
Tic-tac-toe looks trivial until you write the win check — eight lines, checked after every move.
Board and Game as separate responsibilities
class Board:
def __init__(self):
self.cells = [" "] * 9
def place(self, position, mark):
if self.cells[position] != " ":
print(f"Cell {position} already taken")
return False
self.cells[position] = mark
return True
def render(self):
rows = [self.cells[i:i+3] for i in range(0, 9, 3)]
for row in rows:
print(" | ".join(row))
print()
def winner(self):
lines = [
(0,1,2), (3,4,5), (6,7,8), # rows
(0,3,6), (1,4,7), (2,5,8), # columns
(0,4,8), (2,4,6), # diagonals
]
for a, b, c in lines:
if self.cells[a] != " " and self.cells[a] == self.cells[b] == self.cells[c]:
return self.cells[a]
return None
class Game:
def __init__(self):
self.board = Board()
self.players = ["X", "O"]
self.turn = 0
def play(self, position):
mark = self.players[self.turn % 2]
if not self.board.place(position, mark):
return
self.board.render()
winner = self.board.winner()
if winner:
print(f"{winner} wins!")
self.turn += 1
game = Game()
moves = [0, 3, 1, 4, 2]
for m in moves:
game.play(m)
$ python tictactoe.py
X | |
| |
| |
X | |
O | |
| |
X | X |
O | |
| |
X | X |
O | O |
| |
X | X | X
O | O |
| |
X wins!
Game.play alternates marks with self.turn % 2, delegates the actual write to
Board.place, and asks Board.winner() after every move — it never inspects cells
directly. Board doesn’t know or care that X and O take turns; it just enforces “one
mark per empty cell” and “these eight triples decide the game.”
Practice
1. Play out a full board that ends in a draw.
class Board:
def __init__(self):
self.cells = [" "] * 9
def place(self, position, mark):
if self.cells[position] != " ":
return False
self.cells[position] = mark
return True
def is_full(self):
return " " not in self.cells
def winner(self):
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),
]
for a, b, c in lines:
if self.cells[a] != " " and self.cells[a] == self.cells[b] == self.cells[c]:
return self.cells[a]
return None
class Game:
def __init__(self):
self.board = Board()
self.players = ["X", "O"]
self.turn = 0
def play(self, position):
mark = self.players[self.turn % 2]
if not self.board.place(position, mark):
return
winner = self.board.winner()
if winner:
print(f"{winner} wins!")
elif self.board.is_full():
print("Draw!")
self.turn += 1
game = Game()
moves = [0, 1, 2, 4, 3, 5, 7, 6, 8]
for m in moves:
game.play(m)
$ python draw.py
Draw!
is_full() is only checked once winner() has already come back empty, so a full board that
also happens to be a winning line still reports the win, not a draw.
2. Confirm placing on an already-taken cell is rejected.
class Board:
def __init__(self):
self.cells = [" "] * 9
def place(self, position, mark):
if self.cells[position] != " ":
print(f"Cell {position} already taken")
return False
self.cells[position] = mark
print(f"{mark} placed at {position}")
return True
board = Board()
board.place(4, "X")
board.place(4, "O")
$ python taken_cell.py
X placed at 4
Cell 4 already taken
place returns False on a rejected move — the caller (Game.play) already checks this
return value and skips rendering/win-checking when it’s False, so a rejected placement
never advances the turn either.
3. Make the game refuse further moves once it has a winner.
class Board:
def __init__(self):
self.cells = [" "] * 9
def place(self, position, mark):
self.cells[position] = mark
def winner(self):
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),
]
for a, b, c in lines:
if self.cells[a] != " " and self.cells[a] == self.cells[b] == self.cells[c]:
return self.cells[a]
return None
class Game:
def __init__(self):
self.board = Board()
self.players = ["X", "O"]
self.turn = 0
self.finished = False
def play(self, position):
if self.finished:
print(f"Game over, move to {position} ignored")
return
mark = self.players[self.turn % 2]
self.board.place(position, mark)
winner = self.board.winner()
if winner:
print(f"{winner} wins!")
self.finished = True
self.turn += 1
game = Game()
for m in [0, 3, 1, 4, 2, 8]:
game.play(m)
$ python finished.py
X wins!
Game over, move to 8 ignored
self.finished is exactly the same idea as the vending machine’s state variable two
lessons ago — one flag that, once set, makes an entire class of further calls a no-op instead
of trusting every caller to stop calling play() on their own.
Next: SOLID’s Open/Closed Principle, and why an if/elif chain of shapes breaks every time a new shape is added.