An open-source platform for behavioral research
CNRS - University of Montpellier - CEE-M
https://www.duboishome.info/dimitri/cours/otree/
Can be used for various types of experiments:
Economic games with strategic interactions
Prisoner’s Dilemma, Ultimatum Game, Public Goods Game, etc.
Individual attitudes
decision-making under risk and uncertainty, time preferences, pro-environmental behavior, etc.
Social preferences
fairness, trust, reciprocity, inequality aversion, etc.
Markets
double auctions, etc.
Public policy evaluation
taxes, subsidies, nudges, etc.
Note
oTree enables controlled, interactive, and reproducible studies on all these topics — making it a powerful tool for both research and applied data science.
In behavioral strategy, historical data often falls short. oTree empowers you to generate your own data (good data beats big data) to test hypotheses and validate recommendations.
Shift from Passive Observation to Active Experimentation
Design Proofs of Concept (PoC)
Master the Decision Environment: Control variables, randomize participants (A/B Testing), and isolate cognitive biases.
Generate “Smart Data”: Collect clean, structured, and purpose-built data that directly answers the client’s question.
Behavioural Insights Team (UK)
Public-private organization solving issues in health, education, finance, etc.
https://www.bi.team/
OECD Behavioural Insights Unit
Policy applications across member countries (taxes, environment, digital security)
https://www.oecd.org/gov/regulatory-policy/behavioural-insights.htm
World Bank – eMBeDS
Behavioral research applied to development, governance, and financial inclusion
https://www.worldbank.org/en/programs/embed
European Commission – JRC Behavioral Insights
Behavioral support for EU policy (energy, food, mobility, trust)
https://knowledge4policy.ec.europa.eu/behavioural-insights_en
Nonprofits & Companies Using Experimental Design
ideas42 (US)
Nonprofit using behavioral science in education, climate, justice, etc.
https://www.ideas42.org/
BEworks (Canada/Global)
Consultancy founded by Dan Ariely, working with businesses & governments
https://www.beworks.com/
Irrational Agency (UK)
Behavioral market research and innovation for private companies
https://www.irrationalagency.com/
Common Cents Lab (US)
Focus on improving financial decisions and well-being
https://www.commoncentslab.org/
Download and install the miniconda distribution: https://www.anaconda.com/download/success
conda env listconda create -n envName python=3.xx (where envName is the name given to the environment and 3.xx is the desired Python version).conda activate envNameconda deactivateconda install jupyterNote
When installing Python packages, Anaconda automatically installs dependencies and verifies version compatibility between the different packages.
💻 Prepare the Python environment
conda create -n otreeEnv python=3.13conda activate otreeEnvpip install otree==5.11.4otree --versionCaution
The current version of oTree is 6.0.13, but it is a very recent version with frequent updates. For stability, we recommend using version 5.11.4 for now.
otree startproject my_otree_projectWarning
Select n for the example games, otherwhise the project will be created with the example games, which can be useful for learning but may be overwhelming for a first project.
→ This will create a folder my_otree_project with the project structure.
Navigate to your project folder cd my_otree_project At this point, you have a basic oTree project.
💻 Download the Pycharm Community version and install it
Intégration of the otree project in PyCharm
Now PyCharm will use the correct Python version and environment for your oTree project.
Caution
On Windows, the otree_env environment is located in AppData (hidden directory), so you may need to enable the display of hidden files in the file explorer to find it.
otree devserverhttp://localhost:8000You’ll see the oTree demo page where you can configure and run experiments. With the development server running, you can now start building apps in your oTree project.
otree startapp my_app to create an app called my_appStructure of an oTree Project:
my_otree_project/
├── settings.py # Project settings (apps, currency, etc.)
├── app1/ # An individual app
│ ├── __init__.py # Defines the logic and handles the UI
│ ├── MyPage.html # HTML template for rendering the page
│ ├── Results.html
├── app2/
│ ├── __init__.py
│ ├── MyPage.html
│ ├── Results.html
| ...
Important
models and pages are in the __init__.py file, templates are in the app directory with *.html extension.
Step 1: Create a new app : Run the following command to create a new app in your project: otree startapp my_first_app
Step 2: Add the app to your project
Example:
💬 This defines a simple app where participants will enter their name and age.
The fields that must be displayed on the webpage need to be listed in the corresponding Python class.
The page_sequence list (at the bottom of the __init__ file) controls the sequence of pages participants see during the experiment.
Example:
💬 This example shows two pages: one where participants enter their name and age, and a second (empty) that will display the results.
Templates are are used to render the HTML interface.
Example:
💬 The {{ formfields }} block automatically renders the fields from MyPage (e.g., name, age).
Step 1: Run the development server : otree devserver
Step 2: Access the app in your browser : http://localhost:8000
💬 From here, you can select your app and run it with the demo participants.
my_first_appC used to store constantsC.VARIABLE_NAMEBuilt-in constants:
NAME_IN_URL: the displayed text in the URL for this applicationPLAYERS_PER_GROUP: the number of players per groupNUM_ROUNDS: the number of roundsTip
You can define custom constants for your app.
In oTree, data is structured using three main models:
These models are defined in __init__.py and are subclasses of:
BaseSubsessionBaseGroupBasePlayermodels module → models.StringField(), models.IntegerField(), etc.IntegerField: Stores whole numbers.
score = models.IntegerField()
FloatField : Stores decimal numbers.
average = models.FloatField()
StringField: Stores text data.
name = models.StringField()
BooleanField: Stores True or False values.
student = models.BooleanField()
CurrencyField: Stores monetary values (custom oTree type).
payoff = models.CurrencyField()
label: Text that appears next to the input field.
age = models.IntegerField(label="Your Age")
choices: Provide a list of options for multiple choice fields.
favorite_color = models.StringField(choices=["Red", "Blue", "Green"])
initial: Set a default value.
score = models.IntegerField(initial=0)
min and max: Define boundaries for numeric inputs.
age = models.IntegerField(label="Your Age", min=18, max=100)
blank: Allows a field to be left empty by participants (default is False). If blank=True, the field can be optional.
age = models.IntegerField(label="Your Age", min=18, max=100, blank=True)
widget: Customize the type of input type (e.g., radio buttons, sliders, etc.).
favorite_color = models.StringField(choices=["Red", "Blue", "Green"], widget=widgets.RadioSelectHorizontal)
Tip
By default, fields with choices appear as a dropdown menu.
Other widget options include RadioSelect and RadioSelectHorizontal.
Fields to include
💡 Steps:
otree startapp demographicssettings.py file__init__.py file in the appThree key components of experiment flow:
Page class.page_sequence list.💬 The participant will first see the Introduction page, then the Survey page, and finally the Results page.
is_displayed
You can control whether a page is shown to a participant using the is_displayed method.
Example:
💬 The ForStudent page will only be displayed if the participant is a student (information that should have been collected earlier).
before_next_page
before_next_page method is used to run logic right before moving to the next page.💬 The player’s payoff is calculated before the display of the Results.
Warning
It supposes that the Player class has a method compute_payoff() that calculates the payoff based on the player’s decision and other relevant information.
To wait for all participants across all groups in a session, use the wait_for_all_groups = True attribute.
Tip
When wait_for_all_groups = True, the Wait Page will only proceed when all groups in the session have reached the Wait Page.
This is useful in experiments where group actions affect each other, or you want to synchronize the entire session.
Use the after_all_players_arrive method to run custom logic once all players reach a WaitPage.
💬 This method is called once all players in the group have arrived at the wait page.
Warning
It supposes that the Group class has a method compute_total_contributions() that calculates the total contributions of the group based on the players’ decisions.
To synchronize across all groups, set wait_for_all_groups = True.
In this case, after_all_players_arrive takes a Subsession object instead of a Group.
💬 This method is called once all players in the subsession have arrived at the wait page.
Warning
It supposes that the Subsession class has a method compute_players_payoffs() that calculates the payoffs of all players.
Important
The argument of after_all_players_arrive changes depending on whether you are waiting for a single group or all groups.
number_sync
Create a multiplayer app (number_sync) where:
Tip
subsession.get_players()average in class Subsession: {{ subsession.average }}vars_for_templateUse vars_for_template() to send variables from the Page class to the HTML template.
This method should return a dictionary of values you want to display.
and in the html file
js_varsSometimes, you may want to use participant data inside JavaScript – for timers, sliders, animations, or interactive visualizations.
oTree provides a method called js_vars() to send Python variables to your JavaScript code inside the HTML page.
Note
You’ll discover later when we start adding JavaScript to the templates
Update your number_sync app to show the distribution of chosen numbers
In the Results page, use vars_for_template() to pass the following values:
💡 You may install numpy and/or pandas in your oTree environment to simplify these calculations
conda install -c conda-forge numpy pandas
creating_session Methodcreating_session method is used to initialize data at the start of the session.Subsession object as an argument, which allows you to access all participants and groups in the session. It has to be defined in the __init__.py file of the app, at the root level (not inside a class).To form groups randomly at the start of the session, you can use the group_randomly() method provided by oTree.
Example:
Other example : assign a boolean value to a field is_paid for each player at the start of the session.
Example:
C.PLAYERS_PER_GROUP.Example with PLAYERS_PER_GROUP = 4
Note
By default, these groups will remain fixed across all rounds, unless you implement custom grouping logic or use dynamic grouping.
Tip
If the application is a repeated game, and players should be randomly grouped once at the start and then stay in the same group
Experiment Parameters:
Flow of the Experiment:
C class. The variable must end with _ROLE.By default:
💬 oTree automatically sets this value in the player.role field.
Warning
If the game involves more complex group structures (e.g., 2 buyers and 2 sellers in a 4-player group), roles must be assigned manually
📌 Only the buyer sees the DecisionBuyer page, and only the seller sees the DecisionSeller page.
You can display different content on the same HTML page based on the player’s role with a conditional structure.
💬 You can also use elif for more complex conditional structures.
In sequential games, a player’s input may depend on another player’s earlier decision.
Example: Buyer/Seller Game
You can limit a player’s input dynamically using the fieldname_max method.
This will:
Note
Similarly, you can use fieldname_min() to set a dynamic minimum and fieldname_choices() to set the list of choices.
Players are paired in groups of 2 with predefined roles:
Both players start with 10 Euros.
3 × amount_sent⬇ to be continued on the next slide
[Instructions, Role, DecisionTrustor, DecisionTrustorWaitForGroup, DecisionTrustee, DecisionTrusteeWaitForGroup, Results]
amount_sent: How much the trustor sends to the trustee.amount_returned: How much the trustor receives back.amount_received: The amount the trustee receives (3× amount sent).amount_sent_back: How much the trustee sends back to the trustor.⬇ to be continued on the next slide
set_amount_received: Calculate and set the amount the trustee receives.set_amount_returned: Calculate and set the amount returned to the trustor.amount_sent_back_max: Set the maximum amount the trustee can send back.compute_payoffs: individual payoff, depending on the roleSimulations are useful to test your experiment without real participants.
In this section, you’ll learn two main techniques:
before_next_page and the option “Advance slowest users”tests.py
These tools help you debug and improve your experiment efficiently.
before_next_page for Automated Executionbefore_next_page method can be used to automate certain actions before moving to the next page.Advance slowest users in the admin interface under the Monitor tab.Note
Advance slowest users button), the amount a player allocates to the public account is automatically set to a random value.How to Test it
Advance slowest users💬 This method helps you ensure that pages transition correctly and that data is being properly generated during automated play.
Tip
You can simulate a full session by opening multiple player links in separate tabs.
tests.pyAutomated tests let you simulate players and generate data via the terminal.
In the folder of your oTree app, create a new Python file called tests.py.
Add the first line below to import the necessary oTree testing tools and your app’s models:
PlayerBot class, which simulates participant behavior.play_round() method to specify the behavior for each round.yield to submit pages normally.Submission(PageName, timeout_happened=True) to simulate timeout behaviorIn the terminal, run the tests using the following command
By default, this runs the test with the num_demo_participants defined in your session config. You can specify more players like this:
To export the simulated data as a CSV, add the --export argument and specify the file path where you want the data saved:
The CSV file will contain all data (players, groups, sessions), like a real experiment.
✅ Perfect for debugging, validating logic, and preparing datasets for analyses.
In your existing apps:
Add before_next_page where appropriate to simulate player input
→ Test using “Advance slowest users” in the browser
Create a tests.py file for each app
→ Simulate full sessions using otree test
Export the data from simulations for inspection
Simulate two apps played by the same participants.
→ In your settings.py, create a new session config with at least two apps in the app_sequence.
Example:
Run simulations with 40 players and export the data as a CSV file.
In the oTree Admin Panel, you can:
Go to the Monitor tab during an active session to follow what’s happening.
What is Exported:
What is Exported:
Once exported, use pandas (Python library for data manipulation) or R to clean and prepare your data.
Remove Unnecessary Columns: Drop columns that are irrelevant to your analysis (e.g., internal ID fields or system-generated variables).
Rename Columns: Rename columns to make them more meaningful or easier to work with in your analysis. For example, rename participant.code to participant.
Check and Correct Data Types: Ensure that columns have the correct data type (e.g., categorical variables are not mistakenly treated as strings).
Create New Variables: Sometimes we need to derive new variables from existing data.
Example: Calculate a score by summing up answers to multiple questions in a survey.
In this example, we clean and merge data from two CSV files: Public Goods Game data and Demographics data.
Step-by-Step Code:
Note
# Keep only relevant columns
columns_kept = ["columns_to_keep"]
df = df[[columns_kept]].copy()
# Rename columns for easier manipulation
df.rename(columns={"participant.code": "participant", "session.code": "session"}, inplace=True)
df.rename(columns={c: c.replace("player.", "") for c in df.columns}, inplace=True)Note
Repeat the process for the socio-demographic data
demog = pd.read_csv("demographics.csv") # Load the CSV file
demog = demog[[columns_kept]].copy() # Keep relevant columns
demog.rename(columns={"participant.code": "participant", "session.code": "session"}, inplace=True)
demog.rename(columns={c: c.replace("player.", "") for c in demog.columns}, inplace=True)Merge the Public Goods data and Demographics data on the participant column
Note
The merge() function combines the cleaned Public Goods data (df) with the Demographics data (demog) using the participant column as the key.
✅ Now you have one unified file with data from both apps.
participant columncleaned_data.csvcustom_exportcustom_export method allows you to define a custom export format for your data.participant.vars (like survey demographics) and attach them directly to the main game’s behavioral data, row by row.# In your main game's __init__.py
def custom_export(players):
# 1. Yield the header row (clean, explicit column names)
yield ['session', 'participant', 'round', 'decision', 'age', 'gender']
# 2. Iterate over players to build the dataset
for p in players:
# 3. Retrieve data of other apps from participant.vars
age = p.participant.vars.get('age', 'N/A')
gender = p.participant.vars.get('gender', 'N/A')
# 4. Yield the combined data row
yield [p.session.code, p.participant.code, p.round_number, p.decision, age, gender]custom_exportcustom_export method as shown above.participant.vars during the survey app.Warning
With otree < 6.0, the custom_export method is not called from tests.py, so you will need to run a demo session and export the data from the admin interface to see the results of your custom export.
The Admin Interface in oTree is where you manage sessions, participants, and monitor experiment progress.
The Admin Interface is the central control hub for running and monitoring experiments.
Rooms let you organize and manage controlled access to your experiment.
Rooms are defined in the settings.py file.
Rooms tab.Example settings.py Configuration:
You can now click on the “Rooms” tab, select the room and create a new session inside this room.
To avoid this, we can restrict access using participant labels.
Tip
To enable participant labels, create a participant_label_file for the room.
Create a Room with Labels in settings.py:
Create the participant_label_file: At the root of your oTree project, create a folder named _rooms. Inside this folder, create a text file (e.g., my_lab.txt). In this file, list one participant label per row.
lab_001
lab_002
lab_003
lab_004
Two Ways to Use Participant Labels:
Manual Entry: Participants are asked to enter their assigned label when they access the room URL.
Automatic Label in URL: You can include the label directly in the URL (e.g., http://serverURL/room/roomName/?participant_label=lab_001).
→ in that case, the participant will automatically join the room roomName with the specified label without needing to enter it manually.
This ensures that each participant joins the session with a unique label and avoids duplicate entries.
To be tested : find your IP address and then in terminal write otree devserver IP:8000 and ask your neighbour to connect to IP:8000/rooms/your_room
The Admin Report is a customizable page added to the admin interface under the tab labeled “Report”.
Why Use the Admin Report?
The Admin Report is useful for tracking session progress or summarizing results.
vars_for_admin_report() at the root of the __init__.py file with a Subsession argument.def vars_for_admin_report(subsession: Subsession):
players_infos = list()
for p in subsession.get_players():
players_infos.append(
dict(
player_group=p.group.id_in_subsession,
player_id_in_group=p.id_in_group,
player.decision=p.decision,
player.payoff=p.payoff,
)
return dict(players_infos=players_infos)Next, create an HTML file called admin_report.html to display the data in the report.
<table class="table">
<thead>
<tr>
<th>Group</th>
<th>Id in group</th>
<th>Decision</th>
<th>Payoff</th>
</tr>
</thead>
<tbody>
{{ for p in players_infos }}
<tr>
<td>g{{ p.player_group }}</td>
<td>p{{ p.player_id_in_group }}</td>
<td>{{ p.player.decision }}</td>
<td>{{ p.player.payoff }}</td>
</tr>
{{ endfor }}
</tbody>
</table>You can further customize the Admin Report by adding different data fields, using graphs, or integrating real-time statistics.
💬 This flexibility allows you to monitor any key metrics of your experiment in real-time.
Create an admin report for one of our existing app.
Many experiments require participants to interact with each other in real-time or to see immediate feedback based on their or others’ actions.
Examples include: auctions, negotiations, multiplayer games in continuous time, etc.
live_methodThe live_method allows for live communication between the web page and the server during the experiment.
Two methods in the webpage with Javascript and a method in the Python page class work together to enable this live interaction.
In the webpage :
liveSend(data): Sends data (usually a dictionary) to the server.liveRecv(data): Receives data from the server and updates the web page.In the Python page class:
live_method(player: Player, data): Handles incoming data from the web page, processes it, and sends a response back to the client.liveSend(data)live_method(player, data)liveRecv(data)liveSend (Client \(\rightarrow\) Server)liveSend is already built into oTree. You simply call it and pass your data as an argument.
liveRecv (Server \(\rightarrow\) Client)liveRecv is a function that you must define. The oTree system will automatically call your function whenever Python sends data back.
The live_method in the corresponding page class receives the data, processes it, and sends a response.
live_method(player: Player, data):
id_in_group (or 0 for all players).class Decision(Page):
def live_method(player: Player, data):
print("Data received from JavaScript:", data) # Debugging
# Process the data (e.g., update player variables, compute results, etc.)
response_data = {'message': 'Data processed successfully!'}
return {0: response_data} # Send response to all players in the groupScenario:
Each player sends a random value to all other players in their group.
in the __init__.py file
in the Decision.html
{{ block content }}
<button type="button" class="btn btn-secondary" onclick="send_random_value()">Send a random value</button>
{{ endblock }}
{{ block scripts }}
<script>
// Function to send a random value to the server
function send_random_value() {
let random_value = Math.random(); // Generate a random number
console.log(`Sending: ${random_value}`);
liveSend({random_value: random_value}); // Send the random value
}
// Function to receive live data from the server
function liveRecv(data) {
console.log('Received:');
for (let [key, value] of Object.entries(data)) {
console.log(`${key}: ${value}`); // Display the received data
}
}
</script>
{{ endblock }}liveSend({random_value}): When the player clicks the button, a random value is generated and sent to the server.
live_method(player, data): The server receives the data, adds the player’s id_in_group to indicate the sender, and sends it back to all group members (return {0: data}).
liveRecv(data): All players in the group receive the updated data and log it in the browser’s console.
App live_counter
Create a simple app where players in a group can increment a shared counter. When a player clicks a button, the counter is incremented, and all group members see the updated value in real-time.
Steps:
live_method to handle the live communication between the players and the server.Objective: Build a 90-second continuous market with 3 Buyers and 3 Sellers.
Market Rules:
new_bid >= best_ask or new_ask <= best_bid). After a transaction, both the buyer and seller involved are removed from the market.timeout_seconds = 90 see dedicated slide.⬇ to be continued on the next slide
In __init__.py, the live_method must validate the market rules at the Group level before broadcasting updates to all participants.
class Trading(Page):
timeout_seconds = 90
@staticmethod
def live_method(player, data):
group = player.group
# 1. Processing a Buyer's Bid
if 'bid' in data and player.role == 'buyer':
bid = data['bid']
# Check if it's the highest market bid and within the buyer's budget
if (group.best_bid is None or bid > group.best_bid) and bid <= player.value:
group.best_bid = bid
# 2. Broadcast the update to the entire group (ID 0)
return {0: {'type': 'market_update', 'best_bid': group.best_bid, 'best_ask': group.best_ask}}
# Equivalent logic for Sellers and 'asks'
# Write the code for when a transaction occurs (e.g., bid >= ask)⬇ to be continued on the next slide
On the HTML page, players use an input field to send their offer. liveRecv dynamically updates everyone’s screen using the exact keys sent by Python.
<p>Current Best Bid: <span id="best-bid-display">--</span></p>
<p>Current Best Ask: <span id="best-ask-display">--</span></p>
<input type="number" id="my-offer">
<button onclick="sendOffer()">Submit Offer</button>
<script>
function sendOffer() {
let offerAmount = parseFloat(document.getElementById('my-offer').value);
// ('bid' or 'ask') based on the player's role
liveSend({'bid': offerAmount});
}
function liveRecv(data) {
if (data.type === 'market_update') {
// Update the DOM elements if the values are not null
if (data.best_bid !== null) {
document.getElementById('best-bid-display').innerText = data.best_bid;
}
if (data.best_ask !== null) {
document.getElementById('best-ask-display').innerText = data.best_ask;
}
} else if (data.type === 'transaction') {
// Handle transaction updates (e.g., show a message, update payoffs, etc.)
}
}
</script>ExtraModelPlayer, Group, Subsession) store exactly one row of data per round.player.current_bid, we overwrite the previous data every time!The solution: ExtraModel
It allows to define custom database tables to record multiple actions per player or per group (a One-to-Many relationship).
ExtraModelIn the __init__.py, define a new class inheriting from ExtraModel. Use models.Link() to connect these records to the standard oTree entities.
from otree.api import *
# Define this alongside your Subsession, Group, and Player classes
class Offer(ExtraModel):
# 1. Relational Links (Foreign Keys)
player = models.Link(Player)
group = models.Link(Group)
# 2. Custom Data Fields
round_number = models.IntegerField()
timestamp = models.StringField()
amount = models.IntegerField()
is_bid = models.BooleanField() # True for Buyers, False for Sellers
Whenever the server processes a valid incoming message, we use the .create() method to insert a new row into our custom database table before broadcasting the update.
class Trading(Page):
@staticmethod
def live_method(player, data):
group = player.group
# Processing a Buyer's Bid
if 'bid' in data and player.role == 'buyer':
bid_amount = data['bid']
# 1. Instantly save every submitted offer to the database
Offer.create(
player=player,
group=group,
round_number=player.round_number,
timestamp=str(datetime.now()),
amount=bid_amount,
is_bid=True
)
# 2. Continue with market logic (check if it's the best bid, etc.)
if (group.best_bid is None or bid_amount > group.best_bid):
group.best_bid = bid_amount
return {0: {'type': 'market_update', 'best_bid': group.best_bid}}live_method, the database might become a bottleneck.ExtraModel instantly, we store the data in a temporary Python list (RAM) using player.participant.vars.before_next_page built-in function to write the entire list to the database in one go.Shift from instant writes to batch processing:
class Trading(Page):
timeout_seconds = 90
@staticmethod
def live_method(player, data):
# 1. Initialize the temporary list in RAM if it doesn't exist
if 'bids_history' not in player.participant.vars:
player.participant.vars['bids_history'] = []
# 2. Append the new action as a dictionary
if 'bid' in data:
player.participant.vars['bids_history'].append(
{
'round_number': player.round_number,
'timestamp': str(datetime.now()),
'amount': data['bid'],
'is_bid': True
}
)
# (Continue with normal market broadcast logic...)
@staticmethod
def before_next_page(player, timeout_happened):
# 3. Retrieve the list from memory
history = player.participant.vars.get('bids_history', [])
# 4. Batch create the records in the database at the very end
for action in history:
Offer.create(
player=player,
group=player.group,
round_number=action['round_number'],
timestamp=action['timestamp'],
amount=action['amount'],
is_bid=action['is_bid']
)ExtraModel), we can store the entire history as a single text string inside the standard Player model.json.dumps(), and save it in a LongStringField.pd.json_normalize() in Pandas) to extract the nested information.Here is how you serialize your in-memory list into a string when the page closes.
import json
class Player(BasePlayer):
# Create a field large enough to hold the text data
bids_history_json = models.LongStringField()
class Trading(Page):
# ... (live_method saves data to player.participant.vars['bids_history'] as seen before) ...
@staticmethod
def before_next_page(player, timeout_happened):
# 1. Retrieve the list from memory
history = player.participant.vars.get('bids_history', [])
# 2. Serialize the Python list of dicts into a JSON string
player.bids_history_json = json.dumps(history)Either using the ExtraModel or the JSON serialization method, implement a way to record all bids and asks submitted by players in the continuous double auction. Ensure that this data is saved correctly and can be accessed for analysis after the experiment.
Bootstrap is a popular, open-source CSS framework that helps create responsive, mobile-first websites with minimal effort.
Bootstrap is included by default in oTree templates.
Boostrap’s website
block styles<style> tags inside the {{ block styles }} block.For larger apps, writing CSS inside HTML becomes messy. The best practice is to use an External CSS file.
static folder and a subfolder for your app (e.g., double_auction).Example:
Bootstrap’s grid system ensures your layout adjusts seamlessly on different devices.
Bootstrap provides pre-defined button styles for quick use.
Example:
Bootstrap offers many other button styles, such as danger (red), warning (yellow), and more.
You can easily create styled tables with Bootstrap classes.
Example:
Bootstrap also provides classes like .table-bordered and .table-hover for additional styles.
Bootstrap Cards are flexible content containers with multiple variants, often used for:
Card Structure:
It is standard industry practice to use AI to help design web interfaces. However, AI is your assistant, not your replacement.
The Golden Rules of AI-Assisted Design:
col-md-8 or a card).{{ formfields }}, {{ next_button }}). If you don’t know the basics, you won’t be able to fix the AI’s mistakes.To get good results, your prompt must be highly specific about the framework and the layout you want.
❌ Bad Prompt: > “Make my oTree page look good.” (The AI will guess and probably break your form).
✅ Good Prompt: > “I am building an oTree experiment. I need a Bootstrap 5 layout for the decision page. Create a 2-column layout. The left column (col-md-8) should contain a Bootstrap Card with the game instructions. The right column (col-md-4) should contain the oTree {{ formfields }} and the {{ next_button }} inside a styled container.”
Take one of your existing oTree apps and enhance its user interface using Bootstrap components.
Highcharts is a powerfull JavaScript library for creating interactive charts.
To use Highcharts, include the library via a CDN in the HTML template:
💬 This loads the Highcharts library
Then, create a container where the chart will be rendered:
💬 id="my_chart" defines the target DOM element where the chart will appear.
JavaScript code in the HTML Template
<script>
let mychart;
document.addEventListener('DOMContentLoaded', function () {
mychart = Highcharts.chart('my_chart', {
chart: { type: 'line' }, // Chart type
title: { text: 'Payoffs Over Time' }, // Chart title
xAxis: { categories: ['Round 1', 'Round 2', 'Round 3'] }, // X-axis categories
yAxis: { title: { text: 'Payoff (ECU)' } }, // Y-axis label
series: [{
name: 'Player 1',
data: [100, 120, 130] // Sample data points
}, {
name: 'Player 2',
data: [90, 110, 150] // Another series of data
}]
});
});
</script>📌 Key elements:
js_varsYou can pass dynamic data from Python to Highcharts using the js_vars() method in your Page class.
<script>
document.addEventListener('DOMContentLoaded', function () {
Highcharts.chart('my_chart', {
chart: { type: 'line' },
title: { text: 'Your Payoffs Over Rounds' },
xAxis: { categories: js_vars.rounds },
yAxis: { title: { text: 'Payoff (ECU)' } },
series: [{
name: 'Your Payoff',
data: js_vars.payoff_data
}]
});
});
</script>In the continuous double auction app, create a Highcharts line chart that displays the transaction prices over time. Update the chart dynamically as transactions occur.
Create a lexicon with the words or sentences translated in the different languages. For example, a file lexicon_en.py:
and a file lexicon_fr.py
Import the language code and depending on it import the corresponding lexicon file
In the __init__.py file in the import section
In the page class you add the lexicon in the vars_for_template method
and then in the html file
For long sentences or whole paragraphs, or if some variables are needed (as player.payoff for example), it is easier to use the if statement
Add a language for one of your applications
{{ formfields }} in oTree, the fields and labels are displayed one below the other.The class='table' attribute adds basic styling using Bootstrap.
Tip
For surveys, it is also possible to use Bootstrap cards to display the questions in a more visually appealing way.
Useful Methods:
player.in_all_rounds: Accesses the data for all rounds.player.in_previous_rounds: Accesses the data for previous rounds only.<table class="table">
<thead>
<tr>
<th>Round</th>
<th>Keep</th>
<th>Contribution</th>
<th>Group Total Contribution</th>
<th>Payoff</th>
</tr>
</thead>
<tbody>
{{ for p in player.in_all_rounds }}
<tr>
<td>{{ p.round_number }}</td>
<td>{{ keep }}</td>
<td>{{ contribution }}</td>
<td>{{ p.group.total_contribution }}</td>
<td>{{ p.payoff }}</td>
</tr>
{{ endfor }}
</tbody>
</table>Tip
Often easier to create the history in the vars_for_template method and then just display it in the HTML.
static/appName in your app folder, and put the image inside.img tag.Caution
You can also use this method for videos and audio files, but it’s generally better to host large media files externally and link to them in the src attribute.
Pages in oTree can have time limits, allowing participants a limited amount of time to respond.
Setting a Timeout:
After 30 seconds, the page will automatically submit and move to the next one.
Use the timeout_happened argument in before_next_page to check if the page timed out.
SESSION_CONFIGSSESSION_CONFIGS, rather than hard-coding them in the app.Example: MPCR in the Public Goods Game: Instead of setting MPCR directly in the C class, you can define it in SESSION_CONFIGS:
In the creating_session method, retrieve the parameter like this:
# ADDITIONAL METHODS
def set_final_payoff(player: Player):
paid_round = random.randint(1, C.NUM_ROUNDS)
player.participant.payoff = player.in_round(paid_round).payoff
# PAGES
class Results(Page):
def before_next_page(player: Player, timeout_happened):
if player.round_number == C.NUM_ROUNDS:
set_final_payoff(player)In each application of the experiment :
# ADDITIONAL METHODS
def set_final_payoff(player: Player):
paid_round = random.randint(1, C.NUM_ROUNDS)
txt_final = f"Round {paid_round} has been randomly selected to be paid"
player.participant.vars["app_name"] = dict(
payoff=player.in_round(paid_round).payoff,
txt_final=txt_final
)
# PAGES
class Results(Page):
def before_next_page(player: Player, timeout_happened):
if player.round_number == C.NUM_ROUNDS:
set_final_payoff(player)In a final application (app_sequence=["welcome", "public_goods", "investment_game", "demographics", "final"]):
# ADDITIONAL METHODS
def select_paid_app(subsession: Subsession):
apps = ["public_goods", "investment_game"] # the list of potentially paid applications
for p in subsession.get_players():
paid_app = random.choice(apps)
p.participant.payoff = p.participant.vars[paid_app]["payoff"]
# PAGES
class BeforeFinalPage(WaitPage):
wait_for_all_groups = True
def after_all_players_arrive(subsession):
select_paid_app(subsession)
class Final(Page):
pass|cu filter to the variablewill display “You have an endowment of €10.00”.
The latest stable version: Python 3.13.
Download and install the miniconda distribution: https://www.anaconda.com/download/success
Useful Anaconda Commands (via Anaconda Prompt)
conda env listconda create -n envName python=3.xx (where envName is the name given to the environment and 3.xx is the desired Python version).conda activate envNameconda deactivateconda install jupyterNote
When installing Python packages, Anaconda automatically installs dependencies and verifies version compatibility between the different packages.
💻 Prepare the Python environment
conda create -n myEnv python=3.13conda activate myEnvconda listbash pythonJupyterLab
conda install jupyterjupyter-lab in the promptKey Rules: 1. Consistency: Indentation must be consistent within a block of code (e.g., always 4 spaces). 2. Blocks of code: Code inside loops, functions, conditionals, and other constructs must be indented.
Example:
Integer: Whole numbers python x = 10
Float: Numbers with decimals
String: Text data
Boolean: True or False values
None: Represents an absence of value
List
Example:
Dictionaries
{}.Basic Usage:
The f before the string allows you to embed variables or expressions directly into the string.
Example with Expressions:
You can embed calculations or any valid Python expression inside an f-string.
Example with Formatting:
The : .2f syntax is used to format numbers (in this case, to show only 2 decimal places).
if, elif, elseUsed to execute code based on certain conditions.
Example:
forUsed to iterate over a sequence (like a list).
Example:
This will print each fruit in the list.
range() FunctionUsed to generate a sequence of numbers, commonly used in loops.
Basic Usage:
This will print numbers from 0 to 4 (5 iterations, starting from 0).
Parameters of range()
stop.start up to, but not including, stop.start to stop, with increments of step.Repeats code as long as a condition is True.
Example :
Calling of function
Parameters: - Variables that are specified in the function definition. - These allow functions to accept input data.
Arguments: - The actual values you pass to the function when calling it.
Example:
a and b are parameters, and 5 and 3 are arguments.
Default Arguments
You can assign a default value to a parameter, so it’s optional when calling the function.
Keyword Arguments
You can pass arguments by specifying the parameter name (makes the code more readable).
Python allows to define functions that accept a variable number of arguments using *args and **kwargs.
Using *args (non-keyword arguments):
Using **kwargs (keyword arguments):
lambda functionSyntax:
Example:
Often used in functions like map(), filter(), or sorting lists by custom criteria.
Defining a Class:
Creating an Object:
self.name, self.score).Example:
Example:
Encapsulation : restricting direct access to some of an object’s components.
Polymorphism : allows different classes to be treated as instances of the same class through inheritance.
Example of Encapsulation:
You don’t need to memorize everything. You just need to know how to find the information.
The Documentation (?)
? before or after the name.2. The Exploration (dir())
Create an new Notebook in Jupyterlab, and for each Exercise copy and paste the instructions, and write your code in the cell below.
age and assign it the value of your age.name and assign it your name.height that stores your height in meters (as a float).fruits that contains the following elements: “apple”, “banana”, “cherry”, “date”.score and assign it a value between 0 and 100.n = 10 and decreases n by 1 in each iteration until n equals 0. Print the value of n in each iteration.multiply(a, b) that returns the product of two numbers a and b.is_even(n) that returns True if the number n is even, and False otherwise.count_vowels(s) that takes a string s and returns the number of vowels (a, e, i, o, u) in the string.student with the following keys: “name”, “age”, “grade”.numbers that contains the numbers from 1 to 10.squares that contains the square of each number in numbers.evens that contains only the even numbers from numbers.Basic HTML Structure:
HTML elements are represented by tags enclosed in < > and < />.
<!DOCTYPE html>: Defines the document type (HTML5).<html>: Root element, contains the entire HTML document.<head>: Metadata, such as the title and links to external resources (CSS, scripts).<body>: The main content of the page, including text, images, links, etc.Example:
A CSS rule consists of a selector and a declaration block.
The selector specifies which HTML elements the rule applies to.
Grouping Selectors: Apply the same style to multiple elements.
Class Selector: Targets elements with a specific class (uses .).
ID Selector: Targets a single element with a specific ID (uses #).
CSS Selectors: ID vs. Class
| Feature | Class | ID |
|---|---|---|
| Concept | Reusable style for many elements. | Unique identifier for one element. |
| Analogy | Like a Uniform (worn by many). | Like a Passport Number (unique). |
| HTML Attribute | class="btn" |
id="submit-btn" |
| CSS Symbol | . (Dot) |
# (Hash) |
| CSS Example | .btn { padding: 10px; } |
#submit-btn { background: green; } |
Tip
<style> tag in the <head> of the HTML document.External CSS is the most common and scalable way to style web pages.
Every HTML element is treated as a box, consisting of the following layers:
Example:
(<div>, <p>, <h1>)
I am a DIV (Block)
(<span>, <a>, <b>)
I am a text with a SPAN (Inline) inside.
<script> tag or in an external file (script.js).let or const.Example:
Example:
When the button is clicked, JavaScript changes the content of the <p> element.
onclick: Triggered when an element is clicked.onmouseover: Triggered when the mouse is over an element.onchange: Triggered when an input field’s value changes.oninput: Triggered when the user types in an input field.Example:
JavaScript makes web pages interactive by responding to these events.
addEventListeneronclick="..." inside HTML works, the modern best practice is to separate your HTML structure from your JavaScript logic.addEventListener() allows you to attach multiple behaviors to a single element cleanly and dynamically.DOMContentLoadednull error).DOMContentLoaded event to ensure the page is fully ready.// Wait for the entire HTML document to be fully loaded
document.addEventListener("DOMContentLoaded", function() {
let actionButton = document.getElementById("my-custom-btn");
// Attach the event listener to the element
actionButton.addEventListener("click", function() {
console.log("Button was clicked!");
});
});js_vars1. In Python (init.py):
2. In HTML/JS:
Fields to include
The player receives an endowment of €100. They must choose an amount to invest in a risky asset. There is a 50% chance of losing the investment entirely, and a 50% chance of having it multiplied by 3. The player keeps whatever amount they choose not to invest.
Payoffs :
In the tests.py file, program:
random.randint(0, 100)).Experiment Parameters:
Flow of the Experiment:
Players are paired in groups of 2 with predefined roles:
Both players start with 10 Euros.
3 × amount_sentApp live_counter
Create a simple app where players in a group can increment a shared counter. When a player clicks a button, the counter is incremented, and all group members see the updated value in real-time.
Steps:
live_method to handle the live communication between the players and the server.Objective: Build a 90-second continuous market with 3 Buyers and 3 Sellers.
Market Rules:
new_bid >= best_ask or new_ask <= best_bid). After a transaction, both the buyer and seller involved are removed from the market.timeout_seconds = 90 see documentation.⬇ to be continued on the next slide
In your __init__.py, your live_method must validate the market rules at the Group level before broadcasting updates to all participants.
class Trading(Page):
timeout_seconds = 90
@staticmethod
def live_method(player, data):
group = player.group
# 1. Processing a Buyer's Bid
if 'bid' in data and player.role == 'buyer':
bid = data['bid']
# Check if it's the highest market bid and within the buyer's budget
if (group.best_bid is None or bid > group.best_bid) and bid <= player.value:
group.best_bid = bid
# 2. Broadcast the update to the entire group (ID 0)
return {0: {'type': 'market_update', 'best_bid': group.best_bid, 'best_ask': group.best_ask}}
# Equivalent logic for Sellers and 'asks'
# ...
# Write the code for when a transaction occurs (e.g., bid >= ask)⬇ to be continued on the next slide
On the HTML page, players use an input field to send their offer. liveRecv dynamically updates everyone’s screen using the exact keys sent by Python.
<p>Current Best Bid: <span id="best-bid-display">--</span></p>
<p>Current Best Ask: <span id="best-ask-display">--</span></p>
<input type="number" id="my-offer">
<button onclick="sendOffer()">Submit Offer</button>
<script>
function sendOffer() {
let offerAmount = parseFloat(document.getElementById('my-offer').value);
// ('bid' or 'ask') based on the player's role
liveSend({'bid': offerAmount});
}
function liveRecv(data) {
if (data.type === 'market_update') {
// Update the DOM elements if the values are not null
if (data.best_bid !== null) {
document.getElementById('best-bid-display').innerText = data.best_bid;
}
if (data.best_ask !== null) {
document.getElementById('best-ask-display').innerText = data.best_ask;
}
} else if (data.type === 'transaction') {
// Handle transaction updates (e.g., show a message, update payoffs, etc.)
}
}
</script>oTree