OOD Fundamentals
OOP Foundations
SOLID Principles
Creational Patterns
Structural Patterns
Behavioral Patterns
Classic OOD Problems: Part 1
Classic OOD Problems: Part 2
Design a Card Game Framework
Why build a framework instead of just coding up Blackjack? Because the moment you finish Blackjack, someone asks for Poker. Then Uno. Then Crazy Eights. A framework lets you support all of them without rewriting the plumbing each time. This is the ultimate test of the Open-Closed Principle: your core code stays sealed while new games plug in through well-defined extension points.

Core Entities
Every card game shares a handful of building blocks:
- Card, a suit and a rank. Immutable. Two cards with the same suit and rank are equal regardless of which object you hold.
- Deck, an ordered collection of cards. Supports shuffle, deal, and checking how many remain. Different games use different deck compositions.
- Hand, the cards a player currently holds. Different games evaluate hands differently: Blackjack sums toward 21, Poker ranks combinations.
- Player, has a name, a hand, and can take actions (hit, stand, fold, draw). The available actions depend on the game.
- Game, the abstract lifecycle that every card game follows: setup, deal, play rounds, determine winner.
- RuleEngine, encapsulates the scoring and validation logic specific to one game. This is the primary extension point.
- ScoreCalculator, computes hand values according to game-specific rules. Often part of the RuleEngine.
- TurnManager, tracks whose turn it is, what actions are available, and when the turn ends.
These entities form a clear hierarchy of responsibilities. Card and Deck handle the physical components. Hand and Player represent participants. Game, RuleEngine, and TurnManager orchestrate the flow.
Notice that Card implements __eq__ and __hash__ based on its values, not its identity. This makes it a value object: two cards with the same suit and rank are interchangeable. You can safely use cards in sets and as dictionary keys without worrying about reference equality.
In Java, the equivalent requires overriding equals() and hashCode() together. Forgetting to override hashCode() when you override equals() causes silent bugs with HashMap and HashSet: a classic interview pitfall.
Relationships
The entities connect in a straightforward way: a Game has two or more Players, one Deck, and one RuleEngine. Each Player has one Hand. The Deck deals Cards into Hands. The RuleEngine evaluates Hands and validates Player actions. The Game orchestrates the lifecycle by calling into these collaborators at each step.
This decomposition means each class has one reason to change. If the scoring rules for Blackjack change, only BlackjackRules is modified. If the deck composition for a game changes, only the DeckFactory is affected. The Game class itself stays stable.
The relationship diagram forms a tree with Game at the root. Game composes Players, a Deck, and a RuleEngine. Each Player composes a Hand. The Hand holds Cards. This clean composition hierarchy, no circular dependencies, no hidden couplings, is what makes the framework maintainable as it grows.
Use Cases
The framework must support these interactions:
- Start a game: select the game type, seat the players, initialize the deck.
- Deal cards: distribute cards from the deck to each player according to that game's rules.
- Play turns: each player takes actions; the rule engine validates every move.
- Evaluate hands: score each player's hand using the current game's scoring rules.
- Determine winner: compare scores and declare a result.
What We Intentionally Defer
Framework design is also about knowing what to leave out. We defer:
- GUI rendering, the framework models game logic, not pixels. A rendering layer can observe the game state and display it however it likes.
- Multiplayer networking, turn management is local; network transport is a separate concern layered on top.
- Betting and money, financial logic is a feature layer on top of the core game loop. Poker without betting is still a valid game.
- Persistence, saving and loading game state is a serialization concern, not a game logic concern. A Memento pattern could snapshot the game state without polluting the core classes.
- AI opponents, computer players can implement the same Player interface, but the AI decision logic is a separate concern. A
ComputerPlayerthat implementschoose_action()with strategy algorithms plugs in without any framework changes. - Time limits, chess clocks, turn timers, and round limits are game-variant features layered on top of the core loop.
In an interview, explicitly stating what you defer shows maturity. Interviewers want to see that you can scope a problem and resist the urge to boil the ocean.
Framework vs. Application
A single-game application hardcodes rules inside the game loop. A framework inverts that relationship: the game loop lives in the framework, and each concrete game supplies its own rules. This inversion is what makes it a framework rather than a library. The framework calls your code, not the other way around.
Consider the difference concretely. In a Blackjack application, the main loop directly calls calculate_hand_value() and checks if the total exceeds 21. In a framework, the main loop calls rule_engine.evaluate_hand(hand) and rule_engine.is_game_over(). The framework does not know it is playing Blackjack: it just follows the protocol. When you swap the rule engine for PokerRules, the exact same framework loop plays Poker instead.
This is inversion of control in practice. The framework defines the "holes" (abstract methods, interfaces). Each game fills the holes with concrete logic. The framework controls when each piece runs, but the game controls what happens at each step.
Why Not Just Use Inheritance?
A naive approach might define BlackjackGame, PokerGame, and UnoGame as standalone classes with no shared base. Each implements its own game loop from scratch. This works for two games but degrades quickly:
- Duplicated lifecycle code, every game re-implements the setup-deal-play-winner sequence. Bug fixes must be applied to every game separately.
- No polymorphism, client code cannot treat games generically. A tournament system that manages rounds would need a switch statement on game type.
- No enforced contract, without an abstract base class or interface, there is no guarantee that a new game implements the right methods. Errors surface at runtime instead of at design time.
The framework approach solves all three problems. The abstract Game class provides the shared lifecycle. Polymorphism lets client code call game.play() on any game type. And the abstract methods enforce the contract: a new Game subclass that forgets to implement deal_cards() fails at compile time (or at class definition time in Python with ABCMeta), not during a user's game session.
The strength of a framework lives in its abstractions. Get them wrong and every new game becomes a wrestling match with the base code. Get them right and adding a game feels like filling in a template. This section walks through the core abstractions one at a time and explains why each is shaped the way it is.

Card as a Value Object
A Card has two properties: suit and rank. That is all it will ever have. Two cards with the same suit and rank are equal, regardless of which object reference you hold. This makes Card a textbook value object: immutable, compared by value, and safe to share across any part of the system.
Making Card immutable prevents a category of bugs where one part of the code mutates a card that another part is still reading. Since cards never change after creation, you can pass them freely without defensive copying.
Why does this matter in a card game? Because the same card object might exist in the deck, in a player's hand, and in a discard pile simultaneously (as references). If Card were mutable, changing a property on the deck's reference would silently affect the hand's reference. Immutability makes this impossible: each reference sees the same unchanging data.
By implementing __eq__ and __hash__, two Card objects with the same suit and rank are considered identical. This lets you use cards as dictionary keys or set members: useful for tracking which cards have been played.
Deck and the Factory Pattern
A Deck holds an ordered list of cards and exposes three operations: shuffle(), deal(), and remaining(). The interesting design question is how you create decks, because different games use different decks. A standard Poker deck has 52 cards. An Uno deck has 108 cards in four colors with special action cards. A Pinochle deck doubles certain ranks.
A DeckFactory solves this. You ask the factory for a deck by game type, and it returns the right collection of cards:
This keeps deck construction out of the Game class and makes it trivial to add new deck types.
The Deck itself is simple, an ordered list of cards with three methods:
The optional seed parameter enables deterministic shuffling for testing. In production, you omit the seed for true randomness. In tests, you fix the seed to get predictable card sequences.
Different games need different decks:
- Poker/Blackjack, standard 52 cards (4 suits x 13 ranks)
- Uno, 108 cards with numbered cards, skip, reverse, draw-two, and wild cards in four colors
- Pinochle, 48 cards (double the 9-through-Ace ranks in four suits)
Each deck type is a factory method, and the Game subclass requests the right one during setup.
A common mistake is putting deck creation logic inside the Game subclass. This works initially but creates duplication when multiple game variants share the same deck. Poker and Bridge both use a standard 52-card deck. If each game builds its own deck, the construction logic is duplicated. The DeckFactory centralizes deck creation so that all standard-deck games share one implementation.
This helper function creates the raw card list. The DeckFactory wraps it in a Deck object and optionally applies game-specific modifications (like removing certain cards or adding jokers).
Hand and the Strategy Pattern
A Hand holds the cards a player currently has. You can add cards, remove cards, and ask the hand for its value. The tricky part is that "value" means completely different things in different games. In Blackjack, the hand value is the sum of card values with aces counting as 1 or 11. In Poker, the hand value is a ranking like "full house" or "flush."
The Strategy pattern resolves this. Hand delegates evaluation to a HandEvaluator interface. Each game provides its own evaluator:
Blackjack supplies a BlackjackEvaluator that sums card values toward 21. Poker supplies a PokerEvaluator that classifies five-card combinations into rankings. Uno supplies a UnoEvaluator that counts penalty points from remaining cards. The Hand class itself never changes: it delegates to whatever evaluator is injected.
This is the critical insight: Hand is a framework class that must never contain game-specific logic. The moment you add an if game == "blackjack" inside Hand, you have defeated the purpose of the framework.
Favor composition over deep inheritance hierarchies. Instead of BlackjackHand and PokerHand subclasses, use one Hand class that delegates to different evaluators. This keeps the class hierarchy shallow and lets you mix evaluation strategies freely.
Player
A Player has a name and a Hand. Players take actions during their turn: hit, stand, fold, draw, or discard. The set of available actions depends on the game, so the Game subclass controls which actions are legal at any given moment.
Notice that Player does not know the rules. It receives a list of available actions from the framework and picks one. This keeps Player generic across all games.
What Stays in the Framework vs. What Games Provide
Understanding this boundary is the key to good framework design:
| Framework Provides | Each Game Provides |
| Card, Deck, Hand classes | Deck composition (via DeckFactory) |
| Player with name and hand | Available actions per turn |
| Game lifecycle (setup, deal, play, end) | Implementation of each lifecycle step |
| Turn iteration loop | Turn-specific rules and constraints |
| HandEvaluator interface | Concrete evaluator (Blackjack, Poker, etc.) |
| GameFactory for creation | Registration of new game types |
Everything in the left column is closed for modification. Everything in the right column is open for extension. This split is the architectural signature of a well-designed framework.
Why Composition Over Deep Inheritance
You might be tempted to create a hierarchy like Game -> CardGame -> BlackjackGame, or Hand -> BlackjackHand -> SingleDeckBlackjackHand. Resist this urge. Deep hierarchies create fragile base class problems: changing CardGame breaks both BlackjackGame and PokerGame.
Instead, this framework uses a shallow hierarchy with composition:
- One level of inheritance:
Game(abstract) ->BlackjackGame(concrete). No intermediate layers. - Behavior via composition: Hand delegates evaluation to a HandEvaluator. Game delegates rules to a RuleEngine. Player delegates action selection to the caller.
- Interfaces over abstract classes for strategies: HandEvaluator and RuleEngine are interfaces (or ABCs with no state), making them easy to mock for testing.
The rule of thumb: inherit for "what it is" (BlackjackGame is a Game), compose for "what it does" (a Game has a RuleEngine). This keeps your class tree shallow and your components independently swappable.
Game as an Abstract Class
The Game class defines the skeleton of every card game's lifecycle using the Template Method pattern:
Concrete games like BlackjackGame and PokerGame override each abstract method with their own logic, but the high-level flow, setup, deal, play, determine winner, stays the same across every game. This consistency is exactly what makes it a framework.
The rule engine is where the framework earns its keep. Without it, adding a new game means scattering conditionals throughout the codebase. With a well-defined RuleEngine interface, each game's logic lives in one cohesive place, and the framework never needs to know the specifics.

The RuleEngine Interface
A RuleEngine defines three responsibilities:
Every game implements this interface with its own logic. The framework interacts only with the interface, never with a concrete implementation. When the Game class calls self.rule_engine.evaluate_hand(hand), it does not know whether it is evaluating a Blackjack hand or a Poker hand. It trusts the RuleEngine to return a meaningful score.
This decoupling is the foundation of extensibility. As long as a new game implements these three methods, it plugs into the framework seamlessly.
Blackjack Rules
BlackjackRules implements the RuleEngine with these specifics:
- evaluate_hand: sums card values. Number cards are face value, face cards (J, Q, K) are 10, aces are 11 unless that causes a bust, then 1.
- is_valid_move: a player can "hit" if their hand total is under 21, or "stand" at any time. No other actions exist.
- determine_winner: the player closest to 21 without exceeding it wins. A natural 21 (ace + face card on the initial deal) beats a non-natural 21. Ties go to the dealer.
The ace-handling logic is the most interesting part. Each ace starts as 11, but if the total exceeds 21, aces are downgraded to 1 one at a time until the hand is valid or all aces are 1. This greedy approach always produces the optimal value: you want aces to be 11 (higher is better in Blackjack) unless they cause a bust.
Consider a hand with Ace-Ace-9. Initially: 11 + 11 + 9 = 31 (bust). Downgrade one ace: 1 + 11 + 9 = 21 (perfect). The loop catches this automatically.
Poker Rules
PokerRules uses a completely different evaluation scheme:
- evaluate_hand: classifies the hand into rankings, high card, pair, two pair, three of a kind, straight, flush, full house, four of a kind, straight flush, royal flush, and returns a numeric rank.
- is_valid_move: validates actions like fold, call, raise, and check against the current betting state. A raise must exceed the current bet.
- determine_winner: compares hand rankings; ties are broken by kicker cards. For example, two players with a pair of Kings compare their highest remaining cards.
Here is a simplified PokerRules evaluator:
The key insight: Poker evaluation is entirely different from Blackjack evaluation, yet both implement the same evaluate_hand() interface. The caller never knows which algorithm runs.
The same Hand object, passed to different RuleEngines, produces entirely different scores. This is the Strategy pattern in action at the game level. A hand containing Ace-King-Queen-Jack-10 of hearts scores 21 in Blackjack but "Royal Flush" in Poker. The cards are identical: the interpretation changes entirely based on which rule engine evaluates them.
Without the Strategy Pattern
Imagine what the code would look like without this separation:
Every new game adds another branch. The Hand class becomes a god class that knows about every game in the system. With the Strategy pattern, Hand stays clean and each game owns its own evaluation logic.
Adding a New Game
Here is the real test of extensibility. To add Crazy Eights to the framework, you:
- Create
CrazyEightsRulesimplementingRuleEngine. - Create
CrazyEightsGameextendingGameand overriding the template methods. - Register it in the
GameFactory.
No existing class is modified. No switch statements grow longer. The framework is genuinely open for extension and closed for modification.
If adding a new game requires editing an existing class, your framework has a design flaw. The whole point of the RuleEngine interface and the abstract Game class is to make extension an additive operation, not a modification.
GameFactory
The GameFactory centralizes game creation so client code does not need to know concrete class names:
New games register themselves, and the factory produces them by name. This keeps client code decoupled from concrete game classes and makes it easy to add games at runtime.
The registry pattern is particularly powerful because it supports dynamic extension. A plugin system could scan for game modules at startup and register them automatically. The factory's create() method never changes, no matter how many games are available.
Compare this to a static factory with conditionals:
The registry version is open for extension (new games register themselves) and closed for modification (the create method never changes). The conditional version must be edited every time a new game is added: a direct violation of OCP.
The lifecycle is the backbone of the framework. Every card game, no matter how different its rules, follows the same high-level sequence: set up, deal cards, play rounds, and determine a winner. The Template Method pattern captures this sequence in the abstract Game class and lets concrete games fill in the details.

Template Method in Action
The abstract Game.play() method defines the invariant lifecycle:
Concrete games override each hook but never override play() itself. This guarantees that every game follows the same lifecycle, which makes the framework predictable for anyone reading or extending it.
Compare how two games implement the same lifecycle:
Both games follow the exact same lifecycle (setup, deal, play rounds, determine winner) but fill in completely different logic at each step. The framework does not care about the details: it just calls the methods in order.
This is the Template Method pattern at its best. The invariant parts (the lifecycle sequence) are defined once in the abstract class. The variant parts (how each step works) are left as abstract methods for subclasses to fill in. The result is a predictable, extensible structure where every game behaves consistently at the macro level while differing at the micro level.
Game State Management
A game transitions through well-defined states:
- NOT_STARTED, players are seated but cards have not been dealt.
- IN_PROGRESS, cards are dealt, rounds are being played.
- FINISHED, a winner has been determined or the game ended in a draw.
Tracking state explicitly prevents illegal operations. You cannot deal cards in the FINISHED state or determine a winner before the game starts. A simple enum and guard checks at the top of each method enforce these constraints.
The state machine prevents a common class of bugs: calling methods in the wrong order. Without explicit state tracking, a caller could accidentally deal cards twice, or try to determine a winner mid-game. The enum makes the valid transitions explicit and self-documenting.
You could also add a CANCELLED state for games that end prematurely (a player disconnects, or the deck runs out unexpectedly). The state machine naturally accommodates this: add the state to the enum and add the transition rules. No other code changes.
Some teams implement this as a full State pattern (the GoF behavioral pattern) where each state is a separate class with its own allowed transitions. For a card game with three or four states, an enum with guard checks is simpler and sufficient. Reserve the full State pattern for systems with many states and complex transition logic.
Turn Management
Within play_round(), the game must track whose turn it is, what actions that player can take, and when the turn ends. A common approach is a turn iterator that cycles through players:
The rule engine controls which actions are legal, so the turn management code stays generic. In Blackjack, the available actions might be "hit" and "stand." In Poker, they might be "fold," "call," and "raise." The framework does not care: it asks the rule engine and trusts the answer.
Template Method works because the lifecycle never changes, only the details do. If you find yourself needing to reorder the lifecycle steps for a new game, that is a signal to reconsider whether it belongs in this framework.
Winner Determination
Determining the winner is delegated entirely to the rule engine. The Game class calls self.rule_engine.determine_winner(self.players) and reports the result. This keeps the framework agnostic about how winners are chosen: closest to 21, best five-card hand, or last player standing.
The delegation pattern is consistent across the lifecycle: setup creates the deck and seats players, deal distributes cards, play manages turns, and winner determination compares hands. At every step, the framework handles the orchestration while the rule engine handles the decisions.
Consider how different winner determination looks across games:
- Blackjack: highest hand value at or below 21 wins. Busted players automatically lose.
- Poker: best five-card hand ranking wins. Ties broken by kicker cards.
- Uno: first player to empty their hand wins. Others score penalty points from remaining cards.
All three call rule_engine.determine_winner(players): the framework code is identical despite the wildly different logic.
Extending with New Games
The process is always the same:
- Subclass
Gameand overridesetup(),deal_cards(),play_round(),is_game_over(), anddetermine_winner(). - Implement a new
RuleEnginefor the game's rules. - Optionally create a new deck type via
DeckFactoryif the game uses nonstandard cards. - Register the game in
GameFactory.
Four steps, zero modifications to existing code. That is the payoff of a well-designed framework.
To verify your design is truly extensible, ask yourself these questions when adding a new game:
- Did I modify any existing class? If yes, the framework has a coupling problem.
- Did I add a conditional branch anywhere? If yes, you are likely missing an abstraction.
- Can I test the new game in isolation without instantiating other games? If yes, the separation is clean.
The ideal framework lets a new developer add a game by reading the interface documentation alone, without ever looking at other games' source code. Each game is a self-contained module that plugs into the framework through well-defined contracts.
Error Handling in the Lifecycle
Each lifecycle step can fail in game-specific ways. A deck might run out of cards mid-deal. A player might attempt an illegal action. The framework should define a consistent error handling contract:
Concrete games define what constitutes a GameError and how to handle it. Blackjack might treat a deck exhaustion as a draw. Poker might require a reshuffle of the discard pile. The framework catches the error and delegates handling to the game, maintaining the same pattern throughout: the framework orchestrates the flow, the game decides the details.
This error handling contract also makes testing straightforward. You can simulate edge cases (empty deck, all players busted, ties) by injecting specific game states and verifying that the framework responds correctly regardless of which concrete game is running.
Practice
Apply the patterns from this lesson. Build a card game framework, implement Blackjack, and evaluate poker hands.
Loading problem...
Loading problem...
Loading problem...