Score each hand as an integer

Poker
Poker in C#
using System.Collections.Generic;
using System.Linq;

public static class Poker
{
    public static IEnumerable<string> BestHands(IEnumerable<string> hands) =>
        hands.ToLookup(hand => Parser.ParseHand(hand).Score).MaxBy(g => g.Key);

    private record Card(int Rank, int Suit);

    private record Hand(Card[] Cards)
    {
        private readonly int[] ranks = Cards
            .Select(card => card.Rank)
            .OrderByDescending(rank => Cards.Count(card => card.Rank == rank))
            .ThenByDescending(card => card)
            .ToArray();

        private readonly int[] rankCounts = Cards
            .GroupBy(card => card.Rank)
            .Select(grouping => grouping.Count())
            .OrderDescending()
            .ToArray();

        private readonly int suitCount = Cards.DistinctBy(card => card.Suit).Count();

        public int Score => CategoryRanks.Prepend(CategoryScore).Aggregate((total, value) => total * 14 + value);

        private int[] CategoryRanks => IsLowAceStraight ? ranks.Append(ranks[0]).Skip(1).ToArray() : ranks;

        private int CategoryScore =>
            IsStraightFlush ? 9 :
            IsFourOfAKind ? 8 :
            IsFullHouse ? 7 :
            IsFlush ? 6 :
            IsStraight ? 5 :
            IsThreeOfAKind ? 4 :
            IsTwoPair ? 3 :
            IsOnePair ? 2 :
            1;

        private bool IsStraightFlush => IsFlush && IsStraight;
        private bool IsFlush => suitCount == 1;
        private bool IsStraight => (rankCounts is [1, 1, 1, 1, 1] && ranks[0] - ranks[4] == 4) || IsLowAceStraight;
        private bool IsLowAceStraight => rankCounts is [1, 1, 1, 1, 1] && ranks[0] - ranks[1] is 9;
        private bool IsFourOfAKind => rankCounts is [4, 1];
        private bool IsFullHouse => rankCounts is [3, 2];
        private bool IsThreeOfAKind => rankCounts is [3, 1, 1];
        private bool IsTwoPair => rankCounts is [2, 2, 1];
        private bool IsOnePair => rankCounts is [2, 1, 1, 1];
    }

    private static class Parser
    {
        public static Hand ParseHand(string hand) => new(ParseCards(hand));
        private static Card[] ParseCards(string hand) => hand.Split(' ').Select(ParseCard).ToArray();
        private static Card ParseCard(string card) => new(ParseRank(card), ParseSuit(card));
        private static int ParseRank(string card) => "1234567890JQKA".IndexOf(card[^2]);
        private static int ParseSuit(string card) => "HSDC".IndexOf(card[^1]);
    }
}

In this approach, we'll compare hands by assigning each of them an integer score. When comparing hands, we'll select the hands with the highest score.

Modelling our domain

As our first step, let's look at how we want to model our domain in code.

To score hands based on the card's ranks, we'll model ranks (and suits) as integers: the higher the number, the higher its value.

Cards

A card could be modelled as a class with two properties:

private class Card
{
    public Card(int rank, int suit)
    {
        Rank = rank;
        Suit = suit;
    }

    public int Rank { get; }
    public int Suit { get; }
}

As we'll only be using the card to access its properties (no behavior associated with it), we can also more succinctly use a record type:

private record Card(int Rank, int Suit);

Hands

A hand is basically nothing more than a collection of five cards. While we could model the cards as five different properties, it'll be easier to process them as an array of cards:

private record Hand(Card[] Cards);

Parsing

Having defined our Hand and Card types, let's try and parse them from our input string. As a reminder, these are some example hand inputs:

"4S 5S 7H 8D JC"
"2S 4C 7S 9H 10H"

Parse suit

We can see that the suits are defined by the last character. Traditionally, you'd access the last character in a string by using str[str.Length - 1], but the index from end operator is a more elegant way to do that.

We can then convert a hand's suit to an integer by finding the suit character's index in a string that contains all suit characters:

private static int ParseSuit(string card) => "HSDC".IndexOf(card[^1]);

Neat!

Parse rank

Parsing a rank is similar to parsing a suit, but we'll now look at the first character:

private static int ParseRank(string card) => "1234567890JQKA".IndexOf(card[^2]);

Note that this also works for the "10" rank, as its first character ("1") is a unique value.

Parse card

Parsing a card is then a matter of calling the Card constructor with the parsed rank and suit:

private static Card ParseCard(string card) => new Card(ParseRank(card), ParseSuit(card));

Or, more succinctly, using a target-typed new expression:

private static Card ParseCard(string card) => new (ParseRank(card), ParseSuit(card));

Parsing hand

Parsing a hand means parsing its cards, which are rank/suit strings separated by spaces (e.g. "4S 5S 7H 8D JC"). We thus first split the string by spaces via the string's Split() method:

hand.Split(' ')

This will return an array of strings representing each card. We then use Select() to parse each card string into a Card instance:

hand.Split(' ').Select(card => ParseCard(card))

As the lambda passed to Select() only passed its argument to another method, it can be shortened using a method group:

hand.Split(' ').Select(ParseCard);

We then materialize the parsed cards to an array using ToArray():

private static Card[] ParseCards(string hand) => hand.Split(' ').Select(ParseCard).ToArray();

Finally, we can parse our hand by doing:

public static Hand ParseHand(string hand) => new(ParseCards(hand));

Comparing hands

We now get to the core of this approach: scoring each hand to allow them to be compared. To do so, our Hand class will have Score property that returns the hand's score as an integer:

private record Hand(Card[] Cards) : IComparable<Hand>
{
    public int Score => ???
}

Categorizing hands

Before we'll get to any code, let's take a step back and examine the rules for the categories. We can define all categories using three different constraints:

  • Rank counts: how often ranks occur in a single hand
  • Same suit: all cards must have the same suit
  • Sequential ranks: the ranks have to be in sequential order
Rule Rank counts Same suit Sequential ranks
Straight flush 1, 1, 1, 1, 1 Yes Yes
Four of a kind 4, 1 No No
Full house 3, 2 No No
Flush any Yes No
Straight 1, 1, 1, 1, 1 No Yes
Three of a kind 3, 1, 1 No No
Two pairs 2, 2, 1 No No
One pair 2, 1, 1, 1 No No
High card any No No

Before we'll look at the individual rule, let's first add code to can help us identify the rank counts, same suit and sequential ranks criteria.

Rank counts

To detect the rank counts, we can group cards by rank using GroupBy() and then convert each rank group to its count via Select() and Count():

Cards.GroupBy(card => card.Rank).Select(grouping => grouping.Count())

Finally, we'll order them descendingly using OrderDescending() and ToArray():

private readonly int[] rankCounts = Cards
    .GroupBy(card => card.Rank)
    .Select(grouping => grouping.Count())
    .OrderDescending()
    .ToArray();

We can now detect a full house (rank count of 3 and 2) using a simple list pattern:

private bool IsFullHouse = ranksCounts is [3, 2];

Same suit

To detect whether all cards have the same suit, we can use DistinctBy() using the Suit property followed by Count():

private readonly int suitCount = Cards.DistinctBy(card => card.Suit).Count();

As cards with the same suit are known as a flush, let's define an IsFlush property that returns true when there is just one suit:

private bool IsFlush => suitCount == 1;

You should read this as: there is just one suit and it is present on all five cards.

Sequential ranks

To detect whether all ranks are sequential, we could sort the ranks and then iterate over them in pairs, checking whether the difference between them is always equal to one. But, we can also use the fact that is the ranks are sequential, the difference between the highest and the lowest rank is always 4:

Hand Lowest rank Highest rank Difference
"3S 4D 2S 6D 5C" 2 6 4
"5S 7H 8S 9D 6H" 5 9 4
"10D JH QS KD AC" 10 14 (ace) 4

There is one exception to this rule, but we'll get to that in a minute. Let's start by defining a field that contains the card's ranks:

private readonly Rank[] ranks => Cards.Select(card => card.Rank).ToArray();

As sequential ranks are known as a straight, we'll define an IsStraight property that returns true when the ranks are sequential:

private bool IsStraight => rankCounts is [1, 1, 1, 1, 1] && ranks.Max() - ranks.Min() == 4;

If we modify our ranks field to be in descending order, we don't need to use Max() en Min() (which will enumerate the ranks each time they're called) but can use direct array indexing:

private readonly Rank[] ranks => Cards.Select(card => card.Rank).OrderDescending().ToArray();

private bool IsStraight => rankCounts is [1, 1, 1, 1, 1] && ranks[0] - ranks[4] == 4;
Low ace straight

There is one edge case that our code does not yet account for: a low ace straight, where the ace counts as a 1 (e.g. "4D AH 3S 2D 5C"). We can use a list pattern to check for this edge case:

private bool IsLowAceStraight => rankCounts is [1, 1, 1, 1, 1] && ranks[0] - ranks[1] is 9;

We can then support this edge case by using it in the SequentialRanks property:

private bool IsStraight => (rankCounts is [1, 1, 1, 1, 1] && ranks[0] - ranks[4] == 4) || IsLowAceStraight;

Straight flush

The rules for a straight flush are:

  1. The ranks are all different
  2. The cards all have the same suit
  3. The cards have sequential ranks

This is equivalent to: the hand is a flush and the hand is a straight:

private bool IsStraightFlush => IsFlush && IsStraight;

Four of a kind

The rule for a four of a kind hand is:

  1. One rank occurs four times and one rank just once

This translates to:

private bool IsFourOfAKind => rankCount is [4, 1]

Full house

The rule for a full house is:

  1. One rank occurs three times and one rank two times

This translates to:

private bool IsFullHouse => rankCount is [3, 2]

Flush

The rule for a flush is:

  1. One rank occurs three times and one rank two times

As we have already defined the IsFlush property, we don't need to do anything (yay!).

Straight

The rule for a straight is:

  1. All ranks are sequential

Again, we've already defined a property (IsStraight), so nothing to do here.

Three of a kind

The rule for a three of a kind is:

  1. One rank occurs three times, the other two ranks occur once

This translates to:

private bool IsThreeOfAKind => ranksCounts is [3, 1, 1];

Two pairs

The rule for two pairs is:

  1. One rank occurs twice times, another rank also occurs twice and the other ranks occurs once

This translates to:

private bool IsTwoPair = rankCounts is [2, 2, 1];

One pair

The rule for one pair is:

  1. One rank occurs twice times, the other three ranks once

This translates to:

private bool IsOnePair => rankCounts is [2, 1, 1, 1];

High card

There is no specific rule for the high card category as it is more like a fallback, so let's move on.

Compare hands with different category

We now have logic to categorize and compare hands based on the nine different hand categories. Ordered from high to low, they are:

    1. Straight flush
    1. Four of a kind
    1. Full house
    1. Flush
    1. Straight
    1. Three of a kind
    1. Two pairs
    1. One pair
    1. High card

We can modify the Score property to return a score that reflects the category's ordering, with higher categories being assigned higher scores:

public int Score =>
    IsStraightFlush ? 9 :
    IsFourOfAKind ? 8 :
    IsFullHouse ? 7 :
    IsFlush ? 6 :
    IsStraight ? 5 :
    IsThreeOfAKind ? 4 :
    IsTwoPair ? 3 :
    IsOnePair ? 2 :
    1;

With that, we can now compare hands with different categories.

Compare hands with same category

The second part of comparing two hands is to compare hands that have the same category. Each category has its own rules to determine the better hand.

As an example, let's look at how we should score two four of a kind hands. Four of a kind hands are compared by:

  • First: the rank occuring four times
  • Second: the rank occuring once (known as the kicker)

First, we'll need to be able to identify which rank occurs four times, and which just once. For that, we'll tweak our existing ranks field a bit. Instead of just sorting by rank descendingly, we'll first sort them by count:

private readonly Rank[] ranks => Cards
    .Select(card => card.Rank)
    .OrderByDescending(rank => Cards.Count(card => card.Rank == rank))
    .ThenByDescending(rank => rank)
    .ToArray();

For a four of a kind hand, we are now guaranteed that the first four ranks are those that occur four times (ranks[0], ranks[1], ranks[2] and ranks[3]), and the last rank is the kicker (ranks[4]).

So how do we translate this into different scores?

Base 14 encoding

Consider the following two four of kind hands:

  • Hand 1: "2S 2H 2C 8D 2D"
  • Hand 2: "4S 5H 5S 5D 5C"

We should compare them by quadruplet rank (2 and 5) and then by kicker rank (8 and 4). What we need is some way to have 5,4 translate to a higher score than 2,8. We could do that by "removing the comma": 54 is higher than 28. What we've done here is convert the two numbers into one base 10 number (decimal):

5,4 = 5 * 10^1 + 4 * 10^0 = 5 * 10 + 4 * 1 = 50 + 4 = 54 2,8 = 2 * 10^1 + 8 * 10^0 = 2 * 10 + 8 * 1 = 20 + 8 = 28

Clearly, this works, but how do we apply this to our ranks? Well, first we need to identify what base to use. We can't just use base 10, as we have 13 different ranks. But we can use a base 14 numeral system:

5,4 = 5 * 14^1 + 4 * 14^0 = 5 * 14 + 4 * 1 = 70 + 4 = 74 2,8 = 2 * 14^1 + 8 * 14^0 = 2 * 14 + 8 * 1 = 28 + 8 = 36

Translating that to code, we can do:

if (IsFourOfAKind)
    return ranks[0] * 14 + ranks[4];

Beautiful!

Simplifying

Applying this logic to the flush category, we get:

return ranks[0] * 14 * 14 * 14 * 14 + ranks[1] * 14 * 14 * 14 + ranks[2] * 14 * 14 + ranks[3] * 14 + ranks[4];

It works, but it is quite verbose. We can shorten this quite a bit using Aggregate():

ranks.Aggregate((total, value) => total * 14 + value);

As a matter of fact, we can use this code for all categories! For example, for a four of a kind hand, ranks[0], ranks[1], ranks[2] and ranks[3] all have the same value. It doesn't matter if we compare four of kind hands by just the first rank + kicker or the first four ranks + kicker, as the resulting score will indicate the same relative result (less than, equal or greater than).

Scoring categories

Currently, we're just scoring ranks, resulting in a four of kind hand with a quadruplet of rank 7 losing to a three of kind hand with a triplet of rank 10. To fix this, we'll need to score a hand's category and use that as the first value for our score calculation.

We can score the categories as follows:

private int CategoryScore =>
    IsStraightFlush ? 9 :
    IsFourOfAKind ? 8 :
    IsFullHouse ? 7 :
    IsFlush ? 6 :
    IsStraight ? 5 :
    IsThreeOfAKind ? 4 :
    IsTwoPair ? 3 :
    IsOnePair ? 2 :
    1;

We then prepend this value before the ranks when calculating the score:

public int Score => ranks.Prepend(CategoryScore).Aggregate((total, value) => total * 14 + value);

We now have a fully working scoring system!

Low ace straight

Our hand scoring logic works for all hands but one: a low ace straight. In this case, the ranks field's first value will be the ace (as it has the highest value), but it should actually count as the lowest value and thus be the last element in the comparison. As this also applies to straight flushes, we can add a new CategoryRanks property to special-case straight flush and straight hands that are low ace straights:

private int[] CategoryRanks => IsLowAceStraight ? ranks.Append(ranks[0]).Skip(1).ToArray() : ranks;

Then, we update the Score() method to use the above property:

public int Score => CategoryRanks.Prepend(CategoryScore).Aggregate((total, value) => total * 14 + value);

Finding the best hands

The final step is to use our hand comparison logic to find the best hands. As a reminder, this is the method signature for the BestHands() method:

public static IEnumerable<string> BestHands(IEnumerable<string> hands)

To find the best hands, we'll create a lookup where the key is the hand's score:

hands.ToLookup(hand => Parser.ParseHand(hand).Score);

Then all we need to do is to is to get the values for the maximum score key, for which we can use MaxBy():

hands.ToLookup(hand => Parser.ParseHand(hand).Score).MaxBy(g => g.Key);
30th Apr 2025 · Found it useful?