On this page:
4.2.1 Study Overview
4.2.2 Preamble
4.2.3 Instance-Scoped Variables and Role Assignment
4.2.4 Participant-Scoped Variables
4.2.5 Instructions
4.2.6 Matchmaking
4.2.7 Responder Treatment Assignment
4.2.8 Proposer:   Make Offers and Await Responder Choice
4.2.8.1 The Offers Form
4.2.8.2 Proposer Steps
4.2.9 Responder:   View Offers and Make Decision
4.2.9.1 Control Group Interface
4.2.9.2 Treatment Group Interface
4.2.9.3 Storing the Responder’s Decision
4.2.10 Outcomes
4.2.10.1 Proposer Outcomes
4.2.10.2 Responder Outcomes
4.2.11 Main Study Flow
4.2.12 Key Concepts Recap
4.2.13 Next Steps
8.18

4.2 Tutorial: Proposal Choice StudyπŸ”—

This tutorial walks through a multi-participant study that demonstrates how to create different experiences for participants in different roles. The study implements an economic experiment where three participants propose offers to a fourth participant, who then chooses whether to accept.

You’ll learn how to:

  • Assign participants to different roles within the same study

  • Show completely different interfaces to different participants based on their role

  • Implement control and treatment groups with different decision-making processes

  • Coordinate information flow between participants (what each participant sees vs. reality)

This tutorial builds on concepts from Tutorial: Multi-Participant Studies. If you haven’t worked through that tutorial yet, we recommend doing so first, as this tutorial assumes familiarity with basic matchmaking, instance-scoped variables, and group result storage.

To follow along: You can find the complete code for this study at congame-example-study/proposal-choice.rkt.

4.2.1 Study OverviewπŸ”—

In this study, participants are placed into groups of 4:

  • 3 Proposers: Each makes two offers — an actual offer (the real amount they would give) and a communicated offer (what the Responder sees). These values can differ, allowing Proposers to potentially mislead the Responder.

  • 1 Responder: Sees only the communicated offers from all three Proposers and decides whether to accept or reject.

The Responder is randomly assigned to one of two conditions:

  • Control group: If they accept, a random Proposer is selected for them

  • Treatment group: If they accept, they choose which specific Proposer to pair with

If the Responder accepts (and a Proposer is selected/chosen), the Responder receives the actual offer from that Proposer, which may differ from what was communicated.

4.2.2 PreambleπŸ”—

#lang conscript
 
(require conscript/form0
         conscript/survey-tools
         racket/list
         racket/match)
 
(provide proposal-choice)

As always we begin with #lang conscript, require the additional modules we’ll need, and provide the name of our study. The comment block at the top serves as helpful documentation for anyone reading the code.

We require racket/list for shuffle, and racket/match for match-define and match*, which we’ll use to extract values from complex data structures.

4.2.3 Instance-Scoped Variables and Role AssignmentπŸ”—

;; =============================================================================
;; INSTANCE-SCOPED VARIABLES, ROLE ASSIGNMENT
;; =============================================================================
 
(defvar/instance group-roles)            ; (shuffle '(proposer proposer proposer responder))
(defvar/instance responder-treatments)   ; list of #t/#f for treatment assignment
(defvar role)                            ; 'proposer or 'responder
 
(defstep (assign-roles)
  ; Balanced assignment of 3 proposers and 1 responder
  (with-study-transaction
      (when (or (undefined? group-roles) (null? group-roles))
        (set! group-roles (shuffle '(proposer proposer proposer responder))))
    (set! role (first group-roles))
    (set! group-roles (rest group-roles)))
  (skip))

We use two types of variable scopes here:

  • Instance-scoped variables (group-roles, responder-treatments): These are shared across all participants in the study instance. We use them to coordinate role assignment and treatment assignment.

  • Participant-scoped variables (role): Stores a unique value for each participant.

The assign-roles step assigns each participant to either the 'proposer or 'responder role:

  1. The first participant to reach this step creates a shuffled list of roles: '(proposer proposer proposer responder)

  2. Each participant takes the first role from the list and stores it in their role variable

  3. The role is removed from the shared list, so the next participant gets the next role

The with-study-transaction ensures that even if multiple participants reach this step simultaneously, they each get a unique role without conflicts.

4.2.4 Participant-Scoped VariablesπŸ”—

;; =============================================================================
;; PARTICIPANT-SCOPED VARIABLES
;; =============================================================================
 
(defvar is-responder-treatment?)        ; #t or #f (only for responders)
(defvar actual-offer)                   ; number 0-5
(defvar communicated-offer)             ; number 0-5
(defvar responder-decision)             ; 'accept or 'reject
(defvar chosen-proposer-id)             ; participant id or #f
(defvar outcome)                        ; string describing outcome

These variables store data that is unique to each participant. All participants will have these variables, though not all will use them (for example, only Responders will use is-responder-treatment?, and only Proposers will use actual-offer and communicated-offer).

4.2.5 InstructionsπŸ”—

;; =============================================================================
;; INSTRUCTIONS
;; =============================================================================
 
(defstep (instructions)
  @md{# Proposal Choice Study
 
    Welcome! In this study, you will be placed in a group of 4 participants.
 
    Three participants will be **Proposers** and one will be a **Responder**.
 
    ## How it works:
 
    **Proposers** will make two types of offers:
    - An **actual offer**: the real amount they would give to the Responder (0-5)
    - A **communicated offer**: the amount they tell the Responder (0-5)
 
    These two values can differ.
 
    **Responders** will see only the communicated offers from all three Proposers.
    They can then either accept or reject the offers.
 
    The specific pairing mechanism will be explained to you based on your assigned role.
 
    @button{Continue}})

This is a straightforward instructions step that explains the study to all participants before they are assigned to roles. The instructions are intentionally general since participants don’t yet know whether they will be Proposers or Responders.

4.2.6 MatchmakingπŸ”—

;; =============================================================================
;; MATCHMAKING
;; =============================================================================
 
(defstep (waiter)
  @md{# Please Wait
 
    Please wait while other participants join your group...
 
    @refresh-every[5]})
 
(define matchmaker (make-matchmaker 4))
 
(defstep (matchmake)
  (matchmaker waiter))

The matchmaking section creates groups of 4 participants. We define a waiter step that displays while participants are waiting, then use make-matchmaker to create a matchmaker function for groups of 4. The matchmake step calls this function, passing it the waiter step to display while waiting.

When all 4 participants are matched into a group, they automatically proceed to the next step.

4.2.7 Responder Treatment AssignmentπŸ”—

;; =============================================================================
;; RESPONDER TREATMENT ASSIGNMENT
;; =============================================================================
 
(defstep (assign-responder-treatment)
  ; Balanced: out of every 2 responders, assign 1 to treatment, 1 to control
  (with-study-transaction
    (when (or (undefined? responder-treatments) (null? responder-treatments))
      (set! responder-treatments (shuffle '(#t #f))))
    (set! is-responder-treatment? (first responder-treatments))
    (set! responder-treatments (rest responder-treatments)))
  (skip))

This step uses the same pattern as role assignment, but only affects Responders. Out of every 2 Responders, one is assigned to treatment (#t) and one to control (#f). This ensures balanced assignment across the treatment conditions.

4.2.8 Proposer: Make Offers and Await Responder ChoiceπŸ”—

;; =============================================================================
;; PROPOSER: MAKE OFFERS AND AWAIT RESPONDER CHOICE
;; =============================================================================
 
(define-values (offers-form offers-onsubmit)
  (form+submit
   [actual-offer (ensure binding/number (range/inclusive 0 5) (required))]
   [communicated-offer (ensure binding/number (range/inclusive 0 5) (required))]))
 
(define (render-offers-form rw)
  @md*{
    @rw["actual-offer" @input-number{Actual offer (the real amount you will give): }]
 
    @rw["communicated-offer" @input-number{Communicated offer (what the Responder sees): }]
 
    @|submit-button|})
 
(defstep (proposer-make-offers)
  @md{# Proposer: Make Your Offers
 
    You are a **Proposer**.
 
    Please choose two numbers between 0 and 5:
 
    1. **Actual offer**: The real amount you would give to the Responder if chosen
    2. **Communicated offer**: The amount you tell the Responder you will give
 
    These two values can differ. The Responder will only see your communicated offer.
 
    @form[offers-form offers-onsubmit render-offers-form]})
 
(defstep (store-proposer-offers)
  (store-my-result-in-group! 'offer (list actual-offer communicated-offer))
  (skip))
 
(defstep (proposer-wait-for-responder)
  (define decision (filter values (current-group-member-results 'decision)))
 
  (if (and decision (not (null? decision)))
      (skip)
      @md{# Waiting for Responder
 
        You have submitted your offers. Please wait while the Responder makes their decision...
 
        @refresh-every[5]}))

This section handles the Proposer experience. Let’s break it down:

4.2.8.1 The Offers FormπŸ”—

We use form+submit to create a form that collects both offers. Both fields use binding/number with range/inclusive to constrain the values to 0-5, and both are required.

The render-offers-form function uses md* (note the asterisk) to create reusable content that can be embedded within other content.

4.2.8.2 Proposer StepsπŸ”—
  • proposer-make-offers: Displays the form to collect the two offers from the Proposer

  • store-proposer-offers: Stores both offers as a list under the key 'offer using store-my-result-in-group!, making them accessible to other group members

  • proposer-wait-for-responder: Checks if the Responder has made their decision yet. If so, it skips to the next step. If not, it displays a waiting page that refreshes every 5 seconds. The filter and values are used to remove any #f values from the list before checking if it’s non-empty.

4.2.9 Responder: View Offers and Make DecisionπŸ”—

;; =============================================================================
;; RESPONDER: VIEW OFFERS AND MAKE DECISION
;; =============================================================================
 
(defstep (responder-wait-for-offers)
  ; Check if all 3 proposers have submitted (we're the 4th member, so count should be 3)
  (if (= 3 (current-group-results-count 'offer))
      (skip)
      @md{# Responder: Waiting for Proposers
 
        You are assigned to be the **Responder**.
 
        Please wait while all Proposers submit their offers...
 
        @refresh-every[3]}))
 
; Helper to get sorted list of (pid communicated-offer) pairs
(define (get-offers-with-pids)
  (define offers (current-group-member-results 'offer #:include-ids? #t))
  ;(sort offers < #:key car) ; We could sort offers, perhaps introducing bias
  offers)

The responder-wait-for-offers step checks if all 3 Proposers have submitted their offers by counting the number of results stored under the 'offer key. If the count is 3, it skips to the next step; otherwise, it displays a waiting page.

The helper function get-offers-with-pids retrieves the offers from all group members along with their participant IDs. The #:include-ids? #t argument makes current-group-member-results return a list of (cons pid result) pairs. Note the commented-out line about sorting — the study deliberately doesn’t sort the offers to avoid introducing bias.

4.2.9.1 Control Group InterfaceπŸ”—
(defstep (responder-view-offers-control)
  (define offers (get-offers-with-pids))
 
  (define (accept)
    (set! responder-decision 'accept))
 
  (define (reject)
    (set! responder-decision 'reject))
 
  (match-define (list (cons pid1 (list _ comm1))
                      (cons pid2 (list _ comm2))
                      (cons pid3 (list _ comm3))) offers)
 
  @md{# Responder: View Offers (Control Group)
 
    You are in the **Control Group**.
 
    Here are the communicated offers from the three Proposers:
 
    - **Proposer 1:** @~a[comm1]
    - **Proposer 2:** @~a[comm2]
    - **Proposer 3:** @~a[comm3]
 
    You can either:
    - **Accept**: A random Proposer will be selected for you
    - **Reject all**: End the game with no pairing
 
    @button[accept]{Accept one (Random Selection)}
    @button[reject]{Reject All Offers}})

Let’s break down what’s happening here:

  1. get-offers-with-pids retrieves the offers. Each offer is a cons pair: (cons pid (list actual communicated))

  2. match-define destructures this list. We extract the communicated offers into comm1, comm2, and comm3, using _ to ignore the actual offers and the participant IDs (though we do capture the PIDs as pid1, pid2, and pid3 — they just aren’t used in the control interface)

  3. We define two button handler functions: accept and reject, each setting responder-decision appropriately

  4. The control group interface shows all three communicated offers but only provides two buttons: accept (with random selection) or reject all

4.2.9.2 Treatment Group InterfaceπŸ”—
(defstep (responder-view-offers-treatment)
  (define offers (get-offers-with-pids))
 
  (match-define (list (cons pid1 (list _ comm1))
                      (cons pid2 (list _ comm2))
                      (cons pid3 (list _ comm3))) offers)
 
  (define (choose1)
    (set! responder-decision 'accept)
    (set! chosen-proposer-id pid1))
 
  (define (choose2)
    (set! responder-decision 'accept)
    (set! chosen-proposer-id pid2))
 
  (define (choose3)
    (set! responder-decision 'accept)
    (set! chosen-proposer-id pid3))
 
  (define (reject)
    (set! responder-decision 'reject)
    (set! chosen-proposer-id #f))
 
  @md{# Responder: View Offers (Treatment Group)
 
    You are in the **Treatment Group**.
 
    Here are the communicated offers from the three Proposers:
 
    - **Proposer 1:** @~a[comm1]
    - **Proposer 2:** @~a[comm2]
    - **Proposer 3:** @~a[comm3]
 
    You can either:
    - **Choose** one specific Proposer to pair with
    - **Reject all** offers
 
    @button[choose1]{Choose Proposer 1}
    @button[choose2]{Choose Proposer 2}
    @button[choose3]{Choose Proposer 3}
    @button[reject]{Reject All Offers}})

The treatment group interface follows a similar pattern, but with a crucial difference: instead of accepting with random selection, the Responder gets four buttons — one for each Proposer, plus one to reject all. Each "choose" button stores which Proposer was selected in chosen-proposer-id by capturing the corresponding participant ID (pid1, pid2, or pid3).

4.2.9.3 Storing the Responder’s DecisionπŸ”—
(defstep (store-responder-decision)
  ; If control group and accepted, choose a random proposer
  (when (and (equal? responder-decision 'accept)
             (not is-responder-treatment?))
    (define offers (get-offers-with-pids))
    (define random-proposer (car (list-ref offers (random 0 3))))
    (set! chosen-proposer-id random-proposer))
 
  ; Store the decision for all group members to see
  (store-my-result-in-group! 'decision (list responder-decision chosen-proposer-id))
  (skip))

After the Responder makes their choice, this step handles the control group’s random selection. If the Responder is in the control group and accepted, we randomly select one of the three Proposers:

  1. Get the list of offers (with participant IDs)

  2. Use (random 0 3) to generate a random index (0, 1, or 2)

  3. Use list-ref to get the offer at that index, then car to extract the participant ID from the cons pair

Finally, we store both the decision and the chosen Proposer ID using store-my-result-in-group! so all group members can access it.

4.2.10 OutcomesπŸ”—

4.2.10.1 Proposer OutcomesπŸ”—
;; =============================================================================
;; OUTCOMES
;; =============================================================================
 
(defstep (show-outcome-proposer)
  (define decision-data (first (filter values (current-group-member-results 'decision))))
  (define decision (first decision-data))
  (define chosen-pid (second decision-data))
 
  (cond
    [(equal? decision 'reject)
     (set! outcome "The Responder rejected all offers. You were not chosen.")
     @md{# Outcome
 
       **The Responder rejected all offers.**
 
       You were not chosen.
 
       The game has ended.}]
 
    [(equal? chosen-pid (current-participant-id))
     (set! outcome (format "You were chosen! You gave ~a to the Responder (after communicating ~a)."
                           actual-offer communicated-offer))
     @md{# Outcome
 
       **You were chosen by the Responder!**
 
       Your communicated offer was **@~a[communicated-offer]**.
 
       Your actual offer was **@~a[actual-offer]**.
 
       You have given @~a[actual-offer] to the Responder.}]
 
    [else
     (set! outcome "You were not chosen. The Responder chose another Proposer.")
     @md{# Outcome
 
       **You were not chosen.**
 
       The Responder chose a different Proposer.
 
       The game has ended.}]))

The Proposer outcome step retrieves the Responder’s decision from group results. The filter with values removes any #f values (the Proposers do not record a response for 'decision so current-group-member-results will return a #f value in the list for each Proposer).

The step uses cond to handle three cases:

  • The Responder rejected all offers

  • This Proposer was chosen (determined by comparing chosen-pid with the current participant’s ID)

  • Another Proposer was chosen

When chosen, the Proposer sees both offers to understand what they communicated versus what they actually gave.

4.2.10.2 Responder OutcomesπŸ”—
(defstep (show-outcome-responder)
  (define decision-data (first (filter values (current-group-member-results 'decision #:include-self? #t))))
  (define decision (first decision-data))
  (define chosen-pid (second decision-data))
 
  (cond
    [(equal? decision 'reject)
     (set! outcome "You rejected all offers.")
     @md{# Outcome
 
       You chose to **reject all offers**. What a bummer.
 
       The game has ended.}]
 
    [else
     ; Get the chosen proposer's offer
     (define all-offers (get-offers-with-pids))
     (match-define (list actual communicated)
       (for/or ([offer (in-list all-offers)])
         (match-define (list pid act comm) offer)
         (and (= pid chosen-pid) (list act comm))))
     (set! outcome (format "You received ~a (communicated offer was ~a)." actual communicated))
     @md{# Outcome
 
       You accepted and were paired with Proposer #@~a[chosen-pid].
 
       The **communicated offer** was **@~a[communicated]**.
 
       The **actual offer** was **@~a[actual]**.
 
       You received **@~a[actual]**.}]))

The Responder outcome step also retrieves the decision data, but in this case it’s retrieving the Responder’s own decision (since the current participant is the Responder).

If the Responder accepted, we need to find the chosen Proposer’s offers. We use for/or to loop through all offers until we find the one matching chosen-pid. The for/or form returns the first non-#f value it encounters:

  • For each offer, we destructure it using match-define to get the participant ID, actual offer, and communicated offer

  • If the participant ID matches chosen-pid, we return a list containing the actual and communicated offers

  • Otherwise, the and expression returns #f and the loop continues

We then use another match-define to extract the actual and communicated offers from the result, and display both values to show the Responder what they were promised versus what they actually received.

4.2.11 Main Study FlowπŸ”—

;; =============================================================================
;; MAIN STUDY FLOW
;; =============================================================================
 
(defstudy proposal-choice
  [instructions
   --> matchmake
   --> assign-roles
   --> ,(lambda ()
          (if (equal? role 'responder)
              'assign-responder-treatment
              'proposer-make-offers))]
 
  ; Responder path
  [assign-responder-treatment
   --> responder-wait-for-offers
   --> ,(lambda ()
          (if is-responder-treatment?
              'responder-view-offers-treatment
              'responder-view-offers-control))]
 
  [responder-view-offers-control
   --> store-responder-decision
   --> show-outcome-responder]
 
  [responder-view-offers-treatment
   --> store-responder-decision
   --> show-outcome-responder]
 
  ; Proposer path
  [proposer-make-offers
   --> store-proposer-offers
   --> proposer-wait-for-responder
   --> show-outcome-proposer]
 
  [show-outcome-proposer --> show-outcome-proposer]
  [show-outcome-responder --> show-outcome-responder])

The transition graph uses inline lambda functions to route participants down different paths based on their role and treatment assignment. All participants start with the same sequence: instructions, matchmake, and assign-roles. After role assignment, the first conditional transition checks (equal? role 'responder) to send Responders to 'assign-responder-treatment and Proposers to 'proposer-make-offers.

Responders then encounter a second conditional transition that checks is-responder-treatment? to route them to either 'responder-view-offers-treatment or 'responder-view-offers-control. Both responder paths converge at store-responder-decision and end at show-outcome-responder. Meanwhile, Proposers follow their own linear path through making offers, storing them, waiting for the Responder’s decision, and seeing their outcome.

The final two transitions ([show-outcome-proposer --> show-outcome-proposer] and [show-outcome-responder --> show-outcome-responder]) make these terminal steps where participants remain once the study is complete.

4.2.12 Key Concepts RecapπŸ”—

This tutorial demonstrated several advanced patterns for multi-participant studies:

  • Role-based experiences: Using instance-scoped variables and conditional transitions to assign participants to different roles with completely different interfaces

  • Conditional UI rendering: Creating multiple step definitions for different conditions (control vs. treatment) and routing participants to the appropriate version

  • Information asymmetry: Storing multiple values (actual vs. communicated offers) and selectively revealing different information to different participants

  • Pattern matching with match-define: Extracting values from complex nested data structures returned by group result functions

  • Conditional data manipulation: Implementing random selection in code (control group) versus user selection (treatment group) before storing the final decision

  • Coordination through waiting steps: Using current-group-results-count and refresh-every to create waiting steps that poll until all participants are ready

4.2.13 Next StepsπŸ”—

This study demonstrates how to create rich, multi-role experiments with different experiences for different participants. You might want to explore:

  • Add multiple rounds to the study, allowing participants to learn and adapt their strategies over time

  • Implement feedback systems where participants can rate or comment on their experience

  • Consult the Conscript Cookbook for more recipes and patterns for common study tasks