Game Framework
A reusable game engine where adding a new game means plugging in rules, not rewriting the loop. Template Method defines the game lifecycle, Strategy swaps scoring and win conditions, and Command enables move history with undo.
Key Abstractions
Template Method: defines the game loop skeleton: initialize, play turns, check win, end. Subclasses never override the loop itself, only the hooks.
Strategy: validates moves, checks win conditions, calculates scores. Swap this to change the game entirely.
State enum tracking whose turn it is and whether the game is running or finished. Prevents illegal transitions like playing after game over.
Command: encapsulates a player action with execute/undo. Enables move history and replay.
Participant with name, score, and game-specific attributes. The engine treats all players identically.
Generic board abstraction: grid-based or card-based. Games provide their own board type.
Observer: notified on move made, turn changed, game over. UI and logging subscribe here.
Factory: creates the right Game+Rules+Board combo from a game type string.
Class Diagram
The Whole Point Is: Don't Build Games, Build a Game Machine
Think about what happens when you build Tic-Tac-Toe from scratch. You write a game loop, a board, move validation, a win check. Works great. Now your interviewer says "add Connect Four." You could copy-paste the Tic-Tac-Toe code, change the board size, rewrite the win check. But that gives you two separate codebases that share 80% of their logic. Add a third game and you have a maintenance disaster.
A game framework flips this. You build the machine once. The game loop, the turn management, the move history, the observer notifications. All of that is shared infrastructure. When you add a new game, you plug in three things: a board, a set of rules, and the moves. The engine never changes.
This is the same philosophy behind real game engines like Unity or Unreal. The engine handles the lifecycle. Your game-specific code just fills in the blanks.
Requirements
Functional
- Support multiple game types through a single engine (Tic-Tac-Toe, Connect Four, etc.)
- Validate moves according to game-specific rules
- Detect win conditions and draws
- Maintain full move history with undo support
- Notify observers on game events (move made, turn changed, game over)
- Create fully configured games from a single factory call
Non-Functional
- Adding a new game should require zero changes to the engine code
- Move undo should operate in O(1) time, not require state snapshots
- The framework should handle any number of players (2, 3, 4+)
- Board abstraction should support grid-based and non-grid-based games
Design Decisions
Why Template Method for the game loop?
Every turn-based game follows the same lifecycle. Initialize. Loop: get a move, validate it, apply it, check for a winner, switch turns. End. That sequence never changes. What changes is the content of each step.
Template Method captures this perfectly. The play() method in the base Game class defines the skeleton. Subclasses override hooks like _initialize() and _get_move() to plug in game-specific behavior. You could not use Strategy here because the steps have a fixed ordering and they share state (the board, the current player, the move history). Template Method gives you that shared context naturally.
Why Strategy for rules instead of inheritance?
You might be tempted to put the win-check logic directly in TicTacToeGame and ConnectFourGame. That works initially. But it means the game class is responsible for both lifecycle management (Template Method) and rule enforcement. Two reasons to change.
Pulling rules into a separate GameRules interface lets you mix and match. Want Tic-Tac-Toe on a 5x5 board with a "4 in a row" win condition? Write new rules, keep the same game class. Want to test rules in isolation without spinning up the full game engine? Just instantiate the rules object and call check_winner() directly. Strategy gives you that flexibility without touching the engine.
Why Command for moves instead of state snapshots?
The naive approach to undo is saving a copy of the entire board before each move. For a 3x3 Tic-Tac-Toe board, that is cheap. For a 6x7 Connect Four board, it is still fine. But scale up to Chess (8x8 with complex piece state) or Go (19x19) and you are allocating huge snapshots every single turn.
Command flips the cost model. Each Move stores just enough information to reverse itself: what cell was affected and what was there before. Undo is a single cell write. The move history doubles as a replay log, a debug trail, and an undo stack all at once.
How to handle different board types?
A Tic-Tac-Toe board is a 3x3 grid. A Connect Four board is a 6x7 grid. A card game has no grid at all. If you bake int[][] grid into the Board class, card games are out.
The solution is an abstract Board interface with minimal operations: get a cell, set a cell, check if something is empty, check if the board is full. GridBoard implements this with a 2D array. A hypothetical CardBoard could implement it with a list of hands. The engine never cares which one it is. It just passes the board to the rules and lets the rules interpret it.
Interview Follow-ups
How would you add multiplayer networking?
The core change is replacing local move input with a network transport layer. You would introduce a MoveSource interface with two implementations: LocalMoveSource (reads from the current player's input) and NetworkMoveSource (receives moves over a WebSocket or TCP connection). The game loop stays identical. On each turn, the engine asks the current player's MoveSource for a move. If that player is remote, the source blocks until a move arrives over the network. You would also add a GameServer that broadcasts every move to all connected clients so their local boards stay in sync. The Observer pattern already handles event distribution, so wiring in network listeners is straightforward.
How would you add game persistence and save/load?
Move history gives you this almost for free. Since every move is a Command with full execute/undo capability, saving a game is just serializing the move list plus the player information and game type. To load, you create a fresh game via the factory and replay the moves. This is simpler and more reliable than trying to serialize the board state directly. Board state is derived data. The move history is the source of truth. For quick resume without replay, you could also snapshot the board state alongside the move list, but the move list alone is sufficient.
How would you integrate an AI opponent?
Create an AIPlayer that implements a MoveStrategy interface. The simplest version picks a random valid move. A smarter version uses minimax with alpha-beta pruning. The key insight is that the AI just produces a move. The engine does not care whether a move came from a human or a bot. You would modify _get_move() to check if the current player has an AI strategy attached. If so, the strategy evaluates the board and returns a move. If not, you prompt the human. The GameRules interface already has is_valid_move() and check_winner(), so the AI can simulate moves and evaluate positions using the same rules the engine uses.
How would you extend this for real-time games instead of turn-based?
Turn-based games have a natural "get move, apply, check, switch turn" cycle. Real-time games (like a simplified version of Tetris or a reaction-time game) need a different loop: a fixed-timestep update cycle. You would introduce a GameLoop strategy with two implementations: TurnBasedLoop (the current approach) and RealTimeLoop (calls update(deltaTime) on every game object at a fixed interval). The Board, Move, and Rules abstractions still apply, but moves arrive asynchronously rather than sequentially. The Observer pattern becomes even more important here because the UI needs to update on every tick, not just on player actions.
Code Implementation
1 from abc import ABC, abstractmethod
2 from dataclasses import dataclass, field
3 from enum import Enum
4 from typing import Optional
5
6
7 # ── State: game phases ──────────────────────────────────────
8
9 class GamePhase(Enum):
10 SETUP = "setup"
11 PLAYING = "playing"
12 FINISHED = "finished"
13
14
15 # ── Player ──────────────────────────────────────────────────
16
17 @dataclass
18 class Player:
19 name: str
20 symbol: str
21 score: int = 0
22
23 def __str__(self) -> str:
24 return f"{self.name}({self.symbol})"
25
26
27 # ── Board interface + GridBoard ─────────────────────────────
28
29 class Board(ABC):
30 @abstractmethod
31 def get_cell(self, row: int, col: int) -> str: ...
32
33 @abstractmethod
34 def set_cell(self, row: int, col: int, value: str) -> None: ...
35
36 @abstractmethod
37 def is_empty(self, row: int, col: int) -> bool: ...
38
39 @abstractmethod
40 def is_full(self) -> bool: ...
41
42 @abstractmethod
43 def display(self) -> None: ...
44
45
46 class GridBoard(Board):
47 def __init__(self, rows: int, cols: int):
48 self.rows = rows
49 self.cols = cols
50 self._grid: list[list[str]] = [["." for _ in range(cols)] for _ in range(rows)]
51
52 def get_cell(self, row: int, col: int) -> str:
53 return self._grid[row][col]
54
55 def set_cell(self, row: int, col: int, value: str) -> None:
56 self._grid[row][col] = value
57
58 def is_empty(self, row: int, col: int) -> bool:
59 return self._grid[row][col] == "."
60
61 def is_full(self) -> bool:
62 return all(
63 self._grid[r][c] != "."
64 for r in range(self.rows)
65 for c in range(self.cols)
66 )
67
68 def display(self) -> None:
69 for r in range(self.rows):
70 print(" ".join(self._grid[r]))
71 print()
72
73
74 # ── Command: Move with execute/undo ─────────────────────────
75
76 @dataclass
77 class Move:
78 row: int
79 col: int
80 player: Player
81 previous_value: str = "."
82
83 def execute(self, board: Board) -> None:
84 self.previous_value = board.get_cell(self.row, self.col)
85 board.set_cell(self.row, self.col, self.player.symbol)
86
87 def undo(self, board: Board) -> None:
88 board.set_cell(self.row, self.col, self.previous_value)
89
90 def __str__(self) -> str:
91 return f"{self.player} -> ({self.row}, {self.col})"
92
93
94 # ── Observer: event listeners ───────────────────────────────
95
96 class GameEventListener(ABC):
97 @abstractmethod
98 def on_move_made(self, move: Move) -> None: ...
99
100 @abstractmethod
101 def on_turn_changed(self, player: Player) -> None: ...
102
103 @abstractmethod
104 def on_game_over(self, winner: Optional[Player]) -> None: ...
105
106
107 class ConsoleLogger(GameEventListener):
108 def on_move_made(self, move: Move) -> None:
109 print(f" [LOG] Move: {move}")
110
111 def on_turn_changed(self, player: Player) -> None:
112 print(f" [LOG] Turn -> {player}")
113
114 def on_game_over(self, winner: Optional[Player]) -> None:
115 if winner:
116 print(f" [LOG] Game Over! Winner: {winner}")
117 else:
118 print(f" [LOG] Game Over! It's a draw.")
119
120
121 # ── Strategy: GameRules interface ───────────────────────────
122
123 class GameRules(ABC):
124 @abstractmethod
125 def is_valid_move(self, board: Board, move: Move) -> bool: ...
126
127 @abstractmethod
128 def check_winner(self, board: Board, players: list[Player]) -> Optional[Player]: ...
129
130 @abstractmethod
131 def calculate_score(self, board: Board, player: Player) -> int: ...
132
133 @abstractmethod
134 def is_game_over(self, board: Board, players: list[Player]) -> bool: ...
135
136
137 class TicTacToeRules(GameRules):
138 def is_valid_move(self, board: Board, move: Move) -> bool:
139 if not (0 <= move.row < 3 and 0 <= move.col < 3):
140 return False
141 return board.is_empty(move.row, move.col)
142
143 def check_winner(self, board: Board, players: list[Player]) -> Optional[Player]:
144 for player in players:
145 s = player.symbol
146 # Rows and columns
147 for i in range(3):
148 if all(board.get_cell(i, c) == s for c in range(3)):
149 return player
150 if all(board.get_cell(r, i) == s for r in range(3)):
151 return player
152 # Diagonals
153 if all(board.get_cell(i, i) == s for i in range(3)):
154 return player
155 if all(board.get_cell(i, 2 - i) == s for i in range(3)):
156 return player
157 return None
158
159 def calculate_score(self, board: Board, player: Player) -> int:
160 temp_players = [player]
161 return 1 if self.check_winner(board, temp_players) == player else 0
162
163 def is_game_over(self, board: Board, players: list[Player]) -> bool:
164 return self.check_winner(board, players) is not None or board.is_full()
165
166
167 class ConnectFourRules(GameRules):
168 ROWS = 6
169 COLS = 7
170 WIN_LENGTH = 4
171
172 def is_valid_move(self, board: Board, move: Move) -> bool:
173 if not (0 <= move.col < self.COLS):
174 return False
175 # In Connect Four, you drop into a column. The row must be the
176 # lowest empty cell in that column. We check the column is not full.
177 return board.is_empty(0, move.col)
178
179 def find_drop_row(self, board: Board, col: int) -> int:
180 """Find the lowest empty row in the given column."""
181 for r in range(self.ROWS - 1, -1, -1):
182 if board.is_empty(r, col):
183 return r
184 return -1
185
186 def check_winner(self, board: Board, players: list[Player]) -> Optional[Player]:
187 for player in players:
188 s = player.symbol
189 for r in range(self.ROWS):
190 for c in range(self.COLS):
191 if board.get_cell(r, c) != s:
192 continue
193 # Check four directions: right, down, down-right, down-left
194 directions = [(0, 1), (1, 0), (1, 1), (1, -1)]
195 for dr, dc in directions:
196 if self._count_in_direction(board, r, c, dr, dc, s) >= self.WIN_LENGTH:
197 return player
198 return None
199
200 def _count_in_direction(self, board: Board, r: int, c: int,
201 dr: int, dc: int, symbol: str) -> int:
202 count = 0
203 while (0 <= r < self.ROWS and 0 <= c < self.COLS
204 and board.get_cell(r, c) == symbol):
205 count += 1
206 r += dr
207 c += dc
208 return count
209
210 def calculate_score(self, board: Board, player: Player) -> int:
211 temp_players = [player]
212 return 1 if self.check_winner(board, temp_players) == player else 0
213
214 def is_game_over(self, board: Board, players: list[Player]) -> bool:
215 return self.check_winner(board, players) is not None or board.is_full()
216
217
218 # ── Template Method: the Game engine ────────────────────────
219
220 class Game(ABC):
221 def __init__(self, rules: GameRules, board: Board, players: list[Player]):
222 self._rules = rules
223 self._board = board
224 self._players = players
225 self._phase = GamePhase.SETUP
226 self._current_player_index = 0
227 self._move_history: list[Move] = []
228 self._listeners: list[GameEventListener] = []
229
230 def add_listener(self, listener: GameEventListener) -> None:
231 self._listeners.append(listener)
232
233 def _notify_move(self, move: Move) -> None:
234 for listener in self._listeners:
235 listener.on_move_made(move)
236
237 def _notify_turn_changed(self, player: Player) -> None:
238 for listener in self._listeners:
239 listener.on_turn_changed(player)
240
241 def _notify_game_over(self, winner: Optional[Player]) -> None:
242 for listener in self._listeners:
243 listener.on_game_over(winner)
244
245 @property
246 def current_player(self) -> Player:
247 return self._players[self._current_player_index]
248
249 @property
250 def phase(self) -> GamePhase:
251 return self._phase
252
253 # Template method: the game loop
254 def play(self, scripted_moves: list[tuple[int, int]]) -> None:
255 self._initialize()
256 self._phase = GamePhase.PLAYING
257
258 move_index = 0
259 while self._phase == GamePhase.PLAYING and move_index < len(scripted_moves):
260 row, col = scripted_moves[move_index]
261 move = self._get_move(self.current_player, row, col)
262 move_index += 1
263
264 if not self._rules.is_valid_move(self._board, move):
265 print(f" Invalid move: {move}. Skipping.")
266 continue
267
268 move.execute(self._board)
269 self._move_history.append(move)
270 self._notify_move(move)
271
272 if self._rules.is_game_over(self._board, self._players):
273 winner = self._rules.check_winner(self._board, self._players)
274 self._end_game(winner)
275 return
276
277 self._switch_turn()
278
279 if self._phase == GamePhase.PLAYING:
280 self._end_game(None)
281
282 @abstractmethod
283 def _initialize(self) -> None:
284 """Set up the board for this specific game."""
285 ...
286
287 @abstractmethod
288 def _get_move(self, player: Player, row: int, col: int) -> Move:
289 """Create a Move for this game type."""
290 ...
291
292 def _switch_turn(self) -> None:
293 self._current_player_index = (
294 (self._current_player_index + 1) % len(self._players)
295 )
296 self._notify_turn_changed(self.current_player)
297
298 def _end_game(self, winner: Optional[Player]) -> None:
299 self._phase = GamePhase.FINISHED
300 if winner:
301 winner.score += 1
302 self._board.display()
303 self._notify_game_over(winner)
304
305 def undo_last_move(self) -> bool:
306 if not self._move_history:
307 print(" No moves to undo.")
308 return False
309 last_move = self._move_history.pop()
310 last_move.undo(self._board)
311 # Revert turn
312 self._current_player_index = (
313 (self._current_player_index - 1) % len(self._players)
314 )
315 print(f" Undid move: {last_move}")
316 return True
317
318
319 # ── Concrete games ──────────────────────────────────────────
320
321 class TicTacToeGame(Game):
322 def _initialize(self) -> None:
323 print("Setting up Tic-Tac-Toe (3x3 board)")
324
325 def _get_move(self, player: Player, row: int, col: int) -> Move:
326 return Move(row=row, col=col, player=player)
327
328
329 class ConnectFourGame(Game):
330 def _initialize(self) -> None:
331 print("Setting up Connect Four (6x7 board)")
332
333 def _get_move(self, player: Player, row: int, col: int) -> Move:
334 # In Connect Four, the player picks a column. We find the drop row.
335 rules: ConnectFourRules = self._rules # type: ignore
336 actual_row = rules.find_drop_row(self._board, col)
337 return Move(row=actual_row, col=col, player=player)
338
339
340 # ── Factory ─────────────────────────────────────────────────
341
342 class GameFactory:
343 @staticmethod
344 def create(game_type: str) -> Game:
345 if game_type == "tic-tac-toe":
346 board = GridBoard(3, 3)
347 rules = TicTacToeRules()
348 players = [Player("Alice", "X"), Player("Bob", "O")]
349 return TicTacToeGame(rules, board, players)
350 elif game_type == "connect-four":
351 board = GridBoard(6, 7)
352 rules = ConnectFourRules()
353 players = [Player("Alice", "R"), Player("Bob", "Y")]
354 return ConnectFourGame(rules, board, players)
355 else:
356 raise ValueError(f"Unknown game type: {game_type}")
357
358
359 # ── Demo ────────────────────────────────────────────────────
360
361 if __name__ == "__main__":
362 logger = ConsoleLogger()
363
364 # ── Game 1: Tic-Tac-Toe ──
365 print("=" * 50)
366 print("GAME 1: Tic-Tac-Toe via GameFactory")
367 print("=" * 50)
368
369 ttt = GameFactory.create("tic-tac-toe")
370 ttt.add_listener(logger)
371
372 # Scripted moves: (row, col) pairs. X wins with top row.
373 ttt_moves = [
374 (0, 0), # X top-left
375 (1, 0), # O mid-left
376 (0, 1), # X top-center
377 (1, 1), # O mid-center
378 (0, 2), # X top-right -> X wins
379 ]
380 ttt.play(ttt_moves)
381 print()
382
383 # ── Demonstrate undo ──
384 print("=" * 50)
385 print("UNDO DEMO: Tic-Tac-Toe")
386 print("=" * 50)
387
388 ttt2 = GameFactory.create("tic-tac-toe")
389 ttt2.add_listener(logger)
390
391 # Play two moves, undo one, then continue
392 print("\nPlaying two moves manually...")
393 ttt2._initialize()
394 ttt2._phase = GamePhase.PLAYING
395
396 m1 = Move(row=1, col=1, player=ttt2._players[0])
397 m1.execute(ttt2._board)
398 ttt2._move_history.append(m1)
399 ttt2._notify_move(m1)
400 ttt2._switch_turn()
401
402 m2 = Move(row=0, col=0, player=ttt2._players[1])
403 m2.execute(ttt2._board)
404 ttt2._move_history.append(m2)
405 ttt2._notify_move(m2)
406
407 print("\nBoard after two moves:")
408 ttt2._board.display()
409
410 print("Undoing last move...")
411 ttt2.undo_last_move()
412 print("\nBoard after undo:")
413 ttt2._board.display()
414
415 # ── Game 2: Connect Four ──
416 print("=" * 50)
417 print("GAME 2: Connect Four via GameFactory")
418 print("=" * 50)
419
420 c4 = GameFactory.create("connect-four")
421 c4.add_listener(logger)
422
423 # Scripted moves: (ignored_row, col). R wins with vertical 4 in col 0.
424 c4_moves = [
425 (0, 0), # R drops in col 0
426 (0, 1), # Y drops in col 1
427 (0, 0), # R drops in col 0
428 (0, 1), # Y drops in col 1
429 (0, 0), # R drops in col 0
430 (0, 1), # Y drops in col 1
431 (0, 0), # R drops in col 0 -> R wins (4 vertical)
432 ]
433 c4.play(c4_moves)
434
435 print()
436 print("=" * 50)
437 print("Same engine, different rules. That's the framework.")
438 print("=" * 50)Common Mistakes
- ✗Putting game-specific logic in the engine. The engine should know nothing about chess rules or card hands. If you edit the engine to add a new game, the abstraction is broken.
- ✗Skipping the Command pattern for moves. Without it, undo requires snapshots of the entire game state (expensive and fragile).
- ✗Making the Board too specific. A chess board is 8x8, a card game has no grid. The board abstraction needs to accommodate both.
- ✗Not using State for game phases. Without explicit states (SETUP, PLAYING, PAUSED, FINISHED), you get boolean flags like `isStarted`, `isOver`, `isPaused` that conflict with each other.
Key Points
- ✓Template Method keeps the game loop identical across all games: initialize board, loop (get move, validate, apply, check win, switch turn), end game. Each game only overrides the hooks.
- ✓Strategy for rules means Tic-Tac-Toe's win check (3 in a row) and Chess's win check (checkmate) are both just implementations of the same GameRules interface.
- ✓Command pattern for moves gives you undo for free. Every move stores enough state to reverse itself. Move history is just a list of commands.
- ✓Factory hides game creation complexity. `GameFactory.create("tic-tac-toe")` returns a fully wired Game with the right board, rules, and player count.