Expanding Experiment Capabilities

Brief Overview of Advanced Options in oTree (if have time)

Exercise: Multi-Round Results Table (guessing game)

Task: Create a results page showing player's performance across all rounds

Required table format:

| Round | Endowment | Difference | Payoff |
|-------|-----------|------------|---------|
|   1   |    100    |     5      |   95    |
|   2   |    100    |    10      |   90    |
|-------|-----------|------------|---------|
| Total |    200    |    15      |   185   |

Steps:

  1. Add NUM_ROUNDS = 3 to Constants
  2. Use in_all_rounds() to get data from previous rounds
  3. Calculate totals using list comprehension
  4. Pass data to template using vars_for_template
  5. Create table using template syntax

Results Page sample Code

@staticmethod
def vars_for_template(player):
all_rounds = player.in_all_rounds()
total_payoff = sum(p.payoff for p in all_rounds)
total_diff = sum(p.difference for p in all_rounds)
return {
    'rounds': all_rounds,
    'total_payoff': total_payoff,
    'total_difference': total_diff,
    'total_endowment': C.ENDOWMENT * C.NUM_ROUNDS
}

Results Template Code


Results Summary



<table class="table">
<thead>
    <tr>
        <th>Round</th>
        <th>Endowment</th>
        <th>Difference</th>
        <th>Payoff</th>
    </tr>
</thead>
<tbody>
    
    <tr>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
    </tr>
    
    <tr class="table-active">
        <td><strong>Total</strong></td>
        <td></td>
        <td></td>
        <td></td>
    </tr>
</tbody>
</table>



πŸ’‘ Uses Bootstrap's table classes for styling

Passing Data Between Rounds and Apps

Between Rounds:

# Get data from rounds
player.in_previous_rounds()  # All previous
player.in_all_rounds()      # Including current
player.in_rounds(1, 3)      # Specific rounds
player.in_round(1)         # Single round

# Example:
total_payoff = sum(p.payoff 
for p in player.in_all_rounds())

Between Apps:

# Store in participant
participant.my_data = [1, 2, 3]

# Access in any app
my_data = participant.my_data

# Note: internally stored in vars
# participant.xyz is same as
# participant.vars['xyz']

πŸ’‘ Tip: Use participant vars for data that needs to persist across apps

Form Fields: Basic & Advanced Usage

Basic Usage:

<!-- Auto-generate all fields -->


<!-- Single field with custom label -->


<!-- Just field name -->

Manual Rendering:

<!-- Just input element -->


<!-- Don't forget errors -->

Custom HTML Widgets:

<!-- Checkbox input -->
<input name="accept" 
   type="checkbox" 
   class="form-check-input"/>

<!-- Range slider -->
<input name="bid" 
   type="range" 
   min="0" max="100"
   class="form-range"/>

<!-- Radio buttons -->
<input name="choice" 
   type="radio" 
   value="A"/>
<input name="choice" 
   type="radio" 
   value="B"/>


πŸ’‘ Note: Custom widgets lose auto-reload data preservation on errors

Buttons: Form Submission & Actions

Form Submit Buttons:

<!-- Default next button -->


<!-- Custom submit buttons -->
<button name="offer_accepted" value="True">
Accept Offer
</button>
<button name="offer_accepted" value="False">
Reject Offer
</button>

<!-- Works with any field type -->
<button name="choice" value="A">
Option A
</button>

Action Buttons:

<!-- Non-submitting button -->
<button type="button" onclick="doSomething()">
Calculate
</button>

<!-- Styled buttons -->
<button class="btn btn-primary" 
    name="action" value="buy">
Buy Now
</button>

<!-- Disabled state -->
<button class="btn btn-secondary" 
    disabled>
Not Available
</button>

πŸ’‘ Tip: Use type="button" for actions that shouldn't submit the form

↗️ elements styling wth bootstrap

Timeouts: Basic & Advanced Usage

Basic Setup:

class MyPage(Page):
# Fixed timeout
timeout_seconds = 60

# Dynamic timeout
@staticmethod
def get_timeout_seconds(player):
    return player.my_timeout
    
# Handle timeout
@staticmethod
def before_next_page(player, timeout_happened):
    if timeout_happened:
        player.decision = 0

Advanced Features:

<!-- Hide timer -->
<style>
.otree-timer {
display: none;
}
</style>

<!-- Custom timer text -->
class MyPage(Page):
timer_text = 'Time left:'

<!-- Soft timeout (no auto-submit) -->
<script>
setTimeout(function() {
alert("Time's up! Please decide.");
}, 60*1000);  // 60 seconds
</script>

πŸ’‘ Tips:

  • Use get_timeout_seconds for dynamic times
  • Handle timeouts in before_next_page
  • Consider soft timeouts for flexibility

Modified Guessing Game (with Groups)

Game Logic:

  • Two players in each group
  • Computer generates a random number
  • Both players make guesses
  • Group payoff: based on average guess accuracy
  • Individual bonus: 20% extra for better guess

Example:

# Computer number: 50
# Player 1 guess: 45 (diff: 5)
# Player 2 guess: 60 (diff: 10)
# Average guess: (45 + 60) / 2 = 52.5
# Base payoff: 100 - |50 - 52.5| = 97.5
# P1 final: 97.5 * 1.2 = 117
# P2 final: 97.5

Implementation: Constants & Models

from otree.api import *

class C(BaseConstants):
NAME_IN_URL = 'group_guessing'
PLAYERS_PER_GROUP = 2
NUM_ROUNDS = 1
ENDOWMENT = cu(100)
BONUS_MULTIPLIER = 1.2
MIN_NUMBER = 0
MAX_NUMBER = 90

class Player(BasePlayer):
guess = models.IntegerField(
    min=C.MIN_NUMBER, max=C.MAX_NUMBER,
    label="Your guess"
)
difference = models.FloatField()  # Individual difference

class Group(BaseGroup):
target_number = models.IntegerField()
avg_guess = models.FloatField()  # Group's average guess
group_difference = models.FloatField()  # Group's accuracy

def creating_session(subsession: Subsession):
for group in subsession.get_groups():
    group.target_number = random.randint(C.MIN_NUMBER, C.MAX_NUMBER)

Pages and Calculations

class MyPage(Page):
form_model = 'player'
form_fields = ['guess']

class ResultsWaitPage(WaitPage):
@staticmethod
def after_all_players_arrive(group: Group):
    players = group.get_players()
    # Calculate average guess
    guesses = [p.guess for p in players]
    group.avg_guess = sum(guesses) / len(guesses)
    
    # Calculate group accuracy
    group.group_difference = abs(group.target_number - group.avg_guess)
    
    # Calculate individual differences
    for p in players:
        p.difference = abs(group.target_number - p.guess)
    
    # Base payoff from group performance
    base_payoff = C.ENDOWMENT - group.group_difference
    
    # Find best performer
    best_player = min(players, key=lambda p: p.difference)
    
    # Assign payoffs
    for p in players:
        if p == best_player:
            p.payoff = base_payoff * C.BONUS_MULTIPLIER
        else:
            p.payoff = base_payoff

page_sequence = [
Instructions, MyPage,
ResultsWaitPage, # Wait for all guesses
Results
]

Updated Results Template

class Results(Page):
@staticmethod
def vars_for_template(player: Player):
    group = player.group
    base_payoff = C.ENDOWMENT - group.group_difference
    is_best_performer = player.payoff > base_payoff
    return {
        'base_payoff': base_payoff,
        'is_best_performer': is_best_performer
    }

Group Results



<div class="card">
<div class="card-body">
    <h5>Target & Guesses</h5>
    <p>Target number: </p>
    <p>Your guess: </p>
    <p>Group's average guess: </p>
    
    <h5>Performance</h5>
    <p>Group's accuracy: </p>
    <p>Your accuracy: </p>
    
    <h5>Payoffs</h5>
    <p>Base group payoff: </p>
    <p>Your final payoff: 
       
    </p>
</div>
</div>



How Live Pages Work

  1. Live Communication Flow:
Browser ─────liveSend()────> Server
↑                           β”‚
β”‚                           β”‚
└────liveRecv()───── live_method()
  1. Key Components:

    • live_method: Server-side function processing updates
    • liveSend(): JavaScript function sending data to server
    • liveRecv(): JavaScript callback receiving server response
  2. Benefits:

    • Real-time updates without page refresh
    • Group coordination without wait pages
    • Interactive UI elements
    • Immediate feedback to users

Try: https://otree-more-demos.herokuapp.com/demo/go_no_go

Live Pages in oTree 5 (guessing game)

  1. Add live method to __init__.py:
class Player(BasePlayer):
guess = models.IntegerField(
    min=C.MIN_NUMBER, max=C.MAX_NUMBER,
    label="Your guess"
)
temp_guess = models.IntegerField(blank=True)  # For live updates
partner_guess = models.IntegerField(blank=True)  # Show partner's guess
current_average = models.FloatField(blank=True)  # Live average

def live_guess(player: Player, data):
group = player.group
my_guess = data.get('guess')

# Update current player's temp guess
player.temp_guess = my_guess

# Get partner's guess
partner = group.get_player_by_id(3 - player.id_in_group)  # 1->2, 2->1

response = dict(
    partner_guess=partner.temp_guess if partner.temp_guess else '?',
    my_guess=my_guess
)

# Calculate average if both guesses exist
if partner.temp_guess and my_guess:
    avg = (partner.temp_guess + my_guess) / 2
    response['average'] = round(avg, 1)
    player.current_average = avg

return {0: response}  # Broadcast to all players

Updated MyPage Template with Custom Fields


Your Decision



<div class="card">
<div class="card-body">
    <p>Please enter your guess:</p>
    
    <!-- Manual field rendering -->
    <div class="form-group">
        <label for="id_guess">Your guess:</label>

    </div>
    
    <!-- Error messages for live updates -->
    <div id="error-message" class="alert alert-danger" 
         style="display: none;">
    </div>
    
    <!-- Live updates -->
    <div class="mt-3">
        <p>Your current guess: <span id="my-guess">not submitted</span></p>
        <p>Partner's guess: <span id="partner-guess">waiting...</span></p>
        <p>Current average: <span id="current-average">-</span></p>
    </div>
</div>
</div>

        




<script>
function liveRecv(data) {
    document.getElementById('my-guess').textContent = data.my_guess;
    document.getElementById('partner-guess').textContent = data.partner_guess;
    if (data.average) {
        document.getElementById('current-average').textContent = data.average;
    }
}

document.getElementById('id_guess').addEventListener('input', function(e) {
    let value = parseInt(this.value);
    if (value >=  && value <= ) {
        liveSend({'guess': value});
        document.getElementById('error-message').style.display = 'none';
    } 
});
</script>


Development Patterns (if have time)

Testing with Bots

Basic Setup:

# tests.py
def test_basic(self):
# Submit pages in sequence
yield pages.Introduction
yield pages.Decision, dict(
    guess=50,
    confidence=7
)
yield pages.Results

# Run with use_browser_bots=True

Validation Testing:

# Test form validation
yield SubmissionMustFail(
pages.Decision, 
dict(guess=150)  # Invalid value
)

# Test expectations
expect(player.payoff, '>', 0)
expect('you won', 'in', self.html)

Advanced Features:

# Test different rounds
if self.round_number == 1:
yield pages.Instructions

# Test player roles
if self.player.id_in_group == 1:
yield pages.Leader, dict(choice='A')
else:
yield pages.Follower, dict(accept=True)

# Test timeouts
yield Submission(
pages.Decision, 
dict(guess=50),
timeout_happened=True
)

πŸ’‘ Tips:

  • Write tests early
  • Test edge cases
  • Verify form validation
  • Check HTML content
  • Simulate timeouts

DRY Principle: Code Reuse Patterns

1. Page Inheritance:

class BasePage(Page):
form_model = 'player'

@staticmethod
def is_displayed(player):
    return player.participant.accept

class Page1(BasePage):
form_fields = ['choice1']

class Page2(BasePage):
form_fields = ['choice2']

2. Field Generation:

def make_field(label):
return models.IntegerField(
    choices=[1,2,3,4,5],
    label=label,
    widget=widgets.RadioSelect
)

class Player(BasePlayer):
q1 = make_field('Question 1')
q2 = make_field('Question 2')

3. Shared Functions:

def get_timeout(player):
return player.participant.expiry - time.time()

class Page1(Page):
get_timeout_seconds = get_timeout

class Page2(Page):
get_timeout_seconds = get_timeout

4. Error Handling:

@staticmethod
def error_message(player, values):
solutions = {
    'q1': 42,
    'q2': 'Ottawa'
}
return {
    f: 'Wrong answer'
    for f, ans in solutions.items()
    if values[f] != ans
}

πŸ’‘ Tips:

  • Use rounds instead of duplicate pages
  • Share common functions
  • Generate similar fields
  • Centralize validation logic

Planning & Testing

1. User Flow Design:

Welcome Page
 β”‚
 β–Ό
Consent? ──No──> Exit
 β”‚
Yes
 β”‚
 β–Ό
Instructions
 β”‚
 β–Ό
Practice Round
 β”‚
 β–Ό
Main Task(s)
 β”‚
 β–Ό
Survey(s)
 β”‚
 β–Ό
Payment

2. Test Before Coding:

# tests.py
def test_user_flow(self):
# Test consent rejection
yield SubmissionMustFail(
    pages.Consent, dict(accept=False)
)

# Test normal flow
yield pages.Consent, dict(accept=True)
yield pages.Instructions
yield pages.Practice

3. Test Tips:

  • Update pages
  • Cognitive load and quizes
  • Try think as tester (once sq root of 3 people enter in bar)
  • Carefully check dependencies and library call

Useful Development Tools

JSFiddle @jsfiddle.net:

  • Live web code editor
  • Instant preview
  • HTML/CSS/JS testing
  • Isolate frontend issues
  • Share code snippets

πŸ’‘ Best for:

  • Testing JavaScript code
  • Debugging UI components
  • Prototyping page elements
  • Validating Bootstrap styles
  • Sharing solutions

Cursor Editor @cursor.com:

  • AI-powered code editor
  • Multi-file management
  • Smart code completion
  • Project-wide refactoring
  • Composer

πŸ’‘ Best for:

  • Managing complex projects
  • Working with multiple files
  • Understanding code relationships
  • Quick code navigation
  • AI-assisted development

JSON Storage in StringField

1. Model Setup:

import json

class Player(BasePlayer):
# Store complex data as JSON
choices_made = models.StringField()
survey_data = models.StringField()

# Functions
def store_choices(player, choices):
player.choices_made = json.dumps(choices)
    

def get_choices(player):
return json.loads(player.choices_made or '[]')

2. Usage Example:

# Store list of choices
player.store_choices([
{'round': 1, 'choice': 'A', 'time': 2.5},
{'round': 2, 'choice': 'B', 'time': 1.8}
])

# Store dictionary
player.survey_data = json.dumps({
'age': 25,
'preferences': ['sports', 'music'],
'ratings': {'q1': 5, 'q2': 3}
})

3. Data Access:

@staticmethod
def vars_for_template(player):
# Get stored choices
choices = json.loads(player.choices_made)
last_choice = choices[-1]['choice']

# Get survey data
survey = json.loads(player.survey_data)
preferences = survey['preferences']

return dict(
    last_choice=last_choice,
    preferences=preferences
)

πŸ’‘ Tips:

  • Always handle empty values
  • Use in live methods
  • Save in participant.vars what needed only for processing

Randomizing App Sequence

Challenge: Randomize order of apps within session (e.g., C→D or D→C)

Solution 1: Multiple Session Configs

SESSION_CONFIGS = [
dict(
    name='sequence_CD',
    app_sequence=['A', 'B', 'C', 'D', 'E'],
    # ...
),
dict(
    name='sequence_DC',
    app_sequence=['A', 'B', 'D', 'C', 'E'],
    # ...
),
]

Solution 2: Dynamic Routing (Advanced)

# In last page of app B
@staticmethod
def app_after_this_page(player):
# Random assignment stored in participant.vars
if not player.participant.vars.get('app_order'):
    import random
    player.participant.vars['app_order'] = random.choice(['CD', 'DC'])

order = player.participant.vars['app_order']
return 'C' if order == 'CD' else 'D'

Thank You!

Questions & Discussion

Contacts:

Special thanks to: