Pai Gow Poker

Posted on 2017-03-30 by nbloomf
Tags: haskell, games

Recently I was in Las Vegas for a conference. While there, I was introduced by a friend to a card game I’d never heard of before called pai gow poker. This post is an exploration of the rules and strategy of this strange game. You can get some background on the game from wikipedia.

By the way – this post is literate Haskell; you can load the source into GHCi and play along.

In order to build an implementation of pai gow poker in software, we first need a complete and precise list of rules. This turns out to be difficult, because (1) every casino makes minor tweaks to the rules which we expect to affect strategy, and (2) as far as I can tell there is no rule set for the game that does not leave a lot of important questions unanswered. This may be because the poker world assumes a lot of implicit knowledge, but is still unnerving, considering the rules of casino games are frequently codified in state gaming commission regulations.

At any rate, here is my best understanding as a non-gambler of the rules for basic pai gow poker.

Players really only have three choices to make: (1) how much to bet, (2) how to set their cards, and (3) whether or not to bank on their turn. In general there is more than one way to divide a seven-card hand into a five-card and a two-card hand, and due to the win condition it is not obvious to me that one way is strictly better than the others. Obviously the Dealer has the advantage (they always do!) but exactly how much of an advantage? To try to answer this, I’ll start with some simplifying assumptions.

  1. The real game is played with seven players, but from the perspective of a non-banker this is really a two-player game. I’ll simulate only two players.
  2. Some casinos tack on extra frilly bits. For instance, under some rules players can bet on the dragon hands, or bet on each other’s hands, or place an “envy” bet that pays in case anyone at the table plays a hand of a given rank. No doubt these change the strategy, but I will ignore this stuff.
  3. Exactly how is the joker (the bug) interpreted? Every definition I can find says something like this: the joker is an ace, unless it can be used to complete a straight or flush, in which case it is the highest-ranked card that completes the hand. A strict reading of this definition would force the player, in some cases, to play a suboptimal hand. For instance, if the player has 5432 and the joker, then playing the bug as a 6 gives the 6-high straight while playing it as an ace gives the ace-low straight. Without the ace-low hack this forces the player into a suboptimal play, and I suspect this is the reason for the ace-low hack in the first place. As another example, suppose the player has 5678 all of one suit, plus the joker. A strict reading of the bug rule here gives an ambiguous outcome. The joker must be a 9, but of what suit? Certainly this hand should be a straight flush, which ranks higher than a straight, but the bug rule is ambiguous. A better interpretation of the bug would be, I think, that the joker is either an ace or the card which yields the highest ranking straight or flush. Because hands are linearly ordered modulo suit, this does away with both the suit ambiguity and the ugly ace-low hack. This is the interpretation of the bug I will use.
  4. Exactly how do we compare a five-card hand to a two-card hand? Certainly two pairs or better is better than any two-card hand, so the tricky part is when the five-card hand is a high card or one pair. In these cases we will truncate the five-card hand at the pair or the two highest ranked cards, and compare as two-card hands.

This is my best understanding after reading the “official” rules from several sources including casinos and gaming commissions. But bear in mind that I have never played this game. :)

Now for the code. We start with some imports.

module PaiGow where

import qualified Data.List                as L
import qualified Data.Set                 as S
import qualified Control.Monad.State.Lazy as ST
import qualified Data.Maybe               as M
import Control.Monad
import Control.Monad.Trans.Class (lift)
import Data.Random (RVar)
import Data.Random.Extras (choice)
import Data.RVar (sampleRVar)

The Deck

Pai gow poker is played with a standard French deck plus one joker. We’ll model the cards with a few types.

data Suit
  -- we impose the usual poker order on suits
  = Clubs | Diamonds | Hearts | Spades
  deriving (Show, Eq, Ord, Enum)

suits :: [Suit]
suits = [Clubs ..]


data Rank
  -- we also impose the aces-high order on ranks
  = Two   | Three | Four | Five | Six
  | Seven | Eight | Nine | Ten  | Jack
  | Queen | King  | Ace
  deriving (Show, Eq, Ord, Enum)

ranks :: [Rank]
ranks = [Two ..]

It will be convenient to distinguish between the “standard” deck of cards and the “full” deck which also includes the joker. So I’ll use two card types: Carte, the French word for card, will represent the standard cards. Card will represent either a standard card or a joker.

data Carte = Carte
  { rank :: Rank
  , suit :: Suit
  } deriving (Show, Eq, Ord)

standardDeck :: [Carte]
standardDeck = [Carte r s | r <- ranks, s <- suits]


data Card = Card Carte | Joker
  deriving (Show, Eq, Ord)

fullDeck :: [Card]
fullDeck = (map Card standardDeck) ++ [Joker]

Sanity check:

$> length fullDeck
53

Note the derived Ord instance for Card; we’ve cooked it up so that cards sort on rank and then suit. This will be handy later.

We derived Show instances for the Suit, Rank, and Card types to make debugging easier. But it’d also be nice to have a more succinct string representation for cards. We’ll use the Pretty class to represent this.

class Pretty t where
  pretty :: t -> String

  -- for convenience
  prettyIO :: t -> IO ()
  prettyIO = putStrLn . pretty


instance (Pretty t) => Pretty (Maybe t) where
  pretty Nothing  = "*"
  pretty (Just x) = pretty x

instance (Pretty t) => Pretty [t] where
  pretty xs = concat $ L.intersperse " " $ map pretty xs

And Unicode conveniently has characters for playing cards. Woo!

instance Pretty Carte where
  pretty (Carte rank suit) = case suit of
    Clubs    -> get "🃒🃓🃔🃕🃖🃗🃘🃙🃚🃛🃝🃞🃑"
    Diamonds -> get "🃂🃃🃄🃅🃆🃇🃈🃉🃊🃋🃍🃎🃁"
    Hearts   -> get "🂲🂳🂴🂵🂶🂷🂸🂹🂺🂻🂽🂾🂱"
    Spades   -> get "🂢🂣🂤🂥🂦🂧🂨🂩🂪🂫🂭🂮🂡"
    where
      get cs =
        -- pattern match should always succeed
        let Just c = L.lookup rank $ zip ranks cs
        in [c]

instance Pretty Card where
  pretty (Card c) = pretty c
  pretty Joker    = "🃏"

For example:

$> Carte Two Diamonds
Carte Two Diamonds
$> pretty $ Carte Two Diamonds
"\127170"
$> prettyIO $ Carte Two Diamonds
🃂

Detecting Hands

We have a type representing the cards. Now let’s think about dividing groups of seven cards into hands.

First, given a list of five cards, what kind of hand is it?

With the joker present, there are 10 different kinds of five-card poker hands. I had never thought about this before, but when comparing poker hands we mod out the suits. That is, hands that differ only by suit are equivalent. The bottom line is although there are \(\binom{53}{5} = 2869685\) different ways to draw 5 cards from the deck, the number of distinct hands is much smaller.

Our Hand5 type represents the possible five-card hands. Note that this type does not depend on Suit at all!

-- arguments to each constructor must be decreasing
data Hand5
  = HighCard Rank Rank Rank Rank Rank
  | OnePair Pair Kicker Kicker Kicker
  | TwoPair Pair Pair Kicker
  | ThreeOfAKind Triplet Kicker Kicker
  | Straight High
  | Flush Rank Rank Rank Rank Rank
  | FullHouse Triplet Pair
  | FourOfAKind Quad Kicker
  | StraightFlush High
  | FiveOfAKind Rank
  deriving (Eq, Ord, Show)

type Kicker  = Rank
type Pair    = Rank
type Triplet = Rank
type High    = Rank
type Quad    = Rank

Also the order on hands is graded lexicographic (!) meaning we can derive our Ord instance. Later on we can compare hands with <. This is what motivated me to tweak the interpretation of the bug – the order on hands becomes much simpler and we don’t need a special case for the ace-low straight.

Now some helper functions.

-- sort items by count, then by rank
tally :: (Ord a) => [a] -> [(a, Integer)]
tally = revlex . count
  where
    count :: (Eq a) => [a] -> [(a, Integer)]
    count [] = []
    count xs =
      let
        x = head xs
        n = sum $ map (const 1) $ filter (==x) xs
      in (x,n):(count $ filter (/= x) xs)

    revlex = L.sortBy $ \(a,h) (b,k) -> if h == k
      then compare b a
      else compare k h


-- detect if items in a list are sequential
isSequential :: (Enum a, Eq a) => [a] -> Bool
isSequential xs = case xs of
  []  -> True
  x:_ -> and $ zipWith (==) xs [x ..]


-- detect if items in a list are all equal
allEqual :: (Eq a) => [a] -> Bool
allEqual list = case list of
  []   -> True
  x:xs -> all (== x) xs


-- all length k sublists
choose :: Integer -> [a] -> [[a]]
choose 0 _  = [[]]
choose _ [] = []
choose k (x:xs) = (map (x:) $ choose (k-1) xs) ++ choose k xs


-- all length k and n-k cuts
cuts :: (Eq a) => Integer -> [a] -> [([a],[a])]
cuts k xs = map (\as -> (as, xs L.\\ as)) $ choose k xs


-- while we're at it
isStraightOrFlush :: Hand5 -> Bool
isStraightOrFlush (Straight _)      = True
isStraightOrFlush (Flush _ _ _ _ _) = True
isStraightOrFlush (StraightFlush _) = True
isStraightOrFlush _                 = False

To detect a five-card hand we start by tallying the cards by rank. If there are at least two cards of equal rank we have an \(N\)-of-a-kind, a full house, or two pairs. If all the cards have different ranks we have to check whether or not they all have the same suit and whether or not their ranks are sequential (with a special case for the ace-low straight).

By the way, note how the partitions of 5 show up in the cases. Neat! Poker hands make way more sense to me now. :)

toHand5 :: [Carte] -> Hand5
toHand5 cs = case tally $ map rank cs of
  [(a,5)] ->              
    FiveOfAKind a

  [(a,4),(b,1)] ->
    FourOfAKind a b

  [(a,3),(b,2)] ->
    FullHouse a b

  [(a,3),(b,1),(c,1)] ->
    ThreeOfAKind a b c

  [(a,2),(b,2),(c,1)] ->
    TwoPair a b c

  [(a,2),(b,1),(c,1),(d,1)] ->
    OnePair a b c d

  [(a,1),(b,1),(c,1),(d,1),(e,1)] ->
    if allEqual $ map suit cs
      then if isSequential [e,d,c,b,a]
        then StraightFlush a
        else if [a,b,c,d,e] == [Ace,Five,Four,Three,Two]
          then StraightFlush Five -- the steel wheel
          else Flush a b c d e
      else if isSequential [e,d,c,b,a]
        then Straight a
        else if [a,b,c,d,e] == [Ace,Five,Four,Three,Two]
          then Straight Five -- the wheel
          else HighCard a b c d e

  _ -> error "toHand5: unrecognized hand!"


-- compare lists of Cartes as hands
compareHand5 :: [Carte] -> [Carte] -> Ordering
compareHand5 as bs = compare (toHand5 as) (toHand5 bs)

-- test lists of Cartes for equality as hands
equalHand5 :: [Carte] -> [Carte] -> Bool
equalHand5 as bs = (toHand5 as) == (toHand5 bs)

As a sanity check, let’s try to count the hands of each type.

handType5 :: Hand5 -> String
handType5 h = case h of
  HighCard _ _ _ _ _ -> "High Card"
  OnePair _ _ _ _    -> "One Pair"
  TwoPair _ _ _      -> "Two Pairs"
  ThreeOfAKind _ _ _ -> "Three of a Kind"
  Straight _         -> "Straight"
  Flush _ _ _ _ _    -> "Flush"
  FullHouse _ _      -> "Full House"
  FourOfAKind _ _    -> "Four of a Kind"
  StraightFlush _    -> "Straight Flush"
  FiveOfAKind _      -> "Five of a Kind"

This command constructs all possible five-card hands and tallies them by hand type. It runs on my machine in about 4 minutes.

$> tally $ map (handType5 . toHand5) $ choose 5 standardDeck

And cleaned up, the output matches the number of hands of each type given on wikipedia. This gives some confidence that we’re counting hands correctly.

Hand Type Number
High Card 1302540
One Pair 1098240
Two Pairs 123552
Three of a Kind 54912
Straight 10200
Flush 5108
Full House 3744
Four of a Kind 624
Straight Flush 40

So now we can recognize five-card hands from a standard deck, and detect when one such hand beats another. Let’s do the same for two-card hands. There are only two kinds of hands:

data Hand2
  = NoPair Rank Rank
  | YesPair Rank
  deriving (Eq, Ord, Show)

And detecting them is simpler:

toHand2 :: [Carte] -> Hand2
toHand2 cs = case tally $ map rank cs of
  [(a,2)] ->              
    YesPair a

  [(a,1),(b,1)] ->
    NoPair a b

  _ -> error "toHand2: unrecognized hand!"

-- test lists of Cartes for equality as hands
equalHand2 :: [Carte] -> [Carte] -> Bool
equalHand2 as bs = (toHand2 as) == (toHand2 bs)

And now to detect when a five-card hand outranks a two-card hand.

compareFiveToTwo :: Hand5 -> Hand2 -> Ordering
compareFiveToTwo h5 h2 = case h5 of
  OnePair a _ _ _ -> case h2 of
    YesPair b -> compare a b
    NoPair _ _ -> GT
  HighCard a b _ _ _ -> case h2 of
    YesPair _ -> LT
    NoPair c d -> compare [a,b] [c,d]
  _ -> GT

A single play consists of a five-card hand and a two-card hand:

data Play = Play
  { fiveCard :: [Carte]
  , twoCard  :: [Carte]
  }

play :: [Carte] -> [Carte] -> Play
play a@[_,_,_,_,_] b@[_,_] = Play a b
play _ _ = error "play: invalid hand!"

instance Pretty Play where
  pretty (Play five two) = pretty five ++ "   " ++ pretty two


equalPlay :: Play -> Play -> Bool
equalPlay p1 p2 = and
  [ equalHand5 (fiveCard p1) (fiveCard p2)
  , equalHand2 (twoCard p1) (twoCard p2)
  ]

beats :: Play -> Play -> Bool
beats p1 p2 = and
  [ (toHand5 $ fiveCard p1) > (toHand5 $ fiveCard p2)
  , (toHand2 $ twoCard p1)  > (toHand2 $ twoCard p2)
  ]

So we can classify and compare both five-card and two-card hands consisting of Cartes. But what about the joker? Here’s how I will handle that: given seven cards, we will build the list of all possible ways to divide the cards and all valid assignments of the joker. Along the way we can filter out any choices that are beaten by other choices, because they necessarily have a lower probability of winning.

-- highest ranked valid assignment of the joker (if present)
mapJoker5 :: [Card] -> [Carte]
mapJoker5 cs = L.sortBy (flip compare) $ L.maximumBy compareHand5 $ do
  c <- standardDeck
  let as = map (to c) cs
  guard $ (Ace == rank c) || (isStraightOrFlush $ toHand5 as)
  return as
  where
    to :: Carte -> Card -> Carte
    to c Joker    = c
    to _ (Card x) = x

-- assign the joker to an ace (if present)
mapJoker2 :: [Card] -> [Carte]
mapJoker2 cs = L.sortBy (flip compare) $ case cs of
  [Card a@(Carte _ _), Card b@(Carte _ _)] -> [a,b]
  [Card a@(Carte _ s), Joker] -> [a, Carte Ace s]
  [Joker, Card a@(Carte _ s)] -> [Carte Ace s, a]
  _ -> error "mapJoker2: unrecognized hand!"

-- all valid hands
playChoices :: [Card] -> [Play]
playChoices cs = L.nubBy equalPlay $ optimal $ do
  (cs2, cs5) <- cuts 2 cs
  let (h2, h5) = (mapJoker2 cs2, mapJoker5 cs5)
  guard $ GT == compareFiveToTwo (toHand5 h5) (toHand2 h2)
  return $ play h5 h2
  where
    -- if one choice is beaten by another, toss it out.
    optimal (p:ps) = if any (\q -> q`beats`p) ps
      then optimal ps
      else p : optimal ps
    optimal ps = ps

As an example, try this command: it takes the first seven cards from the full deck, divides them into two hands in all 21 possible ways, and pretty prints them.

$> sequence_ $ map prettyIO $ playChoices $ take 7 fullDeck

To recap: given seven cards, we can construct a list of all the (maximally ranked) valid hands they can form. We can also compare individual hands. We’re nearly in a position to simulate the game.

Before we can do that, we need to be able to actually deal the cards!

Randomness

To model a game of pai gow, we need to be able to keep track of a deck and randomly draw cards from it. This is a job for the State and RVar monads, which we’ll roll into a transformer stack like so.

type Deal = ST.StateT [Card] RVar

Now an expression of type Deal t is a computation that keeps a deck as state, may use randomness, and returns a value of type t when “run”. We’ll use two helper functions for the “running”.

-- carry out the computation, returning the result.
runDeal :: Deal t -> IO t
runDeal x = runDeal' x >>= (return . fst)

-- carry out the computation, returning both the
-- result and the final state of the deck.
runDeal' :: Deal t -> IO (t, [Card])
runDeal' = sampleRVar . ($ fullDeck) . ST.runStateT

For example, here is a computation that attempts to deal a single card.

drawCard :: Deal (Maybe Card)
drawCard = do
  deck <- ST.get -- get the state of the deck
  case deck of
    [] -> return Nothing
    _  -> do
      c <- lift $ choice deck -- draw a random card
      ST.put $ L.delete c deck -- remove it from the deck
      return $ Just c

Now try running the following command a few times.

$> runDeal drawCard >>= prettyIO

Sequencing a list of drawCards allows us to draw more than one at a time.

drawCards :: Integer -> Deal (Maybe [Card])
drawCards k = do
  deck <- ST.get -- save the state of the deck
  draw <- sequence [drawCard | i <- [1..k]]
  case sequence draw of
    Just cs -> return $ Just cs -- successful draw
    Nothing -> do -- ran out of cards!
      ST.put deck -- return deck to original state
      return Nothing

Now try running the following command a few times.

$> runDeal (drawCards 5) >>= prettyIO

This command draws 7 cards from a fresh deck and prints all the best valid pai gow plays they can form. Try running it a few times.

$> runDeal (drawCards 7) >>= (sequence_ . map prettyIO . playChoices . M.fromJust)

The Game

Finally! We have the parts needed to model a game of pai gow. I’ll make one further simplification: each player has a number of plays to choose from, and we will select one at random. This ignores the House Way for the dealer and one of only three choices the players can make, but implementing a complete pai gow AI is beyond the scope of this post. :) I’ll be happy getting some concrete win percentages even in this simplified situation.

Each round between the player and the banker has one of three possible outcomes.

data Outcome = PlayerWin | Push | BankerWin
  deriving (Eq, Ord, Show)

And the game: first note the asymmetric win conditions for the player and the banker.

beatsBanker :: Play -> Play -> Bool
beatsBanker p b = and
  [ (toHand5 $ fiveCard p) > (toHand5 $ fiveCard b)
  , (toHand2 $ twoCard p)  > (toHand2 $ twoCard b)
  ]

beatsPlayer :: Play -> Play -> Bool
beatsPlayer b p = and
  [ (toHand5 $ fiveCard b) >= (toHand5 $ fiveCard p)
  , (toHand2 $ twoCard b)  >= (toHand2 $ twoCard p)
  ]

-- simulate a round of pai gow
playPaiGow' :: Deal Outcome
playPaiGow' = do
  Just playerHand <- drawCards 7
  Just bankerHand <- drawCards 7
  player <- lift $ choice $ playChoices playerHand
  banker <- lift $ choice $ playChoices bankerHand
  if player `beatsBanker` banker
    then return PlayerWin
    else if banker `beatsPlayer` player
      then return BankerWin
      else return Push


-- simulate k rounds of pai gow
playRounds' :: Integer -> IO [Outcome]
playRounds' k = sequence [runDeal playPaiGow' | i <- [1..k]]

Now this command will run 10 rounds of pai gow and tally the results.

$> playRounds' 10 >>= (return . tally)

I ran the simulation a few times to try to estimate what the win/lose/draw percentages are. It’s pretty slow in GHCi, so this took a while! Of course if I ran the simulation again these numbers would be slightly different, but thanks to the law of large numbers the percentages converge to their ideal values for this simplified version.

Rounds Banker Win Push Player Win
1000 264 473 263
2000 561 891 548
3000 863 1267 870
4000 1132 1764 1104

The first thing I notice is that a plurality of games are pushes. Many sources describe pai gow as a slow game, and this is why: many hands are drawn.

With some concrete numbers we can also estimate the expected value of every dollar bet. If \(p\) is the probability that the player wins and \(q\) the probability they lose, the expected value of 1 dollar bet is \[E = 1.95 \times p + 1 \times (1-p-q) + (-1) \times q = 0.95p - 2q + 1.\] (Remember the rake!) Using the data from the 3000 round run (which is a very small number) I get \(p = 0.29\) and \(q = 0.287\), so that \(E \approx 0.70\).

Strategy?

Here I simulated a game between two players who follow the rules but otherwise make random choices. I doubt this is the optimal strategy. It might be interesting to see what other strategies are possible, but this post is already too long and I am tired. :)

We can think of a strategy here as a mapping [Play] -> Deal Play that takes a list of possible plays (as given by playChoices) and selects one. playPaiGow' does this with the strategy lift . choice for each player (randomly choose one), but by parameterizing on this map we can pass in other strategies.

type Strategy = [Play] -> Deal Play

-- simulate a round of pai gow
playPaiGow :: Strategy -> Strategy -> Deal Outcome
playPaiGow playerStrategy bankerStrategy = do
  -- normally wouldn't pattern match like this,
  -- but if this fails something is wrong
  Just playerHand <- drawCards 7
  Just bankerHand <- drawCards 7
  player <- playerStrategy $ playChoices playerHand
  banker <- bankerStrategy $ playChoices bankerHand
  if player `beatsBanker` banker
    then return PlayerWin
    else if banker `beatsPlayer` player
      then return BankerWin
      else return Push

-- choose an optimal hand at random
randomStrategy :: Strategy
randomStrategy = lift . choice

Anyway, if you’ve followed along in GHCi, you can try writing different strategy – maybe maximize the rank of the five-card hand, or try to favor pushes if no hand beats a majority, or minimize the sum of the ranks of the five-card and two-card hands, or something else. Have fun. :)

main :: IO ()
main = putStrLn "ok"