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:
NUM_ROUNDS = 3
to Constantsin_all_rounds()
to get data from previous roundsvars_for_template
@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 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
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
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
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
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:
get_timeout_seconds
for dynamic timesbefore_next_page
Game Logic:
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
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)
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
]
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>
Browser βββββliveSend()ββββ> Server
β β
β β
βββββliveRecv()βββββ live_method()
Key Components:
live_method
: Server-side function processing updatesliveSend()
: JavaScript function sending data to serverliveRecv()
: JavaScript callback receiving server responseBenefits:
__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
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>
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:
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:
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:
JSFiddle @jsfiddle.net:
π‘ Best for:
Cursor Editor @cursor.com:
π‘ Best for:
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:
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: