Blog Image
PRESS-YOUR-LUCK

Pairs: Coding

For our second game, Pairs, we move away from the trick-taking of Agram to a different genre, and switch up the player goal from winning tricks to avoiding points. These new wrinkles will show themselves in the coding, statistics, and heuristics as we dive into our analysis.

Pairs is a press-your-luck card game for 2 to 6 players, described by designers James Ernest and Paul Peterson as a new classic pub game, which I interpret to mean it is meant to be a light, fun way to pass the time when you’re drinking with friends. It was released in 2014 following a very successful kickstarter, and includes many variant decks and artwork, some of which are based on the world of The Kingkiller Chronicle by Patrick Rothfuss.

First, let’s see how you play!

Rules

Pairs uses a custom deck of 55 cards, containing one card of value 1, two cards of value 2, etc., up to ten cards of value 10. Each round players are dealt one card to a face-up hand, and then players cycle in turn order to either end the round by scoring the minimum value card in play or draw another card into their hand. If the drawn card is the same value as a card currently in their hand, the player scores that many points and the round is over. The first player to score a set number of points over multiple rounds is the loser, so players strive to minimize their points.

RECYCLE Coding

To illustrate how we encode these rules computationally, we will walk through in detail the RECYCLE code for Pairs.

We start by declaring the number of players as the 'NUMP variable, and place each player on their own team. (Teams will matter more in later games, but for these initial games, players are all on their own.)

Next, we create the triangular deck, giving each card a single attribute of VALUE. The repeat action is put to good use here to create the multiple cards of each value.

(game
 (declare 2 'NUMP)
 (setup  
  (create players 'NUMP)
  (create teams (0) (1))
  
  ;; Create the deck source
  (repeat 1 (create deck (game iloc STOCK) (deck (VALUE (ONE))))) 
  (repeat 2 (create deck (game iloc STOCK) (deck (VALUE (TWO)))))        
  (repeat 3 (create deck (game iloc STOCK) (deck (VALUE (THREE)))))         
  (repeat 4 (create deck (game iloc STOCK) (deck (VALUE (FOUR)))))       
  (repeat 5 (create deck (game iloc STOCK) (deck (VALUE (FIVE)))))        
  (repeat 6 (create deck (game iloc STOCK) (deck (VALUE (SIX)))))       
  (repeat 7 (create deck (game iloc STOCK) (deck (VALUE (SEVEN)))))         
  (repeat 8 (create deck (game iloc STOCK) (deck (VALUE (EIGHT)))))         
  (repeat 9 (create deck (game iloc STOCK) (deck (VALUE (NINE)))))       
  (repeat 10 (create deck (game iloc STOCK) (deck (VALUE (TEN))))))        

So we can compare the cards quickly, a PointMap is created to assign the string attributes for VALUE to integers.

The final part of the setup is to shuffle the deck, then burn through 5 cards to the THROWOUT pile, to make the game a little more unpredictable.

 (do 
     (
      (put points 'POINTS 
           (
            ((VALUE (TEN)) 10)
            ((VALUE (NINE)) 9)
            ((VALUE (EIGHT)) 8)
            ((VALUE (SEVEN)) 7)
            ((VALUE (SIX)) 6)
            ((VALUE (FIVE)) 5)
            ((VALUE (FOUR)) 4)
            ((VALUE (THREE)) 3)
            ((VALUE (TWO)) 2)
            ((VALUE (ONE)) 1)))
      
      (shuffle (game iloc STOCK))
      (repeat 5 
              (move (top (game iloc STOCK))
                    (top (game iloc THROWOUT)))))) 

Now we start to play the game. This first stage sets up the rounds, and will continue until one player has enough points to lose the game.

How many points does it take to lose? The rules state “Take 60, divide by the number of players, then add 1.” Thankfully 60 is evenly divisible by 2, 3, 4, 5, and 6, so we’ll always have an integer as the loss threshold.

 (stage player
        (end 
         (any player 'P 
              (>= (sum ('P vloc SCORING) using 'POINTS) (+ (// 60 'NUMP) 1))))
        

The first action of each round is for the game to check if there are enough cards to deal out an initial card to each player. If not, we will need to reset the STOCK location by adding in all of the cards that were thrown out earlier to the THROWOUT location, along with those in the DISCARD location. The STOCK is then shuffled again, and 5 random cards are moved to THROWOUT to fog up the game.

(do 
    (
     
     ((< (size (game iloc STOCK)) 'NUMP)
      (do 
          (
           (repeat all
                   (move (top (game iloc THROWOUT))
                         (top (game iloc STOCK))))
           (repeat all
                   (move (top (game vloc DISCARD))
                         (top (game iloc STOCK))))
           (shuffle (game iloc STOCK))
           (repeat 5 
                   (move (top (game iloc STOCK))
                         (top (game iloc THROWOUT)))))))

We are secure that each player can get a card to start, so we deal each player a card from STOCK, which they place in their face-up HAND. The player with the smallest card will go first, since they are currently “winning” the game.

If there is a tie, then technically, we should add another stage here, which would deal new card and try again to find the minimum of those just dealt, repeating until a clear minimum card emerges.

But, for simplicity, our simulation engine will just break the tie randomly.

 (all player 'P 
      (move (top (game iloc STOCK))
            (top ('P vloc HAND))))
 
 (cycle current (owner (min (union (all player 'P 
                                        ('P vloc HAND))) using 'POINTS)))))

Players now take turns in an inner stage, pressing their luck until one has a pair or stops voluntarily.

Before each player’s turn, however, we must check the deck again, and reset if there are not enough cards for a deal.

(stage player
       (end 
        (== (game sto FINISHED) 1))
       
       (do   
           (
            
            ((== (size (game iloc STOCK)) 0)
             (do 
                 (
                  (repeat all
                          (move (top (game iloc THROWOUT))
                                (top (game iloc STOCK))))
                  (repeat all
                          (move (top (game vloc DISCARD))
                                (top (game iloc STOCK))))
                  (shuffle (game iloc STOCK))
                  (repeat 5 
                          (move (top (game iloc STOCK))
                                (top (game iloc THROWOUT)))))))))

Here is the code for the player’s choice. First, they can either stop their turn, by moving the smallest card in anyone’s HAND to their SCORING location, and set the stage to be finished after their turn. Alternately, they can press-their-luck and deal the top card from the STOCK to their HAND.

   (choice 
     (
     (do 
         (
          (move (actual (min (union (all player 'P ('P vloc HAND))) using 'POINTS))
                (top ((current player) vloc SCORING)))
          (set (game sto FINISHED) 1)))
     
     (move (top (game iloc STOCK))
           (top ((current player) vloc HAND)))))
   

The second choice above could result in a pair of cards in the player’s HAND, two cards with the same value. If this is found, the round is ended, and one of those cards of the pair is moved to the player’s SCORING location.

The cards in a pair are found with the tuples function, which looks through the current player’s HAND for any groups of two cards with the same point value using the 'POINTS PointMap we created earlier.

(do 
   (
    ((> (size (tuples 2 ((current player) vloc HAND) using 'POINTS)) 0)
     (do 
         (
          (set (game sto FINISHED) 1)
          (move (actual (top (top (tuples 2 ((current player) vloc HAND) using 'POINTS))))
                (top ((current player) vloc SCORING)))))))))
        

When the round is over, we move all cards from player’s HAND locations to the DISCARD pile, reset the FINISHED flag for the inner round, and start a new round.

(do 
    (
     (all player 'P
          (repeat all
                  (move (top ('P vloc HAND))
                        (top (game vloc DISCARD)))))
     (set (game sto FINISHED) 0))))
 

To score the game, we will sort the players based on the sum of the cards in their SCORING pile. The player with the highest score loses, and for our purposes, the player with the lowest score wins.

(scoring min (sum ((current player) vloc SCORING) using 'POINTS)))

Up Next

In the next post, we’ll look at the same two questions we asked for Agram, does the player have choices, and is there opportunity for strategy? We’ll also look at the way AI players affect the length of the game for Pairs, something not possible in a trick-taking game like Agram.

Get ready to spin up some simulations with random and AI players and watch the shape of Pairs come together!


comments powered by Disqus