Design a Card Game Framework

Topics Covered

Requirements and Use Cases

Core Entities

Relationships

Use Cases

What We Intentionally Defer

Framework vs. Application

Why Not Just Use Inheritance?

Deck, Hand, and Game Abstractions

Card as a Value Object

Deck and the Factory Pattern

Hand and the Strategy Pattern

Player

What Stays in the Framework vs. What Games Provide

Why Composition Over Deep Inheritance

Game as an Abstract Class

Rule Engine and Extensibility

The RuleEngine Interface

Blackjack Rules

Poker Rules

Without the Strategy Pattern

Adding a New Game

GameFactory

Game Lifecycle Management

Template Method in Action

Game State Management

Turn Management

Winner Determination

Extending with New Games

Error Handling in the Lifecycle

Practice

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.

Deck factory producing game decks

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.

python
1class Card:
2    def __init__(self, suit: str, rank: str):
3        self.suit = suit
4        self.rank = rank
5
6    def __eq__(self, other):
7        return self.suit == other.suit and self.rank == other.rank
8
9    def __hash__(self):
10        return hash((self.suit, self.rank))
11
12    def __repr__(self):
13        return f"{self.rank} of {self.suit}"

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:

  1. Start a game: select the game type, seat the players, initialize the deck.
  2. Deal cards: distribute cards from the deck to each player according to that game's rules.
  3. Play turns: each player takes actions; the rule engine validates every move.
  4. Evaluate hands: score each player's hand using the current game's scoring rules.
  5. 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 ComputerPlayer that implements choose_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.
Interview Tip

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.

Framework shared abstractions

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.

python
1class Card:
2    def __init__(self, suit: str, rank: str):
3        self.suit = suit
4        self.rank = rank
5
6    def __eq__(self, other):
7        if not isinstance(other, Card):
8            return False
9        return self.suit == other.suit and self.rank == other.rank
10
11    def __hash__(self):
12        return hash((self.suit, self.rank))

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:

python
1class DeckFactory:
2    @staticmethod
3    def create(game_type: str) -> Deck:
4        if game_type == "poker":
5            return Deck(standard_52_cards())
6        elif game_type == "uno":
7            return Deck(uno_108_cards())
8        # extend for new games

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:

python
1class Deck:
2    def __init__(self, cards: list[Card]):
3        self._cards = cards
4
5    def shuffle(self, seed: int = None):
6        import random
7        rng = random.Random(seed)
8        rng.shuffle(self._cards)
9
10    def deal(self) -> Card:
11        if not self._cards:
12            raise ValueError("Deck is empty")
13        return self._cards.pop()
14
15    def remaining(self) -> int:
16        return len(self._cards)

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.

python
1def standard_52_cards() -> list[Card]:
2    suits = ["Hearts", "Diamonds", "Clubs", "Spades"]
3    ranks = ["2","3","4","5","6","7","8","9","10","J","Q","K","A"]
4    return [Card(suit, rank) for suit in suits for rank in ranks]

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:

python
1class HandEvaluator(ABC):
2    @abstractmethod
3    def evaluate(self, cards: list[Card]) -> HandResult:
4        pass

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.

Key Insight

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.

python
1class Player:
2    def __init__(self, name: str):
3        self.name = name
4        self.hand = Hand()
5
6    def choose_action(self, available: list[str]) -> str:
7        # In a real system, this prompts the user or consults AI
8        pass

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 ProvidesEach Game Provides
Card, Deck, Hand classesDeck composition (via DeckFactory)
Player with name and handAvailable actions per turn
Game lifecycle (setup, deal, play, end)Implementation of each lifecycle step
Turn iteration loopTurn-specific rules and constraints
HandEvaluator interfaceConcrete evaluator (Blackjack, Poker, etc.)
GameFactory for creationRegistration 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:

python
1class Game(ABC):
2    def play(self):
3        self.setup()
4        self.deal_cards()
5        self.play_rounds()
6        self.determine_winner()
7
8    @abstractmethod
9    def setup(self): ...
10
11    @abstractmethod
12    def deal_cards(self): ...
13
14    @abstractmethod
15    def play_rounds(self): ...
16
17    @abstractmethod
18    def determine_winner(self): ...

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.

Rule engine strategy swapping

The RuleEngine Interface

A RuleEngine defines three responsibilities:

python
1class RuleEngine(ABC):
2    @abstractmethod
3    def is_valid_move(self, player: Player, action: str) -> bool:
4        pass
5
6    @abstractmethod
7    def evaluate_hand(self, hand: Hand) -> int:
8        pass
9
10    @abstractmethod
11    def determine_winner(self, players: list[Player]) -> Player:
12        pass

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.
python
1class BlackjackRules(RuleEngine):
2    def evaluate_hand(self, hand: Hand) -> int:
3        total = 0
4        aces = 0
5        for card in hand.cards:
6            if card.rank in ('J', 'Q', 'K'):
7                total += 10
8            elif card.rank == 'A':
9                total += 11
10                aces += 1
11            else:
12                total += int(card.rank)
13        while total > 21 and aces > 0:
14            total -= 10
15            aces -= 1
16        return total

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:

python
1class PokerRules(RuleEngine):
2    HAND_RANKINGS = [
3        "High Card", "One Pair", "Two Pair",
4        "Three of a Kind", "Straight", "Flush",
5        "Full House", "Four of a Kind",
6        "Straight Flush", "Royal Flush"
7    ]
8
9    def evaluate_hand(self, hand: Hand) -> int:
10        # Returns ranking index (0-9). Higher is better.
11        cards = hand.cards
12        if self._is_royal_flush(cards):
13            return 9
14        if self._is_straight_flush(cards):
15            return 8
16        # ... check each ranking in descending order
17        return 0  # high card

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:

python
1# BAD: game-specific logic leaking into Hand
2class Hand:
3    def get_value(self, game_type: str) -> int:
4        if game_type == "blackjack":
5            return self._sum_to_21()
6        elif game_type == "poker":
7            return self._rank_combination()
8        elif game_type == "uno":
9            return self._count_penalty_points()
10        # grows with every new game...

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:

  1. Create CrazyEightsRules implementing RuleEngine.
  2. Create CrazyEightsGame extending Game and overriding the template methods.
  3. 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.

Common Pitfall

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:

python
1class GameFactory:
2    _registry: dict[str, type[Game]] = {}
3
4    @classmethod
5    def register(cls, name: str, game_class: type[Game]):
6        cls._registry[name] = game_class
7
8    @classmethod
9    def create(cls, name: str, players: list[Player]) -> Game:
10        if name not in cls._registry:
11            raise ValueError(f"Unknown game: {name}")
12        return cls._registry[name](players)

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:

python
1# BAD: must modify for every new game
2def create_game(name: str) -> Game:
3    if name == "blackjack":
4        return BlackjackGame()
5    elif name == "poker":
6        return PokerGame()
7    # add more elif for each new game...

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.

Game lifecycle template method

Template Method in Action

The abstract Game.play() method defines the invariant lifecycle:

python
1class Game(ABC):
2    def play(self):
3        self.setup()
4        self.deal_cards()
5        while not self.is_game_over():
6            self.play_round()
7        self.determine_winner()

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:

python
1class BlackjackGame(Game):
2    def setup(self):
3        self.deck = DeckFactory.create("blackjack")
4        self.deck.shuffle()
5
6    def deal_cards(self):
7        for player in self.players:
8            player.hand.add(self.deck.deal())
9            player.hand.add(self.deck.deal())
10
11    def play_round(self):
12        for player in self.players:
13            while True:
14                action = player.choose_action(["hit", "stand"])
15                if action == "stand":
16                    break
17                player.hand.add(self.deck.deal())
18                if self.rule_engine.evaluate_hand(player.hand) > 21:
19                    break  # bust
20
21class PokerGame(Game):
22    def setup(self):
23        self.deck = DeckFactory.create("poker")
24        self.deck.shuffle()
25        self.pot = 0
26
27    def deal_cards(self):
28        for player in self.players:
29            for _ in range(5):
30                player.hand.add(self.deck.deal())
31
32    def play_round(self):
33        # betting rounds, card exchanges, etc.
34        pass

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.

python
1from enum import Enum
2
3class GameState(Enum):
4    NOT_STARTED = "not_started"
5    IN_PROGRESS = "in_progress"
6    FINISHED = "finished"
7
8class Game(ABC):
9    def __init__(self):
10        self.state = GameState.NOT_STARTED
11
12    def deal_cards(self):
13        if self.state != GameState.NOT_STARTED:
14            raise IllegalStateError(
15                "Can only deal in NOT_STARTED state"
16            )
17        self._do_deal()
18        self.state = GameState.IN_PROGRESS

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:

python
1def play_round(self):
2    for player in self.turn_order:
3        available_actions = self.rule_engine.get_available_actions(player)
4        action = player.choose_action(available_actions)
5        if self.rule_engine.is_valid_move(player, action):
6            self.execute_action(player, action)

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.

Key Insight

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:

  1. Subclass Game and override setup(), deal_cards(), play_round(), is_game_over(), and determine_winner().
  2. Implement a new RuleEngine for the game's rules.
  3. Optionally create a new deck type via DeckFactory if the game uses nonstandard cards.
  4. 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:

python
1class Game(ABC):
2    def play(self):
3        try:
4            self.setup()
5            self.deal_cards()
6            while not self.is_game_over():
7                self.play_round()
8            self.determine_winner()
9        except GameError as e:
10            self.state = GameState.FINISHED
11            self.handle_error(e)

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 editor...

Loading problem...

Loading editor...

Loading problem...

Loading editor...