Poker in Clojure: Complete Solution & Deep Dive Guide
Clojure Poker Hand Evaluator: From Zero to Hero
Discover how to build a sophisticated poker hand evaluator in Clojure. This guide breaks down the complex logic of parsing cards, classifying hand ranks from Royal Flush to High Card, and implementing tie-breaker rules using elegant, functional programming techniques.
You've seen it in movies, the tense final moment at the poker table. The cards are revealed, and a flurry of terms like "Full House," "Flush," and "Straight" fly around. For a human, recognizing these patterns becomes second nature with practice. But how would you teach a computer to do it? How do you translate the intricate, rule-based hierarchy of poker into clean, logical code?
This is a classic programming challenge that tests your ability to model data, manage complex conditional logic, and handle nuanced comparison rules. It's a problem that can quickly become a tangled mess of `if-else` statements in many languages. But in Clojure, with its emphasis on data transformation and functional composition, this complexity can be tamed into a beautiful, flowing pipeline of logic.
In this comprehensive guide, we'll build a complete poker hand evaluator from the ground up. We won't just solve the problem; we'll explore how Clojure's unique features make it the perfect tool for the job, turning a daunting task into an enjoyable and enlightening exercise in functional thinking. Prepare to transform card strings into structured data, classify hands with precision, and declare a winner, all with the power and elegance of Clojure.
What is the Poker Hand Evaluation Problem?
The core task is to identify the best hand (or hands, in the case of a tie) from a given list of poker hands. Each hand consists of five cards. The problem requires us to implement the standard rules of poker to rank these hands and determine a winner based on a predefined hierarchy.
This involves two primary challenges:
- Hand Classification: For any given five-card hand, we must correctly identify its rank. Is it a Pair? A Straight? A Flush? The logic must be precise enough to distinguish between all ten possible hand types.
- Hand Comparison & Tie-Breaking: Once hands are classified, we need to compare them. While a Flush always beats a Straight, comparing two Flushes requires a tie-breaking rule (comparing the highest card, then the next highest, and so on). Our system must handle these nuanced comparisons for every rank.
The Official Poker Hand Ranking Hierarchy
For our program to be accurate, it must adhere to the universal ranking of poker hands, from highest to lowest:
- Royal Flush: A, K, Q, J, 10, all of the same suit. The unbeatable hand.
- Straight Flush: Five cards in sequence, all of the same suit.
- Four of a Kind: Four cards of the same rank.
- Full House: Three cards of one rank and two cards of another rank.
- Flush: Five cards of the same suit, not in sequence.
- Straight: Five cards in sequence, but not of the same suit.
- Three of a Kind: Three cards of the same rank.
- Two Pair: Two cards of one rank, two cards of another rank, and one kicker.
- One Pair: Two cards of the same rank and three kickers.
- High Card: None of the above. The hand is valued by its highest card.
Why Use Clojure for this Challenge?
Clojure, a modern Lisp dialect running on the Java Virtual Machine (JVM), is exceptionally well-suited for problems involving data transformation and rule-based logic. Its functional-first philosophy provides a powerful toolkit for solving the poker problem elegantly.
Key Clojure Advantages
- Immutability by Default: Cards and hands are perfect candidates for immutable data structures. Once a hand is dealt, it doesn't change. Clojure's persistent data structures ensure that we can pass hand data through numerous functions without ever worrying about accidental modification or side effects.
- Powerful Core Library: Functions like
map,filter,group-by,frequencies, andsort-byare the building blocks of our solution. They allow us to express complex data manipulations—like counting card ranks or checking for a flush—in a concise and highly readable way. - Data-Oriented Approach: In Clojure, we "bring functions to the data." We'll model our cards and hands using simple maps and vectors. This allows us to use the entire standard library to operate on our data, rather than being locked into a rigid class-based system.
- REPL-Driven Development: The Read-Eval-Print Loop (REPL) is a game-changer. We can build our solution piece by piece, testing each function interactively. We can parse a card, then a hand, then test our `is-flush?` logic, all in real-time, leading to faster development and fewer bugs.
An imperative approach might involve loops, temporary variables, and mutable state to track counts and properties. In Clojure, we'll build a pipeline: raw string -> parsed hand -> analyzed properties -> score -> winner. This declarative style makes the code easier to reason about, test, and maintain.
How to Structure the Data and Logic
Before writing a single line of classification logic, we must decide how to represent our data. A good data structure makes the subsequent logic dramatically simpler.
Step 1: Parsing Cards and Hands
The input is a string like "4S 5S 7H 8D JC". Our first task is to convert this into a structured format that our program can understand. A vector of maps is an excellent choice, where each map represents a single card.
To make comparisons easy, we need to convert card ranks (T, J, Q, K, A) into numerical values. Suits can be represented by keywords.
;; Card String: "KS" -> Parsed Card: {:rank 13, :suit :s}
;; Hand String: "4S 5S 7H" -> Parsed Hand: [{:rank 4, :suit :s}, {:rank 5, :suit :s}, {:rank 7, :suit :h}]
We'll create a helper function to handle this parsing logic, mapping string representations to our internal data model. This isolates the "dirty" work of handling input from our clean, core logic.
Step 2: The Hand Classification Pipeline
The core of the solution is a function that takes a parsed hand and assigns it a score. This score must be comparable, meaning a higher score represents a better hand. A vector of numbers is perfect for this, as Clojure can compare vectors element by element.
For example, a score vector could look like [.
- A Full House of Kings over Twos might be scored as
[7 13 2](7 for Full House, 13 for the King, 2 for the Two). - A Flush with an Ace high might be
[6 14 11 8 5 3](6 for Flush, followed by all card ranks in descending order).
This structure elegantly handles tie-breakers. When comparing [7 13 2] and [7 12 5], Clojure immediately sees that 13 > 12, correctly identifying the first hand as the winner.
Here is a flowchart of the decision-making process for classifying a single hand:
● Start with a parsed 5-card hand
│
▼
┌──────────────────┐
│ Analyze Hand │
│ - Get Ranks │
│ - Get Suits │
│ - Count Freqs │
└────────┬─────────┘
│
▼
◆ Is it a Flush? (all same suit)
╱ ╲
Yes ⟶ ◆ Is it a Straight? ⟶ Yes ⟶ ◎ Straight Flush (Rank 9)
│ (sequential ranks) ╲
│ No ⟶ ◎ Flush (Rank 6)
│
No
│
▼
◆ What are the rank counts? (e.g., 4-1, 3-2, 3-1-1, 2-2-1, 2-1-1-1)
╱ │ │ ╲
4-of-a-kind │ Full House ...and so on
│ │ │
▼ ▼ ▼
◎ Rank 8 ◎ Rank 7 ◎ Other Ranks (5, 4, 3, 2, 1)
The Complete Clojure Solution
Now, let's assemble the code. We'll follow a logical progression: define constants, create parsing helpers, build analysis functions, implement the core scoring logic, and finally, create the main function to find the best hands.
Full Implementation Code
This code is a self-contained solution from the kodikra.com learning path. It demonstrates a data-driven, functional approach to solving the problem.
(ns poker.core
(:require [clojure.string :as str]))
(def card-ranks
"Maps card rank characters to their numerical value."
(zipmap "23456789TJQKA" (range 2 15)))
(defn- parse-card
"Parses a 2-character string (e.g., 'KH') into a map {:rank 13 :suit :h}."
[card-str]
(let [[rank-char suit-char] (seq card-str)]
{:rank (card-ranks rank-char)
:suit (keyword (str/lower-case suit-char))}))
(defn- parse-hand
"Parses a space-separated string of cards into a vector of card maps."
[hand-str]
(->> (str/split hand-str #" ")
(mapv parse-card)))
(defn- analyze-hand
"Analyzes a parsed hand to extract key properties for scoring."
[hand]
(let [ranks (sort > (map :rank hand))
suits (map :suit hand)
rank-freqs (->> (frequencies ranks) (sort-by val >) (mapv key))
is-flush (apply = suits)
;; Check for standard straight and the A-2-3-4-5 'wheel' straight
is-straight (or (= ranks (range (first ranks) (- (first ranks) 5) -1))
(= ranks [14 5 4 3 2]))
;; For a wheel straight, the Ace (14) should be treated as 1 for scoring.
straight-ranks (if (= ranks [14 5 4 3 2]) [5 4 3 2 1] ranks)]
{:ranks ranks
:rank-freqs rank-freqs
:is-flush is-flush
:is-straight is-straight
:straight-ranks straight-ranks}))
(defn- score-hand
"Calculates a comparable score vector for a given hand string."
[hand-str]
(let [hand (parse-hand hand-str)
{:keys [ranks rank-freqs is-flush is-straight straight-ranks]} (analyze-hand hand)
[primary secondary] rank-freqs]
(cond
;; Straight Flush (includes Royal Flush)
(and is-straight is-flush) (into [9] straight-ranks)
;; Four of a Kind
(= 4 (get (frequencies ranks) primary)) (into [8] rank-freqs)
;; Full House
(and (= 3 (get (frequencies ranks) primary))
(= 2 (get (frequencies ranks) secondary))) (into [7] rank-freqs)
;; Flush
is-flush (into [6] ranks)
;; Straight
is-straight (into [5] straight-ranks)
;; Three of a Kind
(= 3 (get (frequencies ranks) primary)) (into [4] rank-freqs)
;; Two Pair
(and (= 2 (get (frequencies ranks) primary))
(= 2 (get (frequencies ranks) secondary))) (into [3] rank-freqs)
;; One Pair
(= 2 (get (frequencies ranks) primary)) (into [2] rank-freqs)
;; High Card
:else (into [1] ranks))))
(defn best-hands
"From a list of hand strings, returns a vector of the winning hand(s)."
[hands]
(if (empty? hands)
[]
(let [scored-hands (map (fn [hand] {:hand hand :score (score-hand hand)}) hands)
best-score (:score (apply max-key :score scored-hands))]
(->> scored-hands
(filter #(= (:score %) best-score))
(mapv :hand)))))
Detailed Code Walkthrough
Let's break down the key components of our solution to understand how they work together.
parse-card and parse-hand
These functions are our input layer. card-ranks is a simple map for translating characters like 'K' to their numerical value 13. parse-card takes a string like "KH", splits it into its constituent characters, and builds a map {:rank 13 :suit :h}. parse-hand orchestrates this process for a full hand string, using str/split and mapv to create a vector of these card maps.
analyze-hand
This is a crucial helper function that does the heavy lifting of feature extraction. Instead of putting all the logic inside score-hand, we pre-calculate all the properties we might need.
ranks: A list of numerical ranks, sorted in descending order (e.g.,[13 13 8 5 3]). This is essential for tie-breaking.rank-freqs: This is a clever trick. We get the frequency of each rank, sort the results by frequency (highest first), and then extract just the ranks. For a Full House (KKK22), this would give us[13 2], immediately telling us our primary and secondary ranks.is-flush: A simple and elegant check:(apply = suits)returns true only if all elements in the suits collection are identical.is-straight: This handles two cases. The standard straight is checked by seeing if the sorted ranks form a descending sequence. The "wheel" straight (A-2-3-4-5) is a special case we check for explicitly. We also createstraight-ranksto handle the Ace's dual value, ensuring an A-5 straight is scored with 5 as the high card.
score-hand
This is the brain of the operation. It uses a cond macro, which is Clojure's version of a multi-branch `if-else if-else`. It checks the pre-calculated properties from analyze-hand in order of hand strength (highest to lowest).
When a condition is met, it constructs the score vector. For example, for a Flush, it prepends the rank value 6 to the vector of sorted card ranks: (into [6] ranks). For a Full House, it prepends 7 to the rank-freqs vector: (into [7] rank-freqs). This consistent structure is what makes comparison and tie-breaking work so seamlessly.
best-hands
This is the top-level function that ties everything together. It implements a clear data flow pipeline, which can be visualized as follows:
● Start with a list of hand strings
│ e.g., ["4H 4S 4D 2S 2D", "5S 6S 7S 8S 9S"]
│
▼
┌───────────────────────────┐
│ Map over list with scoring │
└────────────┬──────────────┘
│
▼
● List of scored-hand maps
│ e.g., [{:hand "...", :score [7 4 2]},
│ {:hand "...", :score [9 9 8 7 6 5]}]
│
▼
┌───────────────────────────┐
│ Find the maximum score │
│ (using `max-key :score`) │
└────────────┬──────────────┘
│
▼
● The best score vector
│ e.g., [9 9 8 7 6 5]
│
▼
┌───────────────────────────┐
│ Filter for all hands │
│ matching the best score │
└────────────┬──────────────┘
│
▼
● List of winning hand(s)
│ e.g., ["5S 6S 7S 8S 9S"]
This approach naturally handles draws. If multiple hands produce the same highest score vector, the filter step will include all of them in the final result.
Pros & Cons of this Functional Approach
Every architectural decision comes with trade-offs. Understanding them is key to becoming an expert developer.
| Pros | Cons / Risks |
|---|---|
Highly Composable: Each function (parse-card, analyze-hand, score-hand) has a single responsibility and can be tested in isolation. |
Initial Learning Curve: For developers new to functional programming, thinking in terms of data transformations rather than step-by-step instructions can be challenging at first. |
Declarative & Readable: The code describes *what* to do, not *how* to do it. Logic like (apply = suits) is much clearer than a manual loop with a flag variable. |
Intermediate Data Structures: This approach creates several intermediate collections (lists of ranks, suits, frequencies). For extremely performance-critical applications with millions of hands, a lower-level imperative approach might be faster, but it would be far more complex. |
| Robust & Testable: Since the functions are pure (no side effects), testing is trivial. You provide an input and assert the output. This makes the code base reliable and easy to refactor. | Verbosity in Analysis: The analyze-hand function returns a map with multiple keys. While explicit, some might find this slightly more verbose than mutating a single object's properties. |
Implicit Tie-Breaking: The scoring vector design is a powerful pattern that handles all tie-breaking rules implicitly through vector comparison, avoiding complex nested if statements. |
Potential for Redundant Calculation: If not structured carefully, one could re-calculate properties. Our use of the analyze-hand helper mitigates this by computing all properties once. |
Frequently Asked Questions (FAQ)
How do you handle the Ace in a low straight (A-2-3-4-5)?
The Ace is a special card. It can be high (in A-K-Q-J-T) or low (in A-2-3-4-5). Our code handles this by explicitly checking for the rank pattern [14 5 4 3 2]. If this pattern is found, we create a special :straight-ranks value of [5 4 3 2 1] to ensure it's correctly scored as a 5-high straight for comparison purposes.
What's the best way to represent card ranks for comparison?
Using integers (2 through 14) is the most effective way. It allows for direct numerical comparison, which is fast and simple. Storing them as strings or keywords would require a custom comparison function, adding unnecessary complexity. Mapping face cards (T, J, Q, K, A) to numbers (10, 11, 12, 13, 14) at the parsing stage is a critical first step.
Why use a scoring vector instead of a single integer score?
A single integer score makes tie-breaking extremely difficult. For example, two different "One Pair" hands would get the same integer score, but we wouldn't know which pair was higher or which kickers were better. A vector like [2 10 13 8 4] (One Pair, Tens, with K, 8, 4 kickers) contains all the information needed for a complete comparison against another hand's vector.
How does Clojure's `sort-by` and `max-key` work here?
max-key is a higher-order function that finds the maximum element in a collection based on the result of applying a function to each element. In our code, (apply max-key :score scored-hands) finds the map with the highest :score value. Since Clojure knows how to compare vectors element by element, this works perfectly for our scoring system.
How can this code be extended for other poker variants like Texas Hold'em?
The core logic (score-hand) remains the same. The main change would be in the input stage. For Texas Hold'em, you would need to generate all possible 5-card hands from the 7 available cards (2 pocket cards + 5 community cards) and then run our existing `best-hands` function on that generated list to find the player's best possible hand.
Is Clojure a good language for game development?
Clojure is excellent for game *logic*, especially for turn-based games, simulations, or strategy games where state management is complex. Its immutable data structures make it easier to manage game state, implement undo/redo features, and reason about game rules. For high-performance, graphics-intensive real-time games, languages like C++ or C# with engines like Unreal or Unity are more common, though Clojure can interoperate with Java's graphics libraries.
Conclusion
We have successfully constructed a robust and elegant poker hand evaluator in Clojure. By prioritizing data transformation over mutable state, we've created a solution that is not only correct but also clear, composable, and easy to test. This exercise from the kodikra.com curriculum perfectly illustrates the power of functional thinking: breaking a complex problem into a pipeline of small, pure functions that work together seamlessly.
The key takeaways are the importance of thoughtful data modeling (using maps and vectors), the utility of Clojure's core library (map, frequencies, sort-by), and the power of the scoring vector pattern for handling complex comparison and tie-breaking logic implicitly. This approach provides a solid foundation that can be adapted to other rule-based systems and complex logic problems.
Disclaimer: The solution provided is written for modern, stable versions of Clojure (1.10+). The core concepts and functions are fundamental to the language and are expected to be stable for the foreseeable future.
Ready to tackle your next challenge and deepen your functional programming skills? Explore the full Kodikra Clojure Learning Path or expand your knowledge with our complete guide to the Clojure language.
Published by Kodikra — Your trusted Clojure learning resource.
Post a Comment