On this page:
3.2.1 Modeling our data
3.2.1.1 Understanding variables and scope
3.2.1.2 Prisoner’s Dilemma variables
3.2.2 The “continue” step
3.2.3 Pairing up Prisoners
3.2.4 Making choices
3.2.5 Sentencing time
3.2.6 Testing the Prisoner’s Dilemma
8.14

3.2 Conscript tutorial: the Prisoner’s DilemmaπŸ”—

The Prisoner’s Dilemma is a classic problem in game theory that demonstrates how two rational individuals might not cooperate, even if it is in their best interest to do so. In its standard form, two suspects are arrested and interrogated separately. If both remain silent (that is, if they cooperate with each other), they each receive a light sentence. If one betrays the other (defects) while the other stays silent, the defector goes free, and the silent prisoner gets the maximum sentence. If both betray each other, they each receive a moderate sentence. The dilemma arises because betrayal is the dominant strategy for both, leading to a worse collective outcome than mutual cooperation.

In this tutorial, we’ll write a study that matches each participant with one other participant, and pits the two against each other in a “Prisoner’s Dilemma”.

Until now, our tutorial studies have only recorded and considered the responses of individual participants in isolation. But in the Prisoner’s Dilemma that’s not enough: each participant’s response will need to be compared with that of one other participant.

In doing so, you’ll learn how to design studies that match people into groups, and that incorporate responses of multiple people when calculating results.

To follow along: open DrRacket on your computer and create a new file that starts with #lang conscript.

3.2.1 Modeling our dataπŸ”—

As with all studies, we start by defining variables to keep track of responses and results of the study.

The wrinkle in this case is that our study code needs to be able to see the responses of other participants, not just the current one — because each person’s outcome depends on how their response matches up with the response of the person they were paired with.

We can do this by adjusting the scope of the variables we declare.

3.2.1.1 Understanding variables and scopeπŸ”—

A variable’s scope is the context in which its value is recorded and shared:

Consider an example study that begins with the following code:

(defvar myval)
 
(defstep (get-random)
  (set! myval (random 10))) ; random number 0–9 for current participant
 
; ...

Let’s say Alice and Bob both take this study: when Alice participates, she gets 8 stored in myval, and when Bob participates, he gets 6. During Alice’s participation, code that looks at myval’s value will see 8 there; likewise it will see 6 when when Bob is the current participant. If, while Alice is participating, the study code overwrites myval with (set! myval 9), the value of Bob’s myval will remain 6.

We can change the scope of a variable by changing the way we define it:

(defvar/instance myval)
 
(defstep (get-random)
  (set! myval (random 10))) ; random number 0–9 for current participant
 
; ...

Here we have changed the definition of myval to use defvar/instance instead of defvar, which means that myval now has instance scope.

Now, when Alice and Bob take this study (assuming they enroll in the same study instance, they share the value of myval. If Bob participants after Alice, whatever random number the study generates for him during the get-random step will overwrite the value that was put there during Alice’s participation.

3.2.1.2 Prisoner’s Dilemma variablesπŸ”—

The Prisoner’s Dilemma is modeled around pairs of prisoners, each of whom can provide one of two responses: cooperate or defect.

This suggests a nested data structure that is more complicated than simple strings and numbers. We want to be able to look up the pair that the current participant is assigned to, and to see the choice of each prisoner in the pair:

      ┏━━━━━━━━━All prisoner groups━━━━━━━━━━┓

                                            

      ┃ Group ID ──▶ Group━━━━━━━━━━━━━━━━━┓ ┃

                    ┃ Prisoner ID──▶Choice┃ ┃

                    ┃ Prisoner ID──▶Choice┃ ┃

                    ┗━━━━━━━━━━━━━━━━━━━━━┛ ┃

                                            

      ┃ Group ID ──▶ Group━━━━━━━━━━━━━━━━━┓ ┃

                    ┃ Prisoner ID──▶Choice┃ ┃

                    ┃ Prisoner ID──▶Choice┃ ┃

                    ┗━━━━━━━━━━━━━━━━━━━━━┛ ┃

      ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Racket provides hash tables for implementing these kinds of Lookup key → value pairs. We won’t get into the details of hash tables here. The key point is that the box labeled All prisoner groups constitutes a single variable that can hold the nested lookup table inside. We will declare this variable with instance scope so that it will be shared between all participants in the study instance.

See Hash Tables in the Racket Guide for more info on hash tables (called “dictionaries” in some other languages)

So here’s how we start our study:

#lang conscript
 
(defvar/instance all-prisoner-groups)
(defvar prison-sentence)

The all-prisoner-groups variable is declared with defvar/instance to give it instance scope. We don’t need to actually specify the details of its structure in the code at this stage — we just need to give it a name and a scope.

The (defvar prison-sentence) line is familiar enough: this is where we’ll record the result of the study for the current participant (the length of the prison sentence they end up with). Because it is declared with defvar, it will have participant scope.

3.2.2 The “continue” stepπŸ”—

The first step in our study will be a simple one explaining what is about to happen:

(defstep (intro)
  @md{# Prisoner's Dilemma
 
  You are a suspect in a crime investigation. Your accomplice (another
  study participant) has also been arrested and is being held separately.
  Each of you has a choice: will you attempt to **cooperate** with your
  accomplice by staying silent, or will you **defect** and admit everything
  to the police, betraying your partner?
 
  * If you both choose to cooperate with each other, you’ll each get a 1-year
    prison sentence.
 
  * If you choose to defect and your partner tries to cooperate, you’ll go free
    and your partner will get a 20-year prison sentence.
 
  * If you both try to betray each other, you’ll each receive a 5-year prison
    sentence.
 
  @button{Continue...}})

In concrete programming terms, this study step is implemented as a function with no arguments (here named intro) that returns a study page.

The @ and surrounding curly-style {} indicate that we’re calling the md function using “Scribble Syntax” in Conscript, to make it easier to intermingle form elements and text.

The md function can take any mix of Markdown and form controls and produce a study page for us.

By adding a button, we give the user a way to move on to the next step.

3.2.3 Pairing up PrisonersπŸ”—
(defstep (waiter)
  @md{# Please Wait
 
      Please wait while another participant joins the queue.
 
      @refresh-every[5]})
 
(define matchmaker (make-matchmaker 2))
 
(defstep (pair-with-someone)
  (matchmaker waiter))

Conscript gives you functions that handle the messy work of matching up people into groups.

make-matchmaker gives you a function that takes 1 argument, that argument should be a procedure that produces a study step/page. When you call this matchmaker function, it will put the current participant in the current partial group, and display the page using the argument you gave it. Every time that page is refreshed, it checks to see if the group has been filled up by other participants; if not it shows the same page again. If the group is filled, the matchmaker function will skip the user to the next step in the study.

Here we’re creating a function matchmaker that keeps group sizes to 2 members. Inside the pair-with-someone step we call that function, giving it a study step waiter that refreshes the page every 5 seconds. That refresh will in turn kick back to the pair-with-someone step, keeping the loop going until another participant fills the other slot in the current group.

3.2.4 Making choicesπŸ”—
(require data/monocle)
(define (&my-choice)
  (parameterize ([current-hash-maker hash])
    (&opt-hash-ref*
     (get-current-group)
     (current-participant-id))))
 
(define (make-choice! choice)
  (with-study-transaction
    (set! all-prisoner-groups ((&my-choice) (if-undefined all-prisoner-groups (hash)) choice))))

The all-prisoner-groups variable is a hash table whose keys are all the groups in the study instance. If we get that table’s value for get-current-group, we get another hash table whose keys are the participants in that group, and whose values are their individual choices.

At some point Congame will provide helpful abstractions for doing this in a simpler way.

This code is all very complicated but it’s basically saying “take the whole all-prisoner-groups hash table and update just the value for the current participant in the current group.”

(defstep (make-choice)
  (define (cooperate)
    (make-choice! 'cooperate))
 
  (define (defect)
    (make-choice! 'defect))
 
  @md{# Make Your Choice
 
      @button[#:id "cooperate" cooperate]{Cooperate}
      @button[#:id "defect" defect]{Defect}})

The page shows you two buttons: you can cooperate or defect. Like we said before, buttons move you to the next step. But these also are given functions that do additional work to record the choice that was made. Yes, you can make functions inside functions. We do so inside this defstep because these functions won’t be used anywhere else. The two functions in turn use the ‘make-choice!‘ function we defined above.

(defstep (wait)
  (if (= (hash-count (hash-ref all-prisoner-groups (get-current-group))) 1)
      @md{# Please Wait
 
          Please wait for the other participant to make their choice...
 
          @refresh-every[5]}
      (skip)))

Even after you’ve made your choice, you can’t really move on until the other person in your group has made theirs. So this step checks to see if there are 2 choices recorded in the current group – if not, displays a page that refreshes every 5 seconds (which triggers the same step again); but if it finds 2 choices, it automatically skips to the next step.

3.2.5 Sentencing timeπŸ”—
; Helper function
(define (group->choices group)
  (match group
    [(hash (current-participant-id) my-choice
           #:rest (app hash-values (list their-choice)))
     (values my-choice their-choice)]))
 
(defstep (display-result)
  (define my-group (hash-ref all-prisoner-groups (get-current-group)))
  (define-values (my-choice their-choice)
    (group->choices my-group))
  (set! outcome
        (match* (my-choice their-choice)
          [('cooperate 'cooperate) 1]
          [('cooperate 'defect) 20]
          [('defect 'defect) 5]
          [('defect 'cooperate) 0]))
 
  @md{# Result
 
      You get @~a[outcome] years of prison.})

The current “group” is a hash of participant IDs to choices. We know what the current participant’s ID is, but we don’t know the ID of the rando we got paired with (and we don’t care). So the group->choices helper function is a way to say “find my choice in the current group and put it here, and whatever the other person’s response was, put it over there.”

Then we set outcome to the result of the paired choices. This records it in the database. We also display it to the user (~a converts the number to a string).

(defstudy prisoners-dilemma
  [intro --> pair-with-someone --> make-choice --> wait --> display-result]
  [display-result --> display-result])
Define the whole study.
3.2.6 Testing the Prisoner’s DilemmaπŸ”—
Upload and test; tricks for logging in anonymously in another tab to simulate a second participant