Two-player card game simulation

0
Remix

You are building the engine for a simple two-player card game. Players are named "A" and "B". The deck is a list of distinct integers, and a higher integer beats a lower one.

Implement a CardGame class:

  • CardGame(deck, seed=None, shuffle=True): shuffle the deck (use seed so a game can be replayed), then deal it one card at a time, alternating players, starting with A. If the deck has an odd number of cards, the last card is not dealt.
  • play_round(): each player plays the top card of their hand. The player with the higher card puts both cards in their scoring pile. Return what was played and who won the round.
  • play() -> str: play rounds until the hands are empty. Return "A", "B", or "tie".
  • scores() -> dict: each player's score, one point per card in their scoring pile.

Model the game with sensible types (at least a player type). Keep the code easy to extend. The interviewer will add rules once the base game works.

Constraints

  • 0 <= len(deck) <= 10^5; card values are distinct integers.
  • play_round() after the game has ended is an error.

Example 1 (no shuffle)

deck = [5, 2, 1, 8, 7, 3]
A's hand: [5, 1, 7]   B's hand: [2, 8, 3]
Round 1: 5 vs 2 -> A    Round 2: 1 vs 8 -> B    Round 3: 7 vs 3 -> A
scores() == {"A": 4, "B": 2}; play() == "A"

Example 2 (no shuffle)

deck = [9, 1, 2, 8]
Round 1: 9 vs 1 -> A    Round 2: 2 vs 8 -> B
scores() == {"A": 2, "B": 2}; play() == "tie"

Likely follow-ups: allow repeated card values (tied rounds), support more than two players, and the War variant, where won cards go back into the winner's hand and the game may never end.

Reconstructed from the reported question; details may differ from what interviewers ask.

AmazonCodingMedium

What L5 looks like

  • Makes the shuffle deterministic in tests (an injected seed or deck) and keeps each method small.
  • Expects ties or duplicates before being asked, and designs the pot or tie rule so part 2 is a small change.
  • States the card-conservation invariant and the O(N) total cost, and adds N players with little rework.
Interviewer expectations for every level ↓

Approach

This is an object-design question disguised as a simulation. The loop is trivial, so most of the score comes from choosing clean types, keeping the randomness testable, and handling the edge cases the interviewer will add later.

Model the nouns.

  • Player: a name, a hand (a deque, because cards come off the top), and a won scoring pile. The score is the size of the pile, one point per card.
  • CardGame: owns the players, deals the deck, plays rounds, and reports scores. It also keeps a pot for cards from tied rounds. A tie cannot happen with unique cards, but adding the pot now costs nothing.

Deal. Shuffle with a random.Random(seed) that the game owns. Do not use the global random, so tests can replay a game. Tests can also turn shuffling off and pass a fixed order. Deal round-robin: card i goes to player i % n. If the deck does not divide evenly, leave the extra cards out so every player has the same number of turns. State this choice out loud.

Round. Each player takes the top card from their hand. All the played cards go into the pot. If exactly one player played the highest card, that player takes the whole pot. With unique integers this always happens, so the winner gets both cards and 2 points.

End. The game ends when any hand is empty. With an even deal, all hands empty together. The winner has the highest score. If several players share the top score, the result is "tie".

A brute-force or "just lists" version (two halves of the list, zip them, count comparisons) gives the right answer for part 1. It falls apart on the follow-ups (ties, more players, per-round history), so lead with the small class design. You can mention that the one-liner gives the same result.

Invariants

  • Every dealt card is in exactly one place: a hand, a scoring pile, or the pot. So sum(scores) + len(pot) + cards in hands == dealt.
  • All hands have equal length after every round.
  • The pot is empty after any round that has a unique high card.

Code

from __future__ import annotations

import random
from collections import deque
from dataclasses import dataclass, field
from typing import Deque, List, Optional, Sequence


@dataclass
class Player:
    name: str
    hand: Deque[int] = field(default_factory=deque)
    won: List[int] = field(default_factory=list)   # scoring pile

    @property
    def score(self) -> int:
        return len(self.won)                        # 1 point per card


@dataclass
class RoundResult:
    played: dict[str, int]
    winner: Optional[str]                           # None means a tie
    pot_size: int


class CardGame:
    def __init__(self, deck: Sequence[int], names: Sequence[str] = ("A", "B"),
                 seed: Optional[int] = None, shuffle: bool = True) -> None:
        if len(names) < 2:
            raise ValueError("need at least two players")
        cards = list(deck)
        if shuffle:
            random.Random(seed).shuffle(cards)
        self.players = [Player(n) for n in names]
        usable = len(cards) - len(cards) % len(self.players)
        for i, card in enumerate(cards[:usable]):   # deal round-robin
            self.players[i % len(self.players)].hand.append(card)
        self.pot: List[int] = []                    # cards from tied rounds
        self.log: List[RoundResult] = []

    def is_over(self) -> bool:
        return any(not p.hand for p in self.players)

    def play_round(self) -> RoundResult:
        if self.is_over():
            raise RuntimeError("game is over")
        played = {p.name: p.hand.popleft() for p in self.players}
        self.pot.extend(played.values())
        best = max(played.values())
        leaders = [p for p in self.players if played[p.name] == best]
        winner: Optional[str] = None
        if len(leaders) == 1:                       # unique high card takes the pot
            winner = leaders[0].name
            leaders[0].won.extend(self.pot)
            self.pot = []
        result = RoundResult(played, winner, len(self.pot))
        self.log.append(result)
        return result

    def play(self) -> str:
        while not self.is_over():
            self.play_round()
        return self.winner()

    def scores(self) -> dict[str, int]:
        return {p.name: p.score for p in self.players}

    def winner(self) -> str:
        """Name of the top scorer, or 'tie' if several share the top score."""
        s = self.scores()
        top = max(s.values())
        best = [n for n, v in s.items() if v == top]
        return best[0] if len(best) == 1 else "tie"

Complexity

  • Construction: O(N) to shuffle and deal N cards.
  • play_round: O(P) for P players (one pop, one max, one scan). Moving the pot costs O(pot size), and each card moves into a pile at most once, so this adds O(N) over the whole game.
  • play: O(N) in total, which is N/P rounds of O(P) each.
  • scores / winner: O(P).
  • Space: O(N) for hands, piles and the pot, plus O(N/P) for the round log.

Test cases

  • CardGame([5, 2, 1, 8, 7, 3], shuffle=False): A holds [5, 1, 7] and B holds [2, 8, 3]. The rounds go A, B, A. Scores {"A": 4, "B": 2}, winner "A".
  • CardGame([9, 1, 2, 8], shuffle=False): A wins with 9, then B wins with 8. Scores {"A": 2, "B": 2}, winner "tie".
  • Odd deck [1, 2, 3, 4, 5] without shuffling: the 5 is not dealt. B wins both rounds. Scores {"A": 0, "B": 4}.
  • Empty deck: the game is over at once. Scores are 0–0 and the result is "tie".
  • Duplicates (follow-up) [4, 4, 3, 1]: round 1 ties 4–4 and the pot holds 2 cards. A wins round 2 with 3 vs 1 and takes 4 cards.
  • Three players [3, 9, 5, 2, 1, 4] with names A, B, C: B takes round 1 with 9 and C takes round 2 with 4. Scores {"A": 0, "B": 3, "C": 3}, result "tie".
  • Same seed on a 52-card deck: two games produce the same winner and scores, and the scores add up to 52.
  • Calling play_round after the game is over raises RuntimeError.

Follow-ups

  1. Cards are not unique. The pot already handles this. A tied round leaves its cards in the pot, and the next unique winner takes them. Cards still in the pot when the hands run out go to nobody, or are split, depending on the rule the interviewer picks. Another rule is a "war": each tied player puts down k face-down cards and then plays again. In that case, change play_round to loop over only the tied players, and handle a player who runs out of cards in the middle of a war.
  2. Winner puts won cards at the bottom of their hand (classic War). The game ends when one player has every card. Now it may never end, so add a cap on the number of rounds, or store a hash of (tuple(hand_A), tuple(hand_B)) in a seen-set and stop when a state repeats. That costs O(N) per round.
  3. N players, or players joining by name. The design already loops over self.players. Discuss how to deal an uneven deck (drop the extra cards, or give them out in order) and how to compare when several players share the high card.
  4. Pluggable rules or strategies. Pass in a compare(cards) -> winners function or a ScoringRule (for example, points equal to the card's value instead of 1 per card). Pass in a choose_card(hand) strategy so a player can play any card from the hand, not only the top one. That is the strategy pattern, and the hand becomes a list or a sorted container.
  5. Testability and replay. Inject the RNG or the deck, keep the per-round log, and add a state() snapshot. Then a UI or a server can render each round, and a seeded game can be replayed exactly.

Interviewer expectations

Mid-level (L4)

  • Gives a correct simulation with a clear deal, compare and score step, and gets the winner and tie right.
  • Has at least a Player concept, not two loose lists, and writes the code without help.
  • Walks through one small example by hand and asks about an odd-sized deck.

Senior (L5)

  • Makes the shuffle deterministic in tests (an injected seed or deck) and keeps each method small.
  • Expects ties or duplicates before being asked, and designs the pot or tie rule so part 2 is a small change.
  • States the card-conservation invariant and the O(N) total cost, and adds N players with little rework.

Staff (L6+)

  • Separates the game engine from the rules (comparison, scoring, end condition) and from player strategy. Explains which follow-ups each extension point handles.
  • Notices that the War variant may never end, and proposes cycle detection with its cost.
  • Talks about how the design would be used for real: event log and replay, a state snapshot for a UI or service, and validating input such as decks and player names.

Make it yours

Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups.