Let’s Program A Prisoner’s Dilemma Part 3: Fight In Cell Block D

Last time we programmed a couple prisoners and ran them through their paces. This time we’re going to write the code for the actual prisoner’s dilemma game.

We start by pseudo-coding our algorithm:

  • First: generate a bunch of prisoners objects.
  • Second: decide how many rounds the game should last.
  • During each round:
    •    Randomly pair up prisoners.
    •    Each prisoner is asked whether or not they want to cooperate with their partner
    •    Prisoners lose points based on the decision they and their partner made
    •    Move on to the next round by randomly sorting prisoners into new pairs
  • When all the rounds are done show the stats of every prisoners so we know who won (by losing the least points).
  • Also show the total sum of all the prisoners’ scores so we can compare different mixes of prisoners.

Breaking News: Prisons Filling Up At An Alarming Rate

Let’s start by figuring out how to set up a group of prisoners. We could just hard code an array of prisoner objects but that would leave us in the bad situation of having to rewrite actual code every time we wanted to try a new mix of prisoners. That sounds both boring and error prone so instead let’s come up with a function we can use to create a mix of prisoners on the fly.

def createPrisoners(saintCount, devilCount)
   prisoners = Array.new
   playerCounter = 0
   saintCount.times{ prisoners.push(Saint.new(playerCounter += 1)) }
   devilCount.times{ prisoners.push(Devil.new(playerCounter += 1)) }
   return prisoners
end

We just tell this function how many saints and devils we want and it gives us exactly what we asked for packaged inside a convenient array. It also keeps count of how many prisoners it has created so far and uses that count to make sure every prisoner has a unique ID.

This function also shows off a rather handy Ruby shortcut for writing a classic “for loop”. If you want a loop that runs a certain number of times just take that number, or a variable holding that number, and add .times{ your code here } to the end. Ex: 4.times{ puts “This will print four times” }

Exciting Next-gen Gameplay!

To actually play the prisoners dilemma we need a group of prisoners and an idea of how many rounds we want the game to last, which means our function definition probably needs to look a little like this:

def playPrisonersDilemma(prisoners, rounds)

Then inside the function itself we want to set up a loop that runs once for every round in the game. During those rounds we want to randomly shuffle our prisoners, divide them up into pairs and then ask every prisoner whether they want to cooperate with their temporary partner or not.

At that point we subtract one point from any player who chose to cooperate and subtract two points from any player who was betrayed by their partner (Yes, a player can be hit by both penalties in the same round).

rounds.times{
   pairs = prisoners.shuffle.each_slice(2)
   pairs.each{ |pair|
      firstPlayerCooperates = pair[0].cooperate?(pair[1].id)
      secondPlayerCooperates = pair[1].cooperate?(pair[0].id)

      if(firstPlayerCooperates)
         pair[0].score -= 1
      else
         pair[1].score -= 2
      end

      if(secondPlayerCooperates)
         pair[1].score -= 1
      else
         pair[0].score -= 2
      end

      pair[0].learnResults(pair[1].id, secondPlayerCooperates)
      pair[1].learnResults(pair[0].id, firstPlayerCooperates)
   }
}

Once again we’re using handy Ruby shortcuts to save a bit of typing. We use rounds.times to set up a loop that will play the game the proper number of times. We then use prionser.shuffle to randomly mix up the prisoners and then chain it to each_slice(2) to divide the random mix into random pairs.

Important note: shuffle and slice don’t change the original array. They instead return a transformed copy that has to be assigned to a variable before you can use it. But for us this is actually a good thing because it means we can just shuffle and slice the same prisoners array at the start of each loop without having to worry about it somehow getting messed up between iterations.

Once we’ve got our random pair array we can use pairs.each to write some quick code we want to run once for each piece of data in our array. The each loop starts out by grabbing an item from the array and storing it inside whatever variable we’ve named with the | variable | syntax.

In our case the pairs array is full of tiny two item arrays, so we call our variable pair. From there it’s pretty simple to each half ot he pair whether or not it wants to cooperate with the other half and then we can assign points. Remember our scoring rules: A player who cooperates loses one point. A player who refuses to cooperate forces the other player to lose two points. We also call the learnResults function to let each prisoner know how the round played out.

After the rounds.times loop has finished playing the game all that’s left is to print out the final score.

#Show the stats for the group as a whole as well as for each individual prisoner
groupScore = 0
prisoners.each{ |prisoner| groupScore += prisoner.score }
puts "The Group's Overall Score was #{groupScore} in #{rounds} rounds with #{prisoners.length} prisoners"
prisoners.sort{ |x, y| y.score <=> x.score}.each{ |prisoner| prisoner.report }

Nothing complicated here. We use an each loop to tally up the scores from every player so we can print a group score. Then we sort the prisoners by score and use another each loop to print out the individual stats for every prisoner.

All Together Now

Gluing together all the bits of code we just wrote leaves us with this nice function:

def playPrisonersDilemma(prisoners, rounds)
   if !prisoners.length.even?
      throw "Prisoners Dilemma requires an even number of participants"
   end

   # Make sure each prisoner starts out with a clean slate
   prisoners.each{ |prisoner| prisoner.score = 0}
   
   rounds.times{
      pairs = prisoners.shuffle.each_slice(2)
      pairs.each{ |pair|
         firstPlayerCooperates = pair[0].cooperate?(pair[1].id)
         secondPlayerCooperates = pair[1].cooperate?(pair[0].id)

         if(firstPlayerCooperates)
            pair[0].score -= 1
         else
            pair[1].score -= 2
         end

         if(secondPlayerCooperates)
            pair[1].score -= 1
         else
            pair[0].score -= 2
         end
      }
   }

   #Show the stats for the group as a whole as well as for each individual prisoner
   groupScore = 0
   prisoners.each{ |prisoner| groupScore += prisoner.score }
   puts "The Group's Overall Score was #{groupScore} in #{rounds} rounds with #{prisoners.length} prisoners"
   prisoners.sort{ |x, y| y.score <=> x.score}.each{ |prisoner| prisoner.report }
end

And now we can test it by having a group of prisoners play the game. Let’s try it out with a group of two saints and two devils playing for a thousand rounds:

prisoners = createPrisoners(2, 2)
playPrisonersDilemma(prisoners, 1000)

This should give you output kind of like this:

The Group’s Overall Score was -6000 in 1000 rounds with 4 prisoners

ID: 4 Score: -650 Strategy: Devil

ID: 3 Score: -650 Strategy: Devil

ID: 2 Score: -2350 Strategy: Saint

ID: 1 Score: -2350 Strategy: Saint

Hmm… looks like the Devils had no trouble at all completely crushing the Saints. We’ll look at that match up in more detail next time.