Cracking The Coding Interview Together – Problem 7.1: Deck of Cards

Cracking The Coding Interview Together – Problem 7.1: Deck of Cards
Photo by Jack Hamilton / Unsplash

At some point in your engineering career, you will be asked to design a system that seems deceptively simple on the surface but quickly unravels into a web of edge cases when real-world requirements are introduced. Designing a deck of cards is the quintessential example of this phenomenon. If you ask a junior developer to build it, they might immediately reach for a flat array of 52 integers or a simple string representation like "As" for the Ace of Spades. But what happens when the business requirements shift?

Suddenly, you are asked to support Blackjack, where an Ace can be worth 1 or 11. Then, you need to support Texas Hold'em, where hands are evaluated based on complex poker hand rankings. Next, your product manager wants to support Uno, which doesn't even use standard suits. If your core data structures are rigid, you will find yourself rewriting the entire engine from scratch.

In this post, we are going to tackle this classic Object-Oriented Design (OOD) challenge together. Our shared goal is to design a robust, type-safe, and highly extensible generic deck of cards system that can seamlessly adapt to different card games without violating the Open-Closed Principle.

Problem Statement

Our objective is to design the data structures for a generic deck of cards. We must ensure that our design is flexible enough to be subclassed or adapted to implement specific card games. To demonstrate the viability of our architecture, we will also implement a concrete version of our system for a game of Blackjack.

The system must support the following core operations:

  • Initializing a standard deck of 52 cards.
  • Shuffling the deck randomly.
  • Dealing cards to hands.
  • Tracking the remaining cards in the deck.
  • Evaluating hand scores dynamically based on game-specific rules.

Analyze the Problem Together

Let's break down the core constraints and identify the architectural tension in this problem. The fundamental conflict lies in the distinction between a card's identity and its value.

A card's identity is immutable and universal: the Ace of Spades is always the Ace of Spades, regardless of whether you are playing Poker, Blackjack, or Hearts. However, its value is highly context-dependent. In Blackjack, an Ace is worth 1 or 11. In Poker, it is the highest card (14) or the lowest card (1) in a wheel straight. In Hearts, it carries zero points but is the highest rank in trick-taking.

If we hardcode the game-specific value logic inside our base Card class, we destroy reusability. If we make the base class too generic, we force the client code to perform unsafe type casting and manual calculations. Let's compare a few approaches:

Approach Pros Cons
Naive (Primitive Arrays)
Represent cards as integers 0-51.
Extremely fast; minimal memory footprint. Zero type safety; unreadable code; game logic is scattered everywhere.
Monolithic Card Class
One class containing properties for all possible games.
Simple to understand initially. Violates Single Responsibility Principle; bloated; hard to maintain.
Polymorphic Generics (Our Choice)
Abstract base classes with generic bounds.
Type-safe; highly extensible; clean separation of concerns. Slightly higher initial boilerplate.

To achieve clean separation of concerns, we will use Java Generics. We will define an abstract Card class and a generic Deck<T extends Card>. This allows the deck to manage the lifecycle of any card type while ensuring that game-specific logic remains encapsulated within concrete subclasses like BlackjackCard.

Questions for the Interviewer

In a real system design or OOD interview, a senior engineer never starts coding immediately. You must clarify the scope. Here are the questions we should ask our interviewer:

  • Q1: Do we need to support multiple decks in a single game?
    Answer: Yes, games like Blackjack often use a "shoe" containing 6 to 8 decks shuffled together. Our design should accommodate this.
  • Q2: Are Jokers included in the generic deck?
    Answer: Standard decks can have them, but they are game-dependent. We should make our base deck initialization customizable so games can opt-in to Jokers.
  • Q3: Do we need to handle card state, such as face-up versus face-down?
    Answer: Yes, this is a common requirement for UI rendering and game state validation. We should track this state on the card or hand level.

Think About the Solution

Let's map out our high-level architecture. We will design the following components:

  • Suit (Enum): Represents the four standard suits (Clubs, Diamonds, Hearts, Spades).
  • Card (Abstract Class): Holds the immutable properties of a card: its Suit and its raw faceValue (1 to 13). It also tracks whether the card is currently face-up.
  • Deck (Generic Class): Manages a collection of cards. It provides methods to shuffle, deal, and track remaining cards. By using a generic parameter T extends Card, we can reuse the deck logic for any card type.
  • Hand (Generic Class): Represents a collection of cards held by a player. It provides basic operations like adding cards and clearing the hand.
  • BlackjackCard (Concrete Class): Extends Card. It implements the specific scoring rules of Blackjack (face cards are worth 10, Aces are worth 11 or 1).
  • BlackjackHand (Concrete Class): Extends Hand<BlackjackCard>. It implements the complex scoring algorithm to calculate the best possible hand value, dynamically adjusting Aces.

We must also handle critical edge cases, such as attempting to draw from an empty deck, and ensure our shuffling algorithm is statistically unbiased using the Fisher-Yates algorithm.

Write Code and Explain

Let's implement this elegant, production-ready design in Java.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

// ========================================== 
// CORE GENERIC STRUCTURES
// ========================================== 

public enum Suit {
    CLUB(0), DIAMOND(1), HEART(2), SPADE(3);
    
    private final int value;
    Suit(int value) { this.value = value; }
    public int getValue() { return value; }
}

public abstract class Card {
    private final Suit suit;
    // faceValue ranges from 1 (Ace) to 13 (King)
    protected final int faceValue;
    private boolean isFaceUp;

    public Card(Suit suit, int faceValue) {
        if (faceValue < 1 || faceValue > 13) {
            throw new IllegalArgumentException("Face value must be between 1 and 13");
        }
        this.suit = suit;
        this.faceValue = faceValue;
        this.isFaceUp = false;
    }

    public Suit getSuit() { return suit; }
    public int getFaceValue() { return faceValue; }
    public boolean isFaceUp() { return isFaceUp; }
    public void setFaceUp(boolean faceUp) { this.isFaceUp = faceUp; }

    public abstract int value();
}

public class Deck<T extends Card> {
    private final List<T> cards;
    private int dealtIndex = 0; // Marks the boundary between dealt and undealt cards

    public Deck(List<T> cards) {
        this.cards = new ArrayList<>(cards);
    }

    public void shuffle() {
        Random rand = new Random();
        // Fisher-Yates Shuffle for unbiased O(N) shuffling
        for (int i = cards.size() - 1; i > dealtIndex; i--) {
            int j = dealtIndex + rand.nextInt(i - dealtIndex + 1);
            T temp = cards.get(i);
            cards.set(i, cards.get(j));
            cards.set(j, temp);
        }
    }

    public int remainingCards() {
        return cards.size() - dealtIndex;
    }

    public T dealCard() {
        if (remainingCards() == 0) {
            throw new IllegalStateException("No cards remaining in the deck");
        }
        T card = cards.get(dealtIndex);
        dealtIndex++;
        return card;
    }

    public List<T> dealHand(int numCards) {
        if (numCards > remainingCards()) {
            throw new IllegalArgumentException("Not enough cards left to deal a hand of size " + numCards);
        }
        List<T> hand = new ArrayList<>();
        for (int i = 0; i < numCards; i++) {
            hand.add(dealCard());
        }
        return hand;
    }

    public void reset() {
        dealtIndex = 0;
    }
}

public abstract class Hand<T extends Card> {
    protected final List<T> cards = new ArrayList<>();

    public void addCard(T card) {
        cards.add(card);
    }

    public void clear() {
        cards.clear();
    }

    public List<T> getCards() {
        return Collections.unmodifiableList(cards);
    }

    public abstract int score();
}

// ========================================== 
// BLACKJACK SPECIFIC IMPLEMENTATION
// ========================================== 

public class BlackjackCard extends Card {
    public BlackjackCard(Suit suit, int faceValue) {
        super(suit, faceValue);
    }

    @Override
    public int value() {
        if (isAce()) return 1; // Base value for Ace; hand logic handles 11
        if (isFaceCard()) return 10;
        return faceValue;
    }

    public boolean isAce() {
        return faceValue == 1;
    }

    public boolean isFaceCard() {
        return faceValue >= 11 && faceValue <= 13;
    }
}

public class BlackjackHand extends Hand<BlackjackCard> {
    @Override
    public int score() {
        int minScore = 0;
        int maxScore = 0;
        int aceCount = 0;

        for (BlackjackCard card : cards) {
            int val = card.value();
            minScore += val;
            if (card.isAce()) {
                aceCount++;
            }
        }

        // Calculate max score assuming one Ace is valued at 11
        if (aceCount > 0) {
            maxScore = minScore + 10;
        } else {
            maxScore = minScore;
        }

        // If max score is valid, return it; otherwise, fall back to min score
        if (maxScore <= 21) {
            return maxScore;
        }
        return minScore;
    }

    public boolean isBusted() {
        return score() > 21;
    }

    public boolean isBlackjack() {
        return cards.size() == 2 && score() == 21;
    }
}

Code Walkthrough

Let's trace how this design functions under the hood:

  1. The Dealt Index Optimization: Instead of physically removing elements from our ArrayList when dealing cards (which is an O(N) operation due to array shifting), we maintain a dealtIndex pointer. Dealing a card is as simple as returning the card at dealtIndex and incrementing the pointer. This makes dealing an O(1) operation.
  2. Fisher-Yates Shuffling: Our shuffle() method only shuffles the remaining, undealt cards in the deck. It swaps elements in-place, ensuring an unbiased, uniform distribution in O(N) time.
  3. Blackjack Hand Scoring: The score() method in BlackjackHand dynamically evaluates the value of Aces. It first sums up the minimum possible score (treating all Aces as 1). If there is at least one Ace, it checks if treating one Ace as 11 keeps the score under or equal to 21. If so, it returns that optimized score. This beautifully encapsulates game rules within the hand class, keeping the card class simple.

Time & Space Complexity

Let's analyze the efficiency of our core operations to ensure they meet production standards:

  • Shuffling (shuffle): O(N) time complexity, where N is the number of cards in the deck. We iterate through the list once and perform constant-time swaps. Space complexity is O(1) because the shuffle is performed in-place.
  • Dealing (dealCard): O(1) time complexity. Thanks to our dealtIndex pointer, we avoid shifting elements in the array. Space complexity is O(1).
  • Scoring (score in BlackjackHand): O(M) time complexity, where M is the number of cards in the hand. Since a hand rarely exceeds 10 cards in Blackjack, this is practically instantaneous. Space complexity is O(1).

Why This Problem Matters (The Senior Perspective)

When an interviewer asks you to design a deck of cards, they aren't testing your ability to write a random number generator. They are evaluating your grasp of Domain-Driven Design (DDD) and your ability to write clean, maintainable code.

Here are three key architectural patterns we demonstrated in our solution:

Immutability

Our Card class is designed with immutability in mind. The suit and faceValue are marked as final. In a real-world multiplayer game server, cards are passed around between threads, network sockets, and rendering engines. Making them immutable prevents accidental state corruption and eliminates a massive class of concurrency bugs.

Separation of Concerns

Notice that the Deck doesn't know anything about Blackjack rules, and the Card doesn't know how to calculate hand scores. The deck is purely a collection manager. The card is a data carrier. The hand is the rule evaluator. This separation makes it incredibly easy to add a new game—say, Texas Hold'em—by simply creating a PokerHand class without touching a single line of code in Deck or Suit.

Thread Safety in Production

In a production environment, multiple players might interact with the same game state simultaneously. While our basic implementation is not thread-safe, we can easily make it thread-safe by wrapping our deck operations in synchronized blocks or by using a thread-safe collection like CopyOnWriteArrayList or protecting state transitions with a reentrant lock.

Final Thoughts

Object-Oriented Design is all about finding the right balance between simplicity and extensibility. By leveraging Java Generics and separating the identity of a card from its game-specific value, we built a deck of cards system that is clean, type-safe, and ready for any card game you throw at it.

How would you extend this design to support a game like Uno, where cards have colors instead of suits and special action cards? Let's discuss in the comments below!

Until next time, happy coding!