Deck/Card/Player/Dealer/GameRules classes, shuffling and dealing as discrete testable methods, and GameRules as an injected strategy for supporting multiple card games.
Published September 23, 2026
enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES }
enum Rank { TWO, THREE, /* ... */ KING, ACE }
class Card { Suit suit; Rank rank; }
class Deck {
private final List<Card> cards = new ArrayList<>();
Deck() { for (Suit s : Suit.values()) for (Rank r : Rank.values()) cards.add(new Card(s, r)); } // standard 52-card build
void shuffle() { Collections.shuffle(cards); }
Card dealOne() { return cards.remove(cards.size() - 1); } // deal from the 'top' (end of list)
}
class Player { String name; List<Card> hand = new ArrayList<>(); }
class Dealer {
Deck deck;
void dealHands(List<Player> players, int cardsPerPlayer) {
for (int i = 0; i < cardsPerPlayer; i++) {
for (Player p : players) p.hand.add(deck.dealOne());
}
}
}
Separating shuffle() and dealOne() into their own single-purpose methods (rather than one monolithic "setup the game" method) is what makes each independently testable: dealOne() can be unit-tested by asserting the deck shrinks by exactly one card and the returned card is no longer in the deck, with zero dependency on randomness — shuffle()'s randomness can be tested separately (e.g. asserting the deck still contains all 52 unique cards after shuffling, without asserting a specific order). Bundling shuffle-and-deal into one method would force every test of dealing logic to also account for shuffle's randomness, an avoidable coupling.
interface GameRules {
int cardsPerPlayer();
boolean isValidPlay(Card card, List<Card> currentHand, GameState state);
Player determineWinner(List<Player> players, GameState state);
}
class PokerRules implements GameRules { /* 5 (or 2, for Hold'em) cards per player, hand-ranking win logic */ }
class BlackjackRules implements GameRules { /* 2 cards per player, closest-to-21-without-busting win logic */ }
class CardGame {
private final Dealer dealer;
private final GameRules rules; // injected — CardGame has zero knowledge of poker vs blackjack specifics
void start(List<Player> players) {
dealer.dealHands(players, rules.cardsPerPlayer());
}
}
This is Strategy pattern applied to "what game is actually being played" — Deck, Card, Dealer, and CardGame's own orchestration logic are entirely game-agnostic; every game-specific rule (how many cards to deal, what counts as a valid play, how a winner is determined) lives behind the GameRules interface. Supporting a new card game means writing one new GameRules implementation, with zero changes to the shared framework — directly reinforcing the same pattern-recognition skill Strategy Pattern and Tic-Tac-Toe / Board Game Design's WinningStrategy build.
A card game framework lives or dies on the fairness of its shuffle. The correct algorithm is Fisher–Yates: walk the deck from the end, and swap each position with a uniformly random position at or before it:
final class Deck {
private final List<Card> cards;
private final RandomGenerator random; // injected: seeded in tests, secure in production
Deck(List<Card> cards, RandomGenerator random) { this.cards = new ArrayList<>(cards); this.random = random; }
static Deck standard(RandomGenerator random) {
List<Card> all = new ArrayList<>(52);
for (Suit s : Suit.values()) for (Rank r : Rank.values()) all.add(new Card(s, r));
return new Deck(all, random);
}
void shuffle() {
for (int i = cards.size() - 1; i > 0; i--) {
int j = random.nextInt(i + 1); // 0..i inclusive
Collections.swap(cards, i, j);
}
}
Card draw() {
if (cards.isEmpty()) throw new IllegalStateException("Deck is empty");
return cards.remove(cards.size() - 1); // O(1) from the end of an ArrayList
}
int remaining() { return cards.size(); }
}
random.nextInt(cards.size()) for every position (swapping with any card). It looks random, but produces some orderings more often than others. Fisher–Yates produces every one of the 52! orderings with equal probability. Collections.shuffle(list, random) implements the same algorithm.new SplittableRandom(42)) to get reproducible deals and verify game logic. A real-money game uses SecureRandom, because a predictable generator lets players reconstruct the deck.Card an immutable value (a record Card(Suit suit, Rank rank)), so cards can be shared and compared safely.Most card games need "which hand wins?". Model the evaluation result as a comparable value, so the game logic is just max():
enum Category { HIGH_CARD, PAIR, TWO_PAIR, THREE_OF_A_KIND, STRAIGHT, FLUSH, FULL_HOUSE, FOUR_OF_A_KIND, STRAIGHT_FLUSH }
record HandValue(Category category, List<Integer> tiebreakers) implements Comparable<HandValue> {
public int compareTo(HandValue o) {
int c = category.compareTo(o.category);
if (c != 0) return c;
for (int i = 0; i < Math.min(tiebreakers.size(), o.tiebreakers.size()); i++) {
c = Integer.compare(tiebreakers.get(i), o.tiebreakers.get(i));
if (c != 0) return c;
}
return 0; // a genuine tie: split the pot
}
}
final class PokerHandEvaluator {
HandValue evaluate(List<Card> five) {
Map<Rank, Long> counts = five.stream().collect(Collectors.groupingBy(Card::rank, Collectors.counting()));
boolean flush = five.stream().map(Card::suit).distinct().count() == 1;
List<Integer> ranks = five.stream().map(c -> c.rank().ordinal()).sorted(Comparator.reverseOrder()).toList();
boolean straight = counts.size() == 5 && ranks.get(0) - ranks.get(4) == 4; // (ace-low straight omitted for brevity)
// group ranks by (count desc, rank desc): e.g. full house → [trips rank, pair rank]
List<Integer> byGroup = counts.entrySet().stream()
.sorted(Map.Entry.<Rank, Long>comparingByValue().reversed()
.thenComparing(Map.Entry.comparingByKey(Comparator.reverseOrder())))
.map(e -> e.getKey().ordinal()).toList();
List<Long> shape = counts.values().stream().sorted(Comparator.reverseOrder()).toList();
if (straight && flush) return new HandValue(Category.STRAIGHT_FLUSH, List.of(ranks.get(0)));
if (shape.equals(List.of(4L, 1L))) return new HandValue(Category.FOUR_OF_A_KIND, byGroup);
if (shape.equals(List.of(3L, 2L))) return new HandValue(Category.FULL_HOUSE, byGroup);
if (flush) return new HandValue(Category.FLUSH, ranks);
if (straight) return new HandValue(Category.STRAIGHT, List.of(ranks.get(0)));
if (shape.get(0) == 3) return new HandValue(Category.THREE_OF_A_KIND, byGroup);
if (shape.equals(List.of(2L, 2L, 1L))) return new HandValue(Category.TWO_PAIR, byGroup);
if (shape.get(0) == 2) return new HandValue(Category.PAIR, byGroup);
return new HandValue(Category.HIGH_CARD, ranks);
}
}
The evaluator is game-specific, so it belongs behind GameRules (Poker supplies this one, and Blackjack supplies a points calculator where aces count as 1 or 11). The framework's CardGame only ever asks the rules object "who won?".
final class CardGame {
private final GameRules rules; private final Deck deck; private final List<Player> players;
GameResult play() {
deck.shuffle();
rules.deal(deck, players); // how many cards, face up or down
while (!rules.isOver(players, deck)) {
for (Player p : players) {
if (!rules.isActive(p)) continue; // folded, bust, already finished
Action a = p.chooseAction(rules.legalActions(p)); // a human UI or a bot strategy
rules.apply(p, a, deck);
if (rules.isOver(players, deck)) break;
}
}
return rules.result(players);
}
}
legalActions is the key extension point. The framework never hard-codes "hit/stand" or "call/raise/fold", so a new game is a new GameRules implementation, and players (human or bot) choose only among actions the rules allow.
Q: Why deal from the end of the list (cards.remove(cards.size() - 1)) rather than the front? A: Removing from the end of an ArrayList is O(1) (no shifting of remaining elements); removing from the front is O(n) (every remaining card shifts down one position) — a minor but easy, free optimization once you're aware ArrayList's removal cost depends on position.
Q: How would isValidPlay() differ meaningfully between Poker and a game like Rummy? A: Poker's validity is mostly about betting/turn structure rather than card legality (any card in hand can generally be played per the betting round); Rummy's validity depends on forming actual valid melds/sequences from the hand — this difference is exactly what's isolated behind the GameRules interface, so CardGame's orchestration code never needs to know which validity model applies.
Q: Should Deck support more than one standard 52-card deck (e.g., games using multiple decks, or jokers)? A: Worth surfacing as a scoping question early (per the Object-Oriented Design Refresher's scoping guidance) — a small change to Deck's constructor (accepting a deck count, optionally including jokers as a configurable extra) accommodates this without restructuring the class, as long as it's anticipated rather than hard-coded to exactly 52 cards.
Q: Does GameState need to be its own class, or could game-specific state just live on Player/CardGame directly? A: A separate GameState class (holding things like the current pot in Poker, or the discard pile in Rummy) keeps game-specific mutable state cleanly separated from the game-agnostic Player/Dealer/Deck classes — bolting Poker-specific fields directly onto Player would leak game-specific concerns into a class meant to stay reusable across games.
Q: Why is swap(i, random(0..n-1)) for every i a biased shuffle?
A: It makes nⁿ equally likely sequences of swaps, but there are n! orderings, and nⁿ isn't divisible by n!, so some orderings must occur more often than others. Fisher–Yates chooses from a shrinking range (0..i), producing exactly n! equally likely outcomes.
Q: Why inject the random generator into the deck? A: Tests can use a seeded generator to reproduce specific deals and verify rules deterministically, while production can use a cryptographically secure generator, so players can't predict upcoming cards.
Q: How do you compare two poker hands cleanly? A: Evaluate each hand into a comparable value, a category plus an ordered list of tie-breaking ranks, and compare those values. Comparing by category first, then tie-breakers in order, handles every case, including exact ties.