DEV Community

loading...

Ruby 3 Pattern Matching Applied: Poker

baweaver profile image Brandon Weaver Updated on ・12 min read

Ruby 3.0 introduced Pattern Matching as a key feature, but if you're like a lot of folks you may not entirely be sure what it's used for or why you might want it.

This article is going to go over just that and show you how to use Pattern Matching to score a poker hand!

Arsene Lemur on a playing card

The Program

Let's start out by looking at the entirety of the script. We'll be walking through each part of it here in a moment, but make sure to note parts that are confusing as you give it a read, and see if you can figure out what it's doing:

Card = Struct.new(:suit, :rank) do
  include Comparable

  def precedence()      = [SUITS_SCORES[self.suit], RANKS_SCORES[self.rank]]
  def rank_precedence() = RANKS_SCORES[self.rank]
  def suit_precedence() = SUITS_SCORES[self.rank]

  def <=>(other) = self.precedence <=> other.precedence

  def to_s() = "#{self.suit}#{self.rank}"
end

Hand = Struct.new(:cards) do
  def sort()         = Hand[self.cards.sort]
  def sort_by_rank() = Hand[self.cards.sort_by(&:rank_precedence)]

  def to_s() = self.cards.map(&:to_s).join(', ')
end

SUITS        = %w(S H D C).freeze
SUITS_SCORES = SUITS.each_with_index.to_h
RANKS        = [*2..10, *%w(J Q K A)].map(&:to_s).freeze
RANKS_SCORES = RANKS.each_with_index.to_h

SCORES = %i(
  royal_flush
  straight_flush
  four_of_a_kind
  full_house
  flush
  straight
  three_of_a_kind
  two_pair
  one_pair
  high_card
).reverse_each.with_index(1).to_h.freeze

CARDS = SUITS.flat_map { |s| RANKS.map { |r| Card[s, r] } }.freeze

def hand_score(unsorted_hand)
  hand = Hand[unsorted_hand].sort_by_rank.cards

  is_straight = -> hand {
    hand
      .map { RANKS_SCORES[_1.rank] }
      .sort
      .each_cons(2)
      .all? { |a, b| b - a == 1 }
  }

  return SCORES[:royal_flush] if hand in [
    Card[s, '10'], Card[^s, 'J'], Card[^s, 'Q'], Card[^s, 'K'], Card[^s, 'A']
  ]

  return SCORES[:straight_flush] if is_straight[hand] && hand in [
    Card[s, *], Card[^s, *], Card[^s, *], Card[^s, *], Card[^s, *]
  ]

  return SCORES[:four_of_a_kind] if hand in [
    *, Card[*, r], Card[*, ^r], Card[*, ^r], Card[*, ^r], *
  ]

  return SCORES[:full_house] if hand in [
    Card[*, r1], Card[*, ^r1], Card[*, ^r1], Card[*, r2], Card[*, ^r2]
  ]

  return SCORES[:full_house] if hand in [
    Card[*, r1], Card[*, ^r1], Card[*, r2], Card[*, ^r2], Card[*, ^r2]
  ]

  return SCORES[:flush] if hand in [
    Card[s, *], Card[^s, *], Card[^s, *], Card[^s, *], Card[^s, *]
  ]

  return SCORES[:straight] if is_straight[hand]

  return SCORES[:three_of_a_kind] if hand in [
    *, Card[*, r], Card[*, ^r], Card[*, ^r], *
  ]

  return SCORES[:two_pair] if hand in [
    *, Card[*, r1], Card[*, ^r1], Card[*, r2], Card[*, ^r2], *
  ]

  return SCORES[:two_pair] if hand in [
    Card[*, r1], Card[*, ^r1], *, Card[*, r2], Card[*, ^r2]
  ]

  return SCORES[:one_pair] if hand in [
    *, Card[*, r], Card[*, ^r], *
  ]

  SCORES[:high_card]
end

# --- Testing ------

EXAMPLES = {
  royal_flush:
    RANKS.last(5).map { Card['S', _1] },

  straight_flush:
    RANKS.first(5).map { Card['S', _1] },

  four_of_a_kind:
    [CARDS[0], *SUITS.map { Card[_1, 'A'] }],

  full_house:
    SUITS.first(3).map { Card[_1, 'A'] } +
    SUITS.first(2).map { Card[_1, 'K'] },

  flush:
    (0..RANKS.size).step(2).first(5).map { Card['S', RANKS[_1]] },

  straight:
    [Card['H', RANKS.first], *RANKS[1..4].map { Card['S', _1] }],

  three_of_a_kind:
    CARDS.first(2) +
    SUITS.first(3).map { Card[_1, 'A'] },

  two_pair:
    CARDS.first(1) +
    SUITS.first(2).flat_map { [Card[_1, 'A'], Card[_1, 'K']] },

  one_pair:
    [CARDS[10], CARDS[15], CARDS[20], *SUITS.first(2).map { Card[_1, 'A'] }],

  high_card:
    [CARDS[10], CARDS[15], CARDS[20], CARDS[5], Card['S', 'A']]
}.freeze

SCORE_MAP = SCORES.invert

EXAMPLES.each do |hand_type, hand|
  score = hand_score(hand)
  correct_text = hand_type == SCORE_MAP[score] ? 'correct' : 'incorrect'

  puts <<~OUT
    Hand:  #{Hand[hand]} (#{hand_type})
    Score: #{score} (#{correct_text})
  OUT

  puts
end

Enter fullscreen mode Exit fullscreen mode

It's a lot to take in at once, and if you don't understand all of it that's perfectly ok: that's what the rest of this article is for.

Shall we start digging in then?

The Explanation

A Constant Factor

Let's start by looking at our constants:

SUITS         = %w(S H D C).freeze
SUITS_SCORES  = SUITS.each_with_index.to_h
RANKS         = [*2..10, *%w(J Q K A)].map(&:to_s).freeze
RANKS_SCORES  = RANKS.each_with_index.to_h

SCORES = %i(
  royal_flush
  straight_flush
  four_of_a_kind
  full_house
  flush
  straight
  three_of_a_kind
  two_pair
  one_pair
  high_card
).reverse_each.with_index(1).to_h.freeze

CARDS = SUITS.flat_map { |s| RANKS.map { |r| Card[s, r] } }.freeze
Enter fullscreen mode Exit fullscreen mode

Suits

We start with our suits: Spades, Hearts, Diamonds, and Clubs. They're shortened to their first letters:

SUITS        = %w(S H D C).freeze
SUITS_SCORES = SUITS.each_with_index.to_h
Enter fullscreen mode Exit fullscreen mode

We also want to score them so we have a quick index of priority to reference elsewhere without iterating the entire array everywhere.

Ranks

Next we have our ranks, the cards from Two all the way up to Ace, represented by a number or their first letter:

RANKS         = [*2..10, *%w(J Q K A)].map(&:to_s).freeze
RANKS_SCORES  = RANKS.each_with_index.to_h
Enter fullscreen mode Exit fullscreen mode

The map(&:to_s) is to make sure it's in a consistent type, and as with the last one we want to create a mapping of rank to its priority. If it were just numbers that'd be silly, but face cards make this a tinge harder, so here we are.

Scores

After that we have the ranking of hands all the way from a Royal Flush down to a High Card:

SCORES = %i(
  royal_flush
  straight_flush
  four_of_a_kind
  full_house
  flush
  straight
  three_of_a_kind
  two_pair
  one_pair
  high_card
).reverse_each.with_index(1).to_h.freeze
Enter fullscreen mode Exit fullscreen mode

We reverse it to make sure the Royal Flush has the highest score, but we want to read it in an order that makes sense to us. Remember to optimize for readability first (I say before the pattern matching monstrosity below, but I digress).

with_index(1) starts indexes at 1 instead of 0 and to_h gives us a mapping of hand type to the score we get for it.

Cards

Last up we need to create all of our cards:

CARDS = SUITS.flat_map { |s| RANKS.map { |r| Card[s, r] } }.freeze
Enter fullscreen mode Exit fullscreen mode

We'll get to Card (including the bracket syntax) in a second in the next section, but what we're doing here is getting the product of all the SUITS applied to all of the card RANKS, or in other words every possible card ignoring jokers.

Adding Structure

Structs in Ruby are very handy when you don't quite want a full class. I tend to use them when I don't want to type out initialize and they're south of 10 lines of code:

Card = Struct.new(:suit, :rank) do
  include Comparable

  def precedence()      = [SUITS_SCORES[self.suit], RANKS_SCORES[self.rank]]
  def rank_precedence() = RANKS_SCORES[self.rank]
  def suit_precedence() = SUITS_SCORES[self.rank]

  def <=>(other) = self.precedence <=> other.precedence

  def to_s() = "#{self.suit}#{self.rank}"
end

Hand = Struct.new(:cards) do
  def sort()         = Hand[self.cards.sort]
  def sort_by_rank() = Hand[self.cards.sort_by(&:rank_precedence)]

  def to_s() = self.cards.map(&:to_s).join(', ')
end
Enter fullscreen mode Exit fullscreen mode

New Structs

How do Structs work then? They take a list of attributes, and act as a simple data container:

Card = Struct.new(:suit, :rank)
Enter fullscreen mode Exit fullscreen mode

This is equivalent to the following class, roughly speaking:

class Card
  attr_accessor :suit, :rank

  def initialize(suit, rank)
    @suit = suit
    @rank = rank
  end
end
Enter fullscreen mode Exit fullscreen mode

...but that takes one line, hence my proclivity towards them for demonstration purposes.

Structs take Blocks

Struct constructors can also take blocks to define methods on them. Once you get here it might be time to consider a class, but in this case it makes for a more fun explanation to keep using Structs, and also I didn't want to type more than I had to there:

Card = Struct.new do
  def method_on_card
    'foo!'
  end
end

Card.new.method_on_card
# => 'foo!'

# Just an alias for new:
Card[].method_on_card
# => 'foo!'
Enter fullscreen mode Exit fullscreen mode

Comparable

We want to be able to compare cards, and since they're not cleanly mapped to numbers we need to give Ruby some help with how to sort them:

Card = Struct.new(:suit, :rank) do
  include Comparable

  def precedence()      = [SUITS_SCORES[self.suit], RANKS_SCORES[self.rank]]
  def rank_precedence() = RANKS_SCORES[self.rank]
  def suit_precedence() = SUITS_SCORES[self.rank]

  def <=>(other) = self.precedence <=> other.precedence

  def to_s() = "#{self.suit}#{self.rank}"
end
Enter fullscreen mode Exit fullscreen mode

Comparable allows us to use all types of sorting methods by implementing <=> (rocket-ship operator, or comparator) on a class, much the same as Enumerable and each. Our <=> looks like this:

def <=>(other) = self.precedence <=> other.precedence
Enter fullscreen mode Exit fullscreen mode

It's using Ruby 3's one-line methods, and we're comparing the precedence of one card to the other. In this case we're using this for precedence in ordering:

def precedence() = [SUITS_SCORES[self.suit], RANKS_SCORES[self.rank]]
Enter fullscreen mode Exit fullscreen mode

Why an Array? Because we want to defaultly sort by suit first, and then by rank. Spades outrank Hearts, and Aces outrank other ranks. Granted below we end up relying primarily on rank_precedence as pattern matching expects things to be in a coherent order, and normal precedence here isn't useful, merely for demonstration purposes.

to_s

String representations are real handy when debugging, and in this case our Card is represented by its' suit and rank. Nothing too fancy here:

def to_s() = "#{self.suit}#{self.rank}"
Enter fullscreen mode Exit fullscreen mode

Hands

Now we want a concept of a hand so we know what we're dealing with:

Hand = Struct.new(:cards) do
  def sort()         = Hand[self.cards.sort]
  def sort_by_rank() = Hand[self.cards.sort_by(&:rank_precedence)]

  def to_s() = self.cards.map(&:to_s).join(', ')
end
Enter fullscreen mode Exit fullscreen mode

You might notice I tend to return new objects rather than mutate in place for sort type methods. That's mostly for functional-style and not messing with my test data repeatedly.

A class or struct doesn't have to be complicated, it just has to provide some value over repeating array sorts and string prints everywhere.

Scoring a Hand

Now we get into the really fun part of this program, and it's quite a lot to take in.

Sorting the Hand

In order to score a hand it needs to be in an order Pattern Matching can work with:

hand = Hand[unsorted_hand].sort_by_rank.cards
Enter fullscreen mode Exit fullscreen mode

Granted we could probably add Array-like methods to the Hand, make it Enumerable, and add Pattern Matching hooks in it, but we just want it for sorting in this case.

Straight and Ordered

Pattern Matching, unless I put a whole lot of cases, will not work well with checking for hands containing a straight. When you have a hammer not everything is a nail, and this is one such case, so we make a lambda function we can reuse a few times below for straight-like hands:

is_straight = -> hand {
  hand
    .map { RANKS_SCORES[_1.rank] }
    .sort
    .each_cons(2)
    .all? { |a, b| b - a == 1 }
}
Enter fullscreen mode Exit fullscreen mode

We want to map our hand into what each card's scores are for rank, we don't care about suit here. After that we want to make sure it's ordered, and get them in each consecutive group of two cards (each_cons(2)).

The point of doing this is we want to make sure that every pair is only one rank apart, or in other words, part of a straight.

The Royal Flush

This gets us to the real interesting parts of this post, starting with one-liner pattern matching:

return SCORES[:royal_flush] if hand in [
  Card[s, '10'], Card[^s, 'J'], Card[^s, 'Q'], Card[^s, 'K'], Card[^s, 'A']
]
Enter fullscreen mode Exit fullscreen mode

A Royal Flush is the same suit with cards ascending from 10 to A. In this pattern match we're using s to capture the first suit we see, and ^s (pin s) to say that we expect all the following suits to be the same.

If the first suit was a Spade, we would expect all the others to be Spades, otherwise go to the next pattern.

You might notice Card[...] being used here. This syntax works on all classes, not just Structs, to get at attributes in a pattern match. I need to experiment more with this to explain all the nuance here, but it works great for Structs in the mean time.

Stylistically I like left-to-right, hence the return score if match type syntax. I could use multi-line pattern matching but that would get messy with this as Straight checks won't play nicely.

The Straight Flush

The next is a bit more interesting, and represents one case where exhaustively Pattern Matching would lead to much more code than it'd save, and quite a mess. So we're using that straight check from above instead:

return SCORES[:straight_flush] if is_straight[hand] && hand in [
  Card[s, *], Card[^s, *], Card[^s, *], Card[^s, *], Card[^s, *]
]
Enter fullscreen mode Exit fullscreen mode

If the hand is a straight and all the suits are the same we have a winner! Same idea as above with s and ^s on all the following matches to signify all the suits are the same. * here, on the other hand, is new. It means we don't really care about that value, leave it be.

The Four of a Kind

For Four of a Kind we want four of the same rank of card with different suits:

return SCORES[:four_of_a_kind] if hand in [
  *, Card[*, r], Card[*, ^r], Card[*, ^r], Card[*, ^r], *
]
Enter fullscreen mode Exit fullscreen mode

The r is the same idea as s above, and we pin the rest to make sure it's the same rank. We don't really care about the suits because if we have four of the same card we know it'll capture all four suits as well.

You might notice * here is in a different spot. In this case it's a search, saying the Four of a Kind could be anywhere in the middle of our hand, or in this specific case at the front or back of the hand. That means AAAAK and KAAAA patterns are both valid.

The Full House

This one is interesting, and presents a strange bit of code:

return SCORES[:full_house] if hand in [
  Card[*, r1], Card[*, ^r1], Card[*, ^r1], Card[*, r2], Card[*, ^r2]
]

return SCORES[:full_house] if hand in [
  Card[*, r1], Card[*, ^r1], Card[*, r2], Card[*, ^r2], Card[*, ^r2]
]
Enter fullscreen mode Exit fullscreen mode

Why two? Well first reason is we can't use named captures and pins if we use | for an "OR" pattern, so we have to break it into two matches.

A Full House is where there's a Three of a Kind and a Two of a Kind together. That could be AAABB or AABBB, making it two distinct patterns. In the first case we want the first three cards to be r1 and the last two cards to be r2. In the next case we reverse that.

The Flush

For a Flush we want to make sure all the cards are of the same suit:

return SCORES[:flush] if hand in [
  Card[s, *], Card[^s, *], Card[^s, *], Card[^s, *], Card[^s, *]
]
Enter fullscreen mode Exit fullscreen mode

Same idea as before, capture s and make sure the remaining card suits are all the same suit with ^s.

The Straight

For a Straight we want to make sure all the cards are one apart, but unlike Straight Flush and Royal Flush they're not the same suit. This is why we made that lambda function above:

return SCORES[:straight] if is_straight[hand]
Enter fullscreen mode Exit fullscreen mode

The Three of a Kind

Three of a Kind is a lot like Four of a Kind, except we have three of the same rank:

return SCORES[:three_of_a_kind] if hand in [
  *, Card[*, r], Card[*, ^r], Card[*, ^r], *
]
Enter fullscreen mode Exit fullscreen mode

Capture and pin the rank, and use * to let it know that our match could be anywhere in the hand. * on the front and back of a pattern means "anywhere in here" as long as the defined segments are still contiguous. This means, for this hand, that KAAAQ, KQAAA, and AAAKQ are all valid as the Aces are all still contiguous.

The Two Pair

The Two Pair presents a similar issue to the Full House, except there's no Three of a Kind:

return SCORES[:two_pair] if hand in [
  *, Card[*, r1], Card[*, ^r1], Card[*, r2], Card[*, ^r2], *
]

return SCORES[:two_pair] if hand in [
  Card[*, r1], Card[*, ^r1], *, Card[*, r2], Card[*, ^r2]
]
Enter fullscreen mode Exit fullscreen mode

We want to start by making sure we have two distinct pairs with r1 and r2 and their associated pins. The next trick is the second match. Who says the pairs can't be at the front and back with something else in the middle?

Granted our cards are sorted so this is a non-issue, but a fun feature to point out if you need it.

The One Pair

That leaves us with our last check:

return SCORES[:one_pair] if hand in [
  *, Card[*, r], Card[*, ^r], *
]
Enter fullscreen mode Exit fullscreen mode

Same ideas as above but we only one two of the same card, and like above we don't need to know the suits of those cards.

The High Card

Now we get to the end. If nothing else we return back High Card and let another method eventually sort out who had the highest card between players:

SCORES[:high_card]
Enter fullscreen mode Exit fullscreen mode

...and with that we have our scores!

Testing and Examples

Now we just need to make sure it all works with some testing code:

EXAMPLES = {
  royal_flush:
    RANKS.last(5).map { Card['S', _1] },

  straight_flush:
    RANKS.first(5).map { Card['S', _1] },

  four_of_a_kind:
    [CARDS[0], *SUITS.map { Card[_1, 'A'] }],

  full_house:
    SUITS.first(3).map { Card[_1, 'A'] } +
    SUITS.first(2).map { Card[_1, 'K'] },

  flush:
    (0..RANKS.size).step(2).first(5).map { Card['S', RANKS[_1]] },

  straight:
    [Card['H', RANKS.first], *RANKS[1..4].map { Card['S', _1] }],

  three_of_a_kind:
    CARDS.first(2) +
    SUITS.first(3).map { Card[_1, 'A'] },

  two_pair:
    CARDS.first(1) +
    SUITS.first(2).flat_map { [Card[_1, 'A'], Card[_1, 'K']] },

  one_pair:
    [CARDS[10], CARDS[15], CARDS[20], *SUITS.first(2).map { Card[_1, 'A'] }],

  high_card:
    [CARDS[10], CARDS[15], CARDS[20], CARDS[5], Card['S', 'A']]
}.freeze

SCORE_MAP = SCORES.invert

EXAMPLES.each do |hand_type, hand|
  score = hand_score(hand)
  correct_text = hand_type == SCORE_MAP[score] ? 'correct' : 'incorrect'

  puts <<~OUT
    Hand:  #{Hand[hand]} (#{hand_type})
    Score: #{score} (#{correct_text})
  OUT

  puts
end
Enter fullscreen mode Exit fullscreen mode

We assemble some hands which match conditions, and in some cases fill the rest with arbitrary deterministic cards selected by which numbers I kinda liked the sound of that didn't cause the hand to match another rule.

We'll do a quick run through of the more unique parts of this.

Numbered Params

Ruby 3 introduced numbered params:

RANKS.last(5).map { Card['S', _1] }
Enter fullscreen mode Exit fullscreen mode

In this case the five highest cards, all Spades. _1 is the implied first parameter to the function.

Splat

Splats unfold an array into a function or another collection. In this case it gives us one flat array. Sometimes I switch between this and joining arrays, and I'm not exceptionally consistent in this example area.

[CARDS[0], *SUITS.map { Card[_1, 'A'] }]
Enter fullscreen mode Exit fullscreen mode

First One

first returns the first element, first(n) returns the first n elements in an array. There's no rule that can't be 1 to return an array of just the first element. Granted I can use this in place of splat, but again, slightly inconsistent in this code:

CARDS.first(1) +
SUITS.first(2).flat_map { [Card[_1, 'A'], Card[_1, 'K']] }
Enter fullscreen mode Exit fullscreen mode

Step

I used step to prevent a flush from being a straight as well by skipping every other card:

(0..RANKS.size).step(2).first(5).map { Card['S', RANKS[_1]] }
Enter fullscreen mode Exit fullscreen mode

In this case by using indexes.

Wrapping Up

This has been a pretty wild trip of a post to write, and especially in getting this code to run properly. I hope you enjoy some of the work that went into this, and that you learned a few things about pattern matching.

There are a lot of fun things in Ruby 3, take some time to explore and enjoy!

Discussion (5)

pic
Editor guide
Collapse
mlibby profile image
Michael C. Libby
RANKS  = [*2..10, *%w(J Q K A)].map(&:to_s).freeze
Enter fullscreen mode Exit fullscreen mode

looks clever, but is also sort of hard to read/understand. Even if you're fluent in the syntax and functionality involved it has a higher cognitive load than

RANKS = %w(2 3 4 5 6 7 8 9 10 J Q K A).freeze
Enter fullscreen mode Exit fullscreen mode

which I think does the same thing, but also has the benefit of less typing.

Whenever I see something complicated looking that just does something simple, I spend a lot of time wondering what it is I missed. Am I missing something here?

Collapse
baweaver profile image
Brandon Weaver Author

Not really, most of the point of these posts is not always to be 100% clear as much as be a ride through a lot of new features to explore and think about. If I were writing this as production code it'd look a fair bit different, but that might be a followup article as well.

Most of the reason for that is that it's quicker to type in a REPL when I'm testing things.

Collapse
mlibby profile image
Michael C. Libby

That makes sense, thanks.

Collapse
pitosalas profile image
Pito Salas

Interesting. But a bit of a “too clever” tour the force. While I leave impressed with your ruby arcana chops, I am not much closer to understanding ruby pattern matching.

Collapse
baweaver profile image
Brandon Weaver Author

Then you'll probably like the refactor: gist.github.com/baweaver/0d566a6af...

I'll be writing up on it soon. The original post was written around 2AM and was more of a mad science experiment and me laughing declaring "IT LIVES! IT LIIIIIVES!". The gist is me looking at it the next day shaking my head.