0

Three-card showdown between two players

by · 1 day ago· ⑂ 0 remixes

Question: Three-card showdown between two players

Companies: OpenAI

Difficulty: Easy

Problem statement

Design a two-player card game that uses a standard 52-card deck.

Each card has a rank and a suit. From lowest to highest, the ranks are 2 3 4 5 6 7 8 9 10 J Q K A. The suits are C D H S. Suits never affect who wins.

Dealing. Draw cards from the top of the deck one at a time. Give them to player 1, then player 2, then player 1, and so on, until each player has three cards. Record every card dealt and the player who received it.

Hand categories, from strongest to weakest:

  1. Three of a kind: all three cards have the same rank.
  2. Pair: exactly two cards have the same rank.
  3. High card: all three ranks are different.

The hand in the stronger category wins. If both hands are in the same category:

  • Three of a kind: the higher rank wins.
  • Pair: compare the ranks of the pairs. If they are equal, compare the third card (the kicker).
  • High card: compare the highest cards, then the second-highest, then the lowest.

If every compared rank is equal, the game is a draw.

Implement

  • A deck that can be built from an exact list of cards (for tests) or shuffled, with an optional seed.
  • deal(): deals the six cards as described and records the deal order.
  • winner() -> int: returns 1 or 2 for the winning player, or 0 for a draw.
  • report() -> str: shows the deal log, both hands with their categories, and the result.

Example 1

Deck order (top first): 9C, QH, 9S, 4D, 2H, 4C

  • Player 1 gets 9C 9S 2H: a pair of 9s.
  • Player 2 gets QH 4D 4C: a pair of 4s.
  • Result: player 1 wins, because the pair of 9s beats the pair of 4s. Player 2's queen does not matter.

Example 2

  • Player 1 holds KS 8D 3C and player 2 holds KH 8C 3S.
  • Both hands are high card with the same ranks, K, 8 and 3, so the result is 0 (draw).

Constraints

  • Each game deals exactly six cards from one deck, and no card is dealt twice.
  • If the deck runs out during the deal, raise an error.

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

Approach

The game has three separate jobs, and keeping them apart is what makes it easy to extend:

  1. Deck: hands out cards in a fixed order. Tests pass in an exact order; real games build all 52 cards and shuffle them with an optional seed.
  2. Dealing: draw one card at a time and give it to player 1, player 2, player 1, and so on, until each hand has three cards. Write each (player, card) to a deal log as you go.
  3. Scoring: turn each hand into a strength key that Python can compare as a tuple. Comparing the two keys gives the winner, and equal keys mean a draw.

Brute force. The obvious first version is a chain of if branches: check both hands for three of a kind, then for pairs, then compare pair ranks, then kickers, then high cards one by one. It works, but each category needs its own tie-break code, so it is easy to get wrong.

Better: one tuple key. Count how many cards share each rank. Sort the (rank, count) groups by count first and rank second, both descending. The key is (category, ranks in that order):

  • Three of a kind 7 7 7 gives (3, (7,)).
  • Pair K K 2 gives (2, (13, 2)). The pair rank comes first, then the kicker.
  • High card A Q 5 gives (1, (14, 12, 5)).

Tuples compare element by element, so this one comparison applies every rule: category first, then the triple or pair rank, then the kicker, then high cards from highest to lowest. Suits are never part of the key, so they cannot affect the result. Two hands draw only when their keys are equal.

Invariants

  • After deal(), each player has exactly three cards and the deal log has six entries in the order 1, 2, 1, 2, 1, 2.
  • A card is drawn from the deck at most once, so no card appears in both hands.
  • hand_key depends only on ranks, never on suits or card order.

Code

from __future__ import annotations

import random
from collections import Counter
from dataclasses import dataclass
from enum import IntEnum

RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
SUITS = ["C", "D", "H", "S"]
RANK_VALUE = {r: i for i, r in enumerate(RANKS, start=2)}  # "2" -> 2 ... "A" -> 14
HAND_SIZE = 3


class Category(IntEnum):
    HIGH_CARD = 1
    PAIR = 2
    THREE_OF_A_KIND = 3


@dataclass(frozen=True)
class Card:
    rank: str
    suit: str

    def __post_init__(self) -> None:
        if self.rank not in RANK_VALUE or self.suit not in SUITS:
            raise ValueError(f"bad card {self.rank}{self.suit}")

    def __str__(self) -> str:
        return f"{self.rank}{self.suit}"


class Deck:
    def __init__(self, cards: list[Card] | None = None, seed: int | None = None) -> None:
        if cards is None:
            cards = [Card(r, s) for s in SUITS for r in RANKS]
            random.Random(seed).shuffle(cards)
        self._cards = list(cards)  # top of the deck is index 0
        self._next = 0

    def draw(self) -> Card:
        if self._next >= len(self._cards):
            raise RuntimeError("deck is empty")
        card = self._cards[self._next]
        self._next += 1
        return card


def hand_key(hand: list[Card]) -> tuple[int, tuple[int, ...]]:
    """Comparable strength: (category, tie-break ranks, most important first)."""
    if len(hand) != HAND_SIZE:
        raise ValueError("hand must have exactly three cards")
    counts = Counter(RANK_VALUE[c.rank] for c in hand)
    # Group size first, then rank: the triple/pair rank leads, kickers follow high to low.
    ordered = sorted(counts.items(), key=lambda kv: (kv[1], kv[0]), reverse=True)
    top_count = ordered[0][1]
    category = {3: Category.THREE_OF_A_KIND, 2: Category.PAIR, 1: Category.HIGH_CARD}[top_count]
    return category, tuple(rank for rank, _ in ordered)


class CardGame:
    PLAYERS = (1, 2)

    def __init__(self, deck: Deck) -> None:
        self.deck = deck
        self.hands: dict[int, list[Card]] = {p: [] for p in self.PLAYERS}
        self.deal_log: list[tuple[int, Card]] = []  # (player, card) in deal order

    def deal(self) -> None:
        for _ in range(HAND_SIZE):
            for player in self.PLAYERS:  # alternate 1, 2, 1, 2, ...
                card = self.deck.draw()
                self.hands[player].append(card)
                self.deal_log.append((player, card))

    def winner(self) -> int:
        """1 or 2 for the winning player, 0 for a draw."""
        k1, k2 = hand_key(self.hands[1]), hand_key(self.hands[2])
        if k1 == k2:
            return 0
        return 1 if k1 > k2 else 2

    def report(self) -> str:
        lines = [f"P{p} <- {c}" for p, c in self.deal_log]
        for p in self.PLAYERS:
            cat, _ = hand_key(self.hands[p])
            cards = " ".join(map(str, self.hands[p]))
            lines.append(f"Player {p}: {cards} ({cat.name.replace('_', ' ').lower()})")
        w = self.winner()
        lines.append("Result: draw" if w == 0 else f"Result: player {w} wins")
        return "\n".join(lines)


def play(cards: list[Card]) -> CardGame:
    game = CardGame(Deck(cards))
    game.deal()
    return game

play(cards) deals a fixed card order, which keeps tests deterministic. CardGame(Deck(seed=42)) plays a shuffled game.

Complexity

  • Deck.__init__: O(52) time and space to build and shuffle the deck.
  • Deck.draw: O(1). It moves an index forward instead of popping from the front of a list.
  • deal: O(H·P) for H cards per hand and P players. That is 6 draws here.
  • hand_key: O(H log H) to count and sort. With H = 3 this is constant.
  • winner: O(H log H). report: O(H·P) time and output size.
  • Space: O(H·P) for the hands and the deal log.

Test cases

Each line gives player 1's hand, then player 2's hand. Cards are dealt alternately: P1[0], P2[0], P1[1], and so on.

  • 7H 7S 7D vs AH AS KD: player 1 wins. Three of a kind beats a pair.
  • 2H 2S 2D vs AC KD QH: player 1 wins. The lowest three of a kind still beats the best high card.
  • JH JS JD vs QC QD QH: player 2 wins. Both have three of a kind, so the higher rank wins.
  • 4H 4S 9D vs 3C 3D AH: player 1 wins. The pair rank counts before the kicker, even when the other kicker is an ace.
  • 4H 4S 2D vs 4C 4D 3H: player 2 wins. Both have a pair of 4s, so the kicker decides (3 > 2).
  • AH KS 2D vs AC QD JH: player 1 wins. High card: aces tie, then K beats Q.
  • 5H 3S 2D vs 5C 4D 2H: player 2 wins. The second-highest card decides.
  • 10H 9S 2D vs 10C 9D 2H: draw. The ranks are the same and only the suits differ.
  • The deal log for any game lists players in the order [1, 2, 1, 2, 1, 2].
  • hand_key for KH 2S KD is (PAIR, (13, 2)). The pair goes first even though 2 is the middle card.
  • A deck with fewer than six cards raises RuntimeError("deck is empty") during deal().
  • A card such as Card("1", "H") or Card("2", "X") raises ValueError.

Follow-ups

  • Add straights and flushes (three-card poker). Add STRAIGHT, FLUSH and STRAIGHT_FLUSH to the enum in the right order. In three-card poker a straight beats a flush. Detect a straight from three distinct sorted ranks. Treat A 2 3 as a special case and score it as high card 3. hand_key still returns (category, ranks), so winner does not change.
  • N players and bigger hands. Make PLAYERS and HAND_SIZE constructor parameters. Find the winner with max over the keys, and report every player tied at the top as a split. The deal loop already alternates in round-robin order.
  • Several rounds and a scoreboard. Keep one deck across rounds and deal until fewer than P·H cards remain, or reshuffle. Track wins per player. Clear the hands each round and keep the full log with a round number.
  • Replay and audit. The deal log is enough to rebuild any game. Add CardGame.from_log(log) and check that replaying it gives the same hands and result. This matters when the deck is shuffled with a seed.
  • Wild cards or a different rank order. Pass the rank order in as data, for example ace-low. For wild cards, give the wild card each possible rank and keep the best hand_key. With 13 ranks per wild card this is still cheap.

Interviewer expectations

Mid-level (L4)

  • Code that runs: deals alternately, records who got each card, and prints both hands and the result.
  • Correct category order and tie-breaks, including the pair-then-kicker rule and draws.
  • A few hand-written tests, including a kicker tie and a suit-only draw.

Senior (L5)

  • A clean split between deck, dealing and scoring, so tests can pass in a fixed deck instead of mocking randomness.
  • One comparable strength key instead of nested ifs. Can explain why a tuple comparison applies every tie-break rule.
  • Handles an empty deck and invalid cards. Uses an index or iterator for draw, not pop(0).

Staff (L6+)

  • Plans for extensions early: more categories, N players, splits and multiple rounds fit without rewriting the comparison.
  • Discusses how to keep the game reproducible and auditable: a seeded shuffle, an append-only deal log and replay.
  • Talks about testing strategy, such as property tests checking that hand strength is the same for any card order or suit change, and then moves on to the follow-ups on their own.

Sign in to comment, vote or remix.