oTree¶

Homepage¶
About¶
oTree is a framework based on Python that lets you build:
- Multiplayer strategy games, like the prisoner’s dilemma, public goods game, and auctions
- Controlled behavioral experiments in economics, psychology, and related fields
- Surveys and quizzes
Contents:¶
Installing oTree¶
Important note¶
If you publish research done with oTree, you are required to cite this paper. (Citation: Chen, D.L., Schonger, M., Wickens, C., 2016. oTree - An open-source platform for laboratory, online and field experiments. Journal of Behavioral and Experimental Finance, vol 9: 88-97)
Installation¶
If you will use oTree Studio (easiest option), go to otreehub.com.
Read: why you should use oTree Studio.
If you are an advanced programmer, you can use oTree with a text editor.
About Python¶
Below is a tutorial with the basics of Python you need to know to use oTree.
The easiest way to run this file is in IDLE (which is usually bundled in the Python installation).
There are many other good python tutorials online, but note that some of the material covered in those tutorials is not necessary for oTree programming specifically.
Tutorial file¶
A downloadable version is here
.
# Comments start with a # symbol.
####################################################
## 1. Basics
####################################################
# integer
3
# float (floating-point number)
3.14
# Math is what you would expect
1 + 1 # => 2
8 - 1 # => 7
10 * 2 # => 20
35 / 5 # => 7.0
# Enforce precedence with parentheses
(1 + 3) * 2 # => 8
# Boolean Operators
# Note they are
True and False # => False
False or True # => True
# negate with not
not True # => False
not False # => True
# Equality is ==
1 == 1 # => True
2 == 1 # => False
# Inequality is !=
1 != 1 # => False
2 != 1 # => True
# More comparisons
1 < 10 # => True
1 > 10 # => False
2 <= 2 # => True
2 >= 2 # => True
# A string (text) is created with " or '
"This is a string."
'This is also a string.'
# Strings can be added too!
"Hello " + "world!" # => "Hello world!"
# None means an empty/nonexistent value
None # => None
####################################################
## 2. Variables, lists, and dicts
####################################################
# print() displays the value in your command prompt window
print("I'm Python. Nice to meet you!") # => I'm Python. Nice to meet you!
# Variables
some_var = 5
some_var # => 5
# Lists store sequences
li = []
# Add stuff to the end of a list with append
li.append(1) # li is now [1]
li.append(2) # li is now [1, 2]
li.append(3) # li is now [1, 2, 3]
# Access a list like you would any array
# in Python, the first list index is 0, not 1.
li[0] # => 1
# Assign new values to indexes that have already been initialized with =
li[0] = 42
li # => [42, 2, 3]
# You can add lists
other_li = [4, 5, 6]
li + other_li # => [42, 2, 3, 4, 5, 6]
# Get the length with "len()"
len(li) # => 6
# Here is a prefilled dictionary
filled_dict = dict(name='Lancelot', quest="To find the holy grail", favorite_color="Blue")
# Look up values with []
filled_dict['name'] # => 'Lancelot'
# Check for existence of keys in a dictionary with "in"
'name' in filled_dict # => True
'age' in filled_dict # => False
# set the value of a key with a syntax similar to lists
filled_dict["age"] = 30 # now, filled_dict["age"] => 30
####################################################
## 3. Control Flow
####################################################
# Let's just make a variable
some_var = 5
# Here is an if statement.
# prints "some_var is smaller than 10"
if some_var > 10:
print("some_var is totally bigger than 10.")
elif some_var < 10: # This elif clause is optional.
print("some_var is smaller than 10.")
else: # This is optional too.
print("some_var is indeed 10.")
"""
SPECIAL NOTE ABOUT INDENTING
In Python, you must indent your code correctly, or it will not work.
All lines in a block of code must be aligned along the left edge.
When you're inside a code block (e.g. "if", "for", "def"; see below),
you need to indent by 4 spaces.
Examples of wrong indentation:
if some_var > 10:
print("bigger than 10." # error, this line needs to be indented by 4 spaces
if some_var > 10:
print("bigger than 10.")
else: # error, this line needs to be unindented by 1 space
print("less than 10")
"""
"""
For loops iterate over lists
prints:
1
4
9
"""
for x in [1, 2, 3]:
print(x*x)
"""
"range(number)" returns a list of numbers
from zero to the given number MINUS ONE
the following code prints:
0
1
2
3
"""
for i in range(4):
print(i)
####################################################
## 4. Functions
####################################################
# Use "def" to create new functions
def add(x, y):
print('x is', x)
print('y is', y)
return x + y
# Calling functions with parameters
add(5, 6) # => prints out "x is 5 and y is 6" and returns 11
####################################################
## 5. List comprehensions
####################################################
# We can use list comprehensions to loop or filter
numbers = [3,4,5,6,7]
[x*x for x in numbers] # => [9, 16, 25, 36, 49]
numbers = [3, 4, 5, 6, 7]
[x for x in numbers if x > 5] # => [6, 7]
####################################################
## 6. Modules
####################################################
# You can import modules
import random
print(random.random()) # random real between 0 and 1
Credits: This page’s tutorial is adapted from Learn Python in Y Minutes, and is released under the same license.
Tutorial¶
This tutorial will cover the creation of several apps.
First, you should be familiar with Python; we have a simple tutorial here: About Python.
Note
In addition to this tutorial, you should check out oTree Hub’s featured apps section. Find an app that is similar to what you want to build, and learn by example.
Part 1: Simple survey¶
(A video of this tutorial is on YouTube )
Let’s create a simple survey – on the first page, we ask the participant for their name and age, then on the next page, display this info back to them.
Player model¶
In the sidebar, go to the Player model. Let’s add 2 fields:
name
(StringField
, meaning text characters)age
(IntegerField
)
Pages¶
This survey has 2 pages:
- Page 1: players enter their name and age
- Page 2: players see the data they entered on the previous page
So, create 2 pages in your page_sequence
: Survey
and Results
.
Page 1¶
First let’s define Survey
. This page contains a form, so set form_model
to player
and for form_fields
, select name
and age
.
Then, set the template’s title to Enter your information
, and set the content to:
Please enter the following information.
{{ formfields }}
{{ next_button }}
Page 2¶
Now we define Results
.
Set the template’s title
block to Results
and set the content
block to:
<p>Your name is {{ player.name }} and your age is {{ player.age }}.</p>
{{ next_button }}
Define the session config¶
In the sidebar, go to “Session Configs”, create a session config, and add your survey app to the app_sequence
.
Download and run¶
Download the otreezip file and follow the instructions on how to install oTree and run the otreezip file.
If there are any problems, you can ask a question on the oTree discussion group.
Part 2: Public goods game¶
(A video of this tutorial is on YouTube )
We will now create a simple public goods game. The public goods game is a classic game in economics.
This is a three player game where each player is initially endowed with 100 points. Each player individually makes a decision about how many of their points they want to contribute to the group. The combined contributions are multiplied by 2, and then divided evenly three ways and redistributed back to the players.
The full code for the app we will write is here.
Create the app¶
Just as in the previous part of the tutorial, create another app, called my_public_goods
.
Constants¶
Go to your app’s constants class (C
).
(For more info, see Constants.)
- Set
PLAYERS_PER_GROUP
to 3. oTree will then automatically divide players into groups of 3. - The endowment to each player is 1000 points. So, let’s define
ENDOWMENT
and set it to a currency value of1000
. - Each contribution is multiplied by 2. So define an integer
constant called
MULTIPLIER = 2
:
Now we have the following constants:
PLAYERS_PER_GROUP = 3
NUM_ROUNDS = 1
ENDOWMENT = cu(1000)
MULTIPLIER = 2
Models¶
After the game is played,
what data points will we need about each player?
It’s important to record how much each person contributed.
So, go to the Player model and define a contribution
column:
class Player(BasePlayer):
contribution = models.CurrencyField(
min=0,
max=C.ENDOWMENT,
label="How much will you contribute?"
)
We also need to record the payoff the user makes at the end of the game,
but we don’t need to explicitly define a payoff
field,
because in oTree, the Player already contains a payoff
column.
What data points are we interested in recording about each group? We might be interested in knowing the total contributions to the group, and the individual share returned to each player. So, we define those 2 fields on the Group:
class Group(BaseGroup):
total_contribution = models.CurrencyField()
individual_share = models.CurrencyField()
Pages¶
This game has 3 pages:
- Page 1: players decide how much to contribute
- Page 2: Wait page: players wait for others in their group
- Page 3: players are told the results
Page 1: Contribute¶
First let’s define Contribute
. This page contains a form, so
we need to define form_model
and form_fields
.
Specifically, this form should let you set the contribution
field on the player. (For more info, see Forms.)
class Contribute(Page):
form_model = 'player'
form_fields = ['contribution']
Now, we create the HTML template.
Set the title
block to Contribute
,
and the content
block to:
<p>
This is a public goods game with
{{ C.PLAYERS_PER_GROUP }} players per group,
an endowment of {{ C.ENDOWMENT }},
and a multiplier of {{ C.MULTIPLIER }}.
</p>
{{ formfields }}
{{ next_button }}
Page 2: ResultsWaitPage¶
When all players have completed the Contribute
page,
the players’ payoffs can be calculated.
Add a group function called set_payoffs
:
def set_payoffs(group):
players = group.get_players()
contributions = [p.contribution for p in players]
group.total_contribution = sum(contributions)
group.individual_share = group.total_contribution * C.MULTIPLIER / C.PLAYERS_PER_GROUP
for player in players:
player.payoff = C.ENDOWMENT - player.contribution + group.individual_share
After a player makes a
contribution, they cannot see the results page right away; they first
need to wait for the other players to contribute. You therefore need to
add a WaitPage
. Let’s call it ResultsWaitPage
.
When a player arrives at a wait page,
they must wait until all other players in the group have arrived.
Then everyone can proceed to the next page. (For more info, see Wait pages).
Add after_all_players_arrive
to ResultsWaitPage
,
and set it to trigger set_payoffs
:
after_all_players_arrive = 'set_payoffs'
Page 3: Results¶
Now we create a page called Results
.
Set the template’s content to:
<p>
You started with an endowment of {{ C.ENDOWMENT }},
of which you contributed {{ player.contribution }}.
Your group contributed {{ group.total_contribution }},
resulting in an individual share of {{ group.individual_share }}.
Your profit is therefore {{ player.payoff }}.
</p>
{{ next_button }}
Page sequence¶
Make sure your page_sequence is correct:
page_sequence = [
Contribute,
ResultsWaitPage,
Results
]
Define the session config¶
We add another session config with my_public_goods
in the app sequence.
Run the code¶
Load the project again then open your browser to http://localhost:8000
.
Troubleshoot with print()¶
I often read messages on programming forums like, “My program is not working. I can’t find the mistake, even though I have spent hours looking at my code”.
The solution is not to re-read the code until you find an error; it’s to interactively test your program.
The simplest way is using print()
statements.
If you don’t learn this technique, you won’t be able to program games effectively.
You just need to insert a line in your code like this:
print('group.total_contribution is', group.total_contribution)
Put this line in the part of your code that’s not working, such as the payoff function defined above. When you play the game in your browser and that code gets executed, your print statement will be displayed in your command prompt window (not in your web browser).
You can sprinkle lots of prints in your code
print('in payoff function')
contributions = [p.contribution for p in players]
print('contributions:', contributions)
group.total_contribution = sum(contributions)
group.individual_share = group.total_contribution * C.MULTIPLIER / C.PLAYERS_PER_GROUP
print('individual share', group.individual_share)
if group.individual_share > 100:
print('inside if statement')
for player in players:
player.payoff = C.ENDOWMENT - p.contribution + group.individual_share
print('payoff after', p.payoff)
print statement not displayed in console/logs¶
If you don’t see the output of the print statement in your console window, that means that line is not getting executed! (Which is why it isn’t working.)
Maybe it’s because your code is in some unreachable place like after a return
statement,
or inside an “if” statement that is always False
. Start putting print statements from the top of the function,
then see where they stop getting displayed.
Or maybe your code is in a function that never gets called (executed).
oTree’s built-in methods such as creating_session
and before_next_page
are automatically executed,
but if you define a custom function such as set_payoffs
, you need to remember to call that function
from a built-in function.
Part 3: Trust game¶
Now let’s create a 2-player Trust game, and learn some more features of oTree.
To start, Player 1 receives 10 points; Player 2 receives nothing. Player 1 can send some or all of his points to Player 2. Before P2 receives these points they will be tripled. Once P2 receives the tripled points he can decide to send some or all of his points to P1.
The completed app is here.
Create the app¶
Just as in the previous part of the tutorial, create another app, called my_trust
.
Constants¶
Go to your app’s constants class (C
).
First we define our app’s constants. The endowment is 10 points and the donation gets tripled.
class C(BaseConstants):
NAME_IN_URL = 'my_trust'
PLAYERS_PER_GROUP = 2
NUM_ROUNDS = 1
ENDOWMENT = cu(10)
MULTIPLICATION_FACTOR = 3
Models¶
Then we add fields to player and group. There are 2 critical data points to record: the “sent” amount from P1, and the “sent back” amount from P2.
Your first instinct may be to define the fields on the Player like this:
# Don't copy paste this
class Player(BasePlayer):
sent_amount = models.CurrencyField()
sent_back_amount = models.CurrencyField()
The problem with this model is that sent_amount
only applies to P1,
and sent_back_amount
only applies to P2. It does not make sense that
P1 should have a field called sent_back_amount
. How can we make our
data model more accurate?
We can do it by defining those fields at the Group
level. This makes
sense because each group has exactly 1 sent_amount
and exactly 1
sent_back_amount
:
class Group(BaseGroup):
sent_amount = models.CurrencyField(
label="How much do you want to send to participant B?"
)
sent_back_amount = models.CurrencyField(
label="How much do you want to send back?"
)
We also define a function called sent_back_amount_choices
to populate the
dropdown menu dynamically. This is the feature called
{field_name}_choices
, which is explained here: Dynamic form field validation.
def sent_back_amount_choices(group):
return currency_range(
0,
group.sent_amount * C.MULTIPLICATION_FACTOR,
1
)
Define the templates and pages¶
We need 3 pages:
- P1’s “Send” page
- P2’s “Send back” page
- “Results” page that both users see.
Send page¶
class Send(Page):
form_model = 'group'
form_fields = ['sent_amount']
@staticmethod
def is_displayed(player):
return player.id_in_group == 1
We use is_displayed() to only show this to P1; P2 skips the
page. For more info on id_in_group
, see Groups.
For the template, set the title
to Trust Game: Your Choice
,
and content
to:
<p>
You are Participant A. Now you have {{C.ENDOWMENT}}.
</p>
{{ formfields }}
{{ next_button }}
SendBack.html¶
This is the page that P2 sees to send money back.
Set the title
block to Trust Game: Your Choice
,
and the content
block to:
<p>
You are Participant B. Participant A sent you {{group.sent_amount}}
and you received {{tripled_amount}}.
</p>
{{ formfields }}
{{ next_button }}
Here is the page code. Notes:
- We use vars_for_template() to pass the variable
tripled_amount
to the template. You cannot do calculations directly in the HTML code, so this number needs to be calculated in Python code and passed to the template.
class SendBack(Page):
form_model = 'group'
form_fields = ['sent_back_amount']
@staticmethod
def is_displayed(player):
return player.id_in_group == 2
@staticmethod
def vars_for_template(player):
group = player.group
return dict(
tripled_amount=group.sent_amount * C.MULTIPLICATION_FACTOR
)
Results¶
The results page needs to look slightly different for P1 vs. P2. So, we
use the {{ if }}
statement
to condition on the current player’s id_in_group
.
Set the title
block to Results
, and the content block to:
{{ if player.id_in_group == 1 }}
<p>
You sent Participant B {{ group.sent_amount }}.
Participant B returned {{ group.sent_back_amount }}.
</p>
{{ else }}
<p>
Participant A sent you {{ group.sent_amount }}.
You returned {{ group.sent_back_amount }}.
</p>
{{ endif }}
<p>
Therefore, your total payoff is {{ player.payoff }}.
</p>
class Results(Page):
pass
Wait pages and page sequence¶
Add 2 wait pages:
WaitForP1
(P2 needs to wait while P1 decides how much to send)ResultsWaitPage
(P1 needs to wait while P2 decides how much to send back)
After the second wait page, we should calculate the payoffs.
So, we define a function called set_payoffs
:
def set_payoffs(group):
p1 = group.get_player_by_id(1)
p2 = group.get_player_by_id(2)
p1.payoff = C.ENDOWMENT - group.sent_amount + group.sent_back_amount
p2.payoff = group.sent_amount * C.MULTIPLICATION_FACTOR - group.sent_back_amount
Then in ResultsWaitPage
, set after_all_players_arrive
:
after_all_players_arrive = set_payoffs
Make sure they are ordered correctly in the page_sequence:
page_sequence = [
Send,
WaitForP1,
SendBack,
ResultsWaitPage,
Results,
]
Add an entry to your SESSION_CONFIGS
¶
Create a session config with my_trust
in the app sequence.
Run the server¶
Load the project again then open your browser to http://localhost:8000
.
Conceptual overview¶
Sessions¶
In oTree, a session is an event during which multiple participants take part in a series of tasks or games. An example of a session would be:
“A number of participants will come to the lab and play a public goods game, followed by a questionnaire. Participants get paid EUR 10.00 for showing up, plus their earnings from the games.”
Subsessions¶
A session is a series of subsessions; subsessions are the “sections” or “modules” that constitute a session. For example, if a session consists of a public goods game followed by a questionnaire, the public goods game would be subsession 1, and the questionnaire would be subsession 2. In turn, each subsession is a sequence of pages. For example, if you had a 4-page public goods game followed by a 2-page questionnaire:

If a game is repeated for multiple rounds, each round is a subsession.
Groups¶
Each subsession can be further divided into groups of players; for example, you could have a subsession with 30 players, divided into 15 groups of 2 players each. (Note: groups can be shuffled between subsessions.)
Object hierarchy¶
oTree’s entities can be arranged into the following hierarchy:
Session
Subsession
Group
Player
- A session is a series of subsessions
- A subsession contains multiple groups
- A group contains multiple players
- Each player proceeds through multiple pages
You can access any higher-up object from a lower object:
player.participant
player.group
player.subsession
player.session
group.subsession
group.session
subsession.session
Participant¶
In oTree, the terms “player” and “participant” have distinct meanings. The relationship between participant and player is the same as the relationship between session and subsession:

A player is an instance of a participant in one particular subsession. A player is like a temporary “role” played by a participant. A participant can be player 2 in the first subsession, player 1 in the next subsession, etc.
Models¶
An oTree app has 3 data models: Subsession, Group, and Player.
A player is part of a group, which is part of a subsession. See Conceptual overview.
Let’s say you want your experiment to generate data that looks like this:
name age is_student
John 30 False
Alice 22 True
Bob 35 False
...
Here is how to define the above table structure:
class Player(BasePlayer):
name = models.StringField()
age = models.IntegerField()
is_student = models.BooleanField()
So, a model is essentially a database table. And a field is a column in a table.
Fields¶
Field types¶
BooleanField
(for true/false and yes/no values)CurrencyField
for currency amounts; see Currency.IntegerField
FloatField
(for real numbers)StringField
(for text strings)LongStringField
(for long text strings; its form widget is a multi-line textarea)
Initial/default value¶
Your field’s initial value will be None
, unless you set initial=
:
class Player(BasePlayer):
some_number = models.IntegerField(initial=0)
min, max, choices¶
For info on how to set a field’s min
, max
, or choices
,
see Simple form field validation.
Built-in fields and methods¶
Player, group, and subsession already have some predefined fields.
For example, Player
has fields called payoff
and id_in_group
, as well as methods like
in_all_rounds()
and get_others_in_group()
.
These built-in fields and methods are listed below.
Subsession¶
round_number¶
Gives the current round number.
Only relevant if the app has multiple rounds
(set in C.NUM_ROUNDS
).
See Rounds.
get_groups()¶
Returns a list of all the groups in the subsession.
get_players()¶
Returns a list of all the players in the subsession.
Player¶
id_in_group¶
Automatically assigned integer starting from 1. In multiplayer games, indicates whether this is player 1, player 2, etc.
round_number¶
Gives the current round number.
Session¶
num_participants¶
The number of participants in the session.
config¶
vars¶
See Session fields.
Participant¶
id_in_session¶
The participant’s ID in the session. This is the same as the player’s
id_in_subsession
.
Other participant attributes and methods¶
Constants¶
C
is the recommended place to put your app’s
parameters and constants that do not vary from player
to player.
Here are the built-in constants:
if you don’t want your app’s real name
to be displayed in URLs,
define a string constant NAME_IN_URL
with your desired name.
Constants can be numbers, strings, booleans, lists, etc.
But for more complex data types like dicts, lists of dicts, etc,
you should instead define it in a function. For example,
instead of defining a Constant called my_dict
, do this:
def my_dict(subsession):
return dict(a=[1,2], b=[3,4])
Pages¶
Each page that your participants see is defined by a Page
.
Your page_sequence
gives the order of the pages.
If your game has multiple rounds, this sequence will be repeated (see Rounds).
A Page
can have any of the following methods and attributes.
is_displayed()¶
You can define this function to return True
if the page should be shown,
and False if the page should be skipped.
If omitted, the page will be shown.
For example, to only show the page to P2 in each group:
@staticmethod
def is_displayed(player):
return player.id_in_group == 2
Or only show the page in round 1:
@staticmethod
def is_displayed(player):
return player.round_number == 1
If you need to repeat the same rule for many pages, use app_after_this_page.
vars_for_template()¶
Use this to pass variables to the template. Example:
@staticmethod
def vars_for_template(player):
a = player.num_apples * 10
return dict(
a=a,
b=1 + 1,
)
Then in the template you can access a
and b
like this:
Variables {{ a }} and {{ b }} ...
oTree automatically passes the following objects to the template:
player
, group
, subsession
, participant
, session
, and C
.
You can access them in the template like this: {{ C.BLAH }}
or {{ player.blah }}
.
If the user refreshes the page, vars_for_template
gets re-executed.
before_next_page()¶
Here you define any code that should be executed after form validation, before the player proceeds to the next page.
If the page is skipped with is_displayed
,
then before_next_page
will be skipped as well.
Example:
@staticmethod
def before_next_page(player, timeout_happened):
player.tripled_apples = player.num_apples * 3
Wait pages¶
See Wait pages
app_after_this_page¶
To skip entire apps, you can define app_after_this_page
.
For example, to skip to the next app, you would do:
@staticmethod
def app_after_this_page(player, upcoming_apps):
if player.whatever:
return upcoming_apps[0]
upcoming_apps
is the remainder of the app_sequence
(a list of strings).
Therefore, to skip to the last app, you would return upcoming_apps[-1]
.
Or you could just return a hardcoded string
(as long as that string is in upcoming_apps
):
@staticmethod
def app_after_this_page(player, upcoming_apps):
print('upcoming_apps is', upcoming_apps)
if player.whatever:
return "public_goods"
If this function doesn’t return anything, the player proceeds to the next page as usual.
Templates¶
Template syntax¶
Variables¶
You can display a variable like this:
Your payoff is {{ player.payoff }}.
The following variables are available in templates:
player
: the player currently viewing the pagegroup
: the group the current player belongs tosubsession
: the subsession the current player belongs toparticipant
: the participant the current player belongs tosession
: the current sessionC
- Any variables you passed with vars_for_template().
Conditions (“if”)¶
Note
oTree 3.x used two types of tags in templates: {{ }}
and {% %}
.
Starting in oTree 5, however, you can forget about {% %}
and just use {{ }}
everywhere if you want.
The old format still works.
With an ‘else’:
Complex example:
Loops (“for”)¶
{{ for item in some_list }}
{{ item }}
{{ endfor }}
Accessing items in a dict¶
Whereas in Python code you do my_dict['foo']
,
in a template you would do {{ my_dict.foo }}
.
Comments¶
{# this is a comment #}
{#
this is a
multiline comment
#}
Things you can’t do¶
The template language is just for displaying values.
You can’t do math (+
, *
, /
, -
)
or otherwise modify numbers, lists, strings, etc.
For that, you should use vars_for_template().
How templates work: an example¶
oTree templates are a mix of 2 languages:
- HTML (which uses angle brackets like
<this>
and</this>
). - Template tags
(which use curly braces like
{{ this }}
)
In this example, let’s say your template looks like this:
<p>Your payoff this round was {{ player.payoff }}.</p>
{{ if subsession.round_number > 1 }}
<p>
Your payoff in the previous round was {{ last_round_payoff }}.
</p>
{{ endif }}
{{ next_button }}
Step 1: oTree scans template tags, produces HTML (a.k.a. “server side”)¶
oTree uses the current values of the variables to convert the above template tags to plain HTML, like this:
<p>Your payoff this round was $10.</p>
<p>
Your payoff in the previous round was $5.
</p>
<button class="otree-btn-next btn btn-primary">Next</button>
Step 2: Browser scans HTML tags, produces a webpage (a.k.a. “client side”)¶
The oTree server then sends this HTML to the user’s computer, where their web browser can read the code and display it as a formatted web page:

Note that the browser never sees the template tags.
The key point¶
If one of your pages doesn’t look the way you want, you can isolate which of the above steps went wrong. In your browser, right-click and “view source”. (Note: “view source” may not work in split-screen mode.)
You can then see the pure HTML that was generated (along with any JavaScript or CSS).
- If the HTML code doesn’t look the way you expect, then something
went wrong on the server side. Look for mistakes in your
vars_for_template
or your template tags. - If there was no error in generating the HTML code, then it is probably an issue with how you are using HTML (or JavaScript) syntax. Try pasting the problematic part of the HTML back into a template, without the template tags, and edit it until it produces the right output. Then put the template tags back in, to make it dynamic again.
Images (static files)¶
The simplest way to include images, video, 3rd party JS/CSS libraries, and other static files in your project is to host them online, for example on Dropbox, Imgur, YouTube, etc.
Then, put its URL in an <img> or <video> tag in your template, for example:
<img src="https://i.imgur.com/gM5yeyS.jpg" width="500px" />
You can also store images directly in your project. (but note that large file sizes can affect performance). oTree Studio has an image upload tool. (If you are using a text editor, see here.) Once you have stored the image, you can display it like this:
<img src="{{ static 'folder_name/puppy.jpg' }}"/>
Dynamic images¶
If you need to show different images depending on the context
(like showing a different image each round),
you can construct it in vars_for_template
and pass it to the template, e.g.:
@staticmethod
def vars_for_template(player):
return dict(
image_path='my_app/{}.png'.format(player.round_number)
)
Then in the template:
<img src="{{ static image_path }}"/>
Includable templates¶
If you are copy-pasting the same content across many templates,
it’s better to create an includable template and reuse it with
{{ include_sibling }}
.
For example, if your game has instructions that need to be repeated on every page,
make a template called instructions.html
, and put the instructions there,
for example:
<div class="card bg-light">
<div class="card-body">
<h3>
Instructions
</h3>
<p>
These are the instructions for the game....
</p>
</div>
</div>
Then use {{ include_sibling 'instructions.html' }}
to insert it anywhere you want.
Note
{{ include_sibling }}
is a new alternative to {{ include }}
.
The advantage is that you can omit the name of the app: {{ include_sibling 'xyz.html' }}
instead of {{ include 'my_app/xyz.html' }}
.
However, if the includable template is in a different folder, you must use {{ include }}
.
JavaScript and CSS¶
Where to put JavaScript/CSS code¶
You can put JavaScript and CSS anywhere just by using the usual
<script></script>
or <style></style>
, anywhere in your template.
If you have a lot of scripts/styles,
you can put them in separate blocks outside of content
: scripts
and styles
.
It’s not mandatory to do this, but: it keeps your code organized and ensures that things are loaded in the correct order
(CSS, then your page content, then JavaScript).
Customizing the theme¶
If you want to customize the appearance of an oTree element, here is the list of CSS selectors:
Element | CSS/jQuery selector |
---|---|
Page body | .otree-body |
Page title | .otree-title |
Wait page (entire dialog) | .otree-wait-page |
Wait page dialog title | .otree-wait-page__title (note: __ , not _ ) |
Wait page dialog body | .otree-wait-page__body |
Timer | .otree-timer |
Next button | .otree-btn-next |
Form errors alert | .otree-form-errors |
For example, to change the page width, put CSS in your base template like this:
<style>
.otree-body {
max-width:800px
}
</style>
To get more info, in your browser, right-click the element you want to modify and select “Inspect”. Then you can navigate to see the different elements and try modifying their styles:

When possible, use one of the official selectors above.
Don’t use any selector that starts with _otree
, and don’t select based on Bootstrap classes like
btn-primary
or card
, because those are unstable.
Passing data from Python to JavaScript (js_vars)¶
To pass data to JavaScript code in your template,
define a function js_vars
on your Page, for example:
@staticmethod
def js_vars(player):
return dict(
payoff=player.payoff,
)
Then, in your template, you can refer to these variables:
<script>
let x = js_vars.payoff;
// etc...
</script>
Bootstrap¶
oTree comes with Bootstrap, a popular library for customizing a website’s user interface.
You can use it if you want a custom style, or a specific component like a table, alert, progress bar, label, etc. You can even make your page dynamic with elements like popovers, modals, and collapsible text.
To use Bootstrap, usually you add a class=
attribute to your HTML
element.
For example, the following HTML will create a “Success” alert:
<div class="alert alert-success">Great job!</div>
Mobile devices¶
Bootstrap tries to show a “mobile friendly” version when viewed on a smartphone or tablet.
Best way to test on mobile is to use Heroku.otree zipserver
doesn’t accept a ‘port’ argument. Also, devserver/zipserver seem to have issues with shutdown/reloading and freeing up the port.
Charts¶
You can use any HTML/JavaScript library for adding charts to your app. A good option is HighCharts, to draw pie charts, line graphs, bar charts, time series, etc.
First, include the HighCharts JavaScript:
<script src="https://code.highcharts.com/highcharts.js"></script>
Go to the HighCharts demo site and find the chart type that you want to make. Then click “edit in JSFiddle” to edit it to your liking, using hardcoded data.
Then, copy-paste the JS and HTML into your template,
and load the page. If you don’t see your chart, it may be because
your HTML is missing the <div>
that your JS code is trying to insert the chart
into.
Once your chart is loading properly, you can replace the hardcoded data
like series
and categories
with dynamically generated variables.
For example, change this:
series: [{
name: 'Tokyo',
data: [7.0, 6.9, 9.5, 14.5, 18.2, 21.5, 25.2, 26.5, 23.3, 18.3, 13.9, 9.6]
}, {
name: 'New York',
data: [-0.2, 0.8, 5.7, 11.3, 17.0, 22.0, 24.8, 24.1, 20.1, 14.1, 8.6, 2.5]
}]
To this:
series: js_vars.highcharts_series
…where highcharts_series
is a variable you defined in js_vars.
If your chart is not loading, click “View Source” in your browser and check if there is something wrong with the data you dynamically generated.
Miscellaneous¶
You can round numbers using the to2
, to1
, or to0
filters. For example::
{{ 0.1234|to2}}
outputs 0.12.
Forms¶
Each page in oTree can contain a form, which the player should fill out and submit by clicking the “Next” button. To create a form, first you need fields on the player model, for example:
class Player(BasePlayer):
name = models.StringField(label="Your name:")
age = models.IntegerField(label="Your age:")
Then, in your Page class, set form_model
and form_fields
:
class Page1(Page):
form_model = 'player'
form_fields = ['name', 'age'] # this means player.name, player.age
When the user submits the form, the submitted data is automatically saved to the corresponding fields on the player model.
Simple form field validation¶
min and max¶
To require an integer to be between 12 and 24:
offer = models.IntegerField(min=12, max=24)
If the max/min are not fixed, you should use {field_name}_max()
choices¶
If you want a field to be a dropdown menu with a list of choices,
set choices=
:
level = models.IntegerField(
choices=[1, 2, 3],
)
To use radio buttons instead of a dropdown menu,
you should set the widget
to RadioSelect
or RadioSelectHorizontal
:
level = models.IntegerField(
choices=[1, 2, 3],
widget=widgets.RadioSelect
)
If the list of choices needs to be determined dynamically, use {field_name}_choices()
You can also set display names for each choice by making a list of [value, display] pairs:
level = models.IntegerField(
choices=[
[1, 'Low'],
[2, 'Medium'],
[3, 'High'],
]
)
If you do this, users will just see a menu with “Low”, “Medium”, “High”, but their responses will be recorded as 1, 2, or 3.
You can do this for BooleanField
, StringField
, etc.:
cooperated = models.BooleanField(
choices=[
[False, 'Defect'],
[True, 'Cooperate'],
]
)
You can get the human-readable label corresponding to the user’s choice like this:
player.cooperated # returns e.g. False
player.field_display('cooperated') # returns e.g. 'Defect'
Note
field_display
is new in oTree 5.4 (August 2021).
Optional fields¶
If a field is optional, you can use blank=True
like this:
offer = models.IntegerField(blank=True)
Dynamic form field validation¶
The min
, max
, and choices
described above are only
for fixed (constant) values.
If you want them to be determined dynamically (e.g. different from player to player), then you can instead define one of the below functions.
{field_name}_choices()¶
Like setting choices=
,
this will set the choices for the form field
(e.g. the dropdown menu or radio buttons).
Example:
class Player(BasePlayer):
fruit = models.StringField()
def fruit_choices(player):
import random
choices = ['apple', 'kiwi', 'mango']
random.shuffle(choices)
return choices
{field_name}_max()¶
The dynamic alternative to setting max=
in the model field. For example:
class Player(BasePlayer):
offer = models.CurrencyField()
budget = models.CurrencyField()
def offer_max(player):
return player.budget
{field_name}_min()¶
The dynamic alternative to setting min=
on the model field.
{field_name}_error_message()¶
This is the most flexible method for validating a field.
class Player(BasePlayer):
offer = models.CurrencyField()
budget = models.CurrencyField()
def offer_error_message(player, value):
print('value is', value)
if value > player.budget:
return 'Cannot offer more than your remaining budget'
Validating multiple fields together¶
Let’s say your form has 3 number fields whose values must sum to 100.
You can enforce this with the error_message
function, which goes on the page:
class MyPage(Page):
form_model = 'player'
form_fields = ['int1', 'int2', 'int3']
@staticmethod
def error_message(player, values):
print('values is', values)
if values['int1'] + values['int2'] + values['int3'] != 100:
return 'The numbers must add up to 100'
Notes:
- If a field was left blank (and you set
blank=True
), its value here will beNone
. - This function is only executed if there are no other errors in the form.
- You can also return a dict that maps field names to error messages. This way, you don’t need to write many repetitive FIELD_error_message methods (see here).
Determining form fields dynamically¶
If you need the list of form fields to be dynamic, instead of
form_fields
you can define a function get_form_fields
:
@staticmethod
def get_form_fields(player):
if player.num_bids == 3:
return ['bid_1', 'bid_2', 'bid_3']
else:
return ['bid_1', 'bid_2']
Widgets¶
You can set a model field’s widget
to RadioSelect
or RadioSelectHorizontal
if you want choices
to be displayed with radio buttons, instead of a dropdown menu.
{{ formfield }}¶
If you want to position the fields individually,
instead of {{ formfields }}
you can use {{ formfield }}
:
{{ formfield 'bid' }}
You can also put the label
in directly in the template:
{{ formfield 'bid' label="How much do you want to contribute?" }}
The previous syntax of {% formfield player.bid %}
is still supported.
Customizing a field’s appearance¶
{{ formfields }}
and {{ formfield }}
are easy to use because they automatically output
all necessary parts of a form field (the input, the label, and any error messages),
with Bootstrap styling.
However, if you want more control over the appearance and layout,
you can use manual field rendering. Instead of {{ formfield 'my_field' }}
,
do {{ form.my_field }}
, to get just the input element.
Just remember to also include {{ formfield_errors 'my_field' }}
.
Example: Radio buttons arranged like a slider¶
pizza = models.IntegerField(
widget=widgets.RadioSelect,
choices=[-3, -2, -1, 0, 1, 2, 3]
)
<p>Choose the point on the scale that represents how much you like pizza:</p>
<p>
Least
{{ for choice in form.pizza }}
{{ choice }}
{{ endfor }}
Most
</p>
Example: Radio buttons in tables and other custom layouts¶
Let’s say you have a set of IntegerField
in your model:
class Player(BasePlayer):
offer_1 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_2 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_3 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_4 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
offer_5 = models.IntegerField(widget=widgets.RadioSelect, choices=[1,2,3])
And you’d like to present them as a likert scale, where each option is in a separate column.
(First, try to reduce the code duplication in your model by following the instructions in How to make many fields.)
Because the options must be in separate table cells,
the ordinary RadioSelectHorizontal
widget will not work here.
Instead, you should simply loop over the choices in the field as follows:
<tr>
<td>{{ form.offer_1.label }}</td>
{{ for choice in form.offer_1 }}
<td>{{ choice }}</td>
{{ endfor }}
</tr>
If you have many fields with the same number of choices, you can arrange them in a table:
<table class="table">
{{ for field in form }}
<tr>
<th>{{ field.label }}</th>
{{ for choice in field }}
<td>{{ choice }}</td>
{{ endfor }}
</tr>
{{ endfor }}
</table>
Raw HTML widgets¶
If {{ formfields }}
and manual field rendering
don’t give you the appearance you want,
you can write your own widget in raw HTML.
However, you will lose the convenient features handled
automatically by oTree. For example, if the form has an error and the page
re-loads, all entries by the user may be wiped out.
First, add an <input>
element.
For example, if your form_fields
includes my_field
,
you can do <input name="my_field" type="checkbox" />
(some other common types are radio
, text
, number
, and range
).
Second, you should usually include {{ formfield_errors 'xyz' }}
,
so that if the participant submits an incorrect or missing value),
they can see the error message.
Raw HTML example: custom user interface with JavaScript¶
Let’s say you don’t want users to fill out form fields, but instead interact with some sort of visual app, like a clicking on a chart or playing a graphical game. Or, you want to record extra data like how long they spent on part of the page, how many times they clicked, etc.
First, build your interface using HTML and JavaScript. Then use JavaScript to write the results into a hidden form field. For example:
# Player class
contribution = models.IntegerField()
# page
form_fields = ['contribution']
# HTML
<input type="hidden" name="contribution" id="contribution" />
# JavaScript
document.getElementById('contribution').value = 42;
When the page is submitted, the value of your hidden input will be recorded in oTree like any other form field.
If this isn’t working, open your browser’s JavaScript console,
see if there are any errors, and use console.log()
(JavaScript’s equivalent of print()
)
to trace the execution of your code line by line.
Buttons¶
Button that submits the form¶
If your page only contains 1 decision,
you could omit the {{ next_button }}
and instead have the user click on one of several buttons
to go to the next page.
For example, let’s say your Player model has offer_accepted = models.BooleanField()
,
and rather than a radio button you’d like to present it as a button like this:

First, put offer_accepted
in your Page’s form_fields
as usual.
Then put this code in the template:
<p>Do you wish to accept the offer?</p>
<button name="offer_accepted" value="True">Yes</button>
<button name="offer_accepted" value="False">No</button>
You can use this technique for any type of field,
not just BooleanField
.
Button that doesn’t submit the form¶
If the button has some purpose other than submitting the form,
add type="button"
:
<button>
Clicking this will submit the form
</button>
<button type="button">
Clicking this will not submit the form
</button>
Miscellaneous & advanced¶
Form fields with dynamic labels¶
If the label should contain a variable, you can construct the string in your page:
class Contribute(Page):
form_model = 'player'
form_fields = ['contribution']
@staticmethod
def vars_for_template(player):
return dict(
contribution_label='How much of your {} do you want to contribute?'.format(player.endowment)
)
Then, in the template:
{{ formfield 'contribution' label=contribution_label }}
If you use this technique, you may also want to use Dynamic form field validation.
JavaScript access to form inputs¶
Note
New beta feature as of oTree 5.9 (July 2022)
In your JavaScript code you can use forminputs.xyz
to access the <input>
element of form field xyz
. For example, you can do:
// get the value of an input
forminputs.xyz.value; // returns '42' or '' etc.
// set the value of a field.
forminputs.xyz.value = '42';
// dynamically set a field's properties -- readonly, size, step, pattern, etc.
forminputs.xyz.minlength = '10';
// do live calculations on inputs
function myFunction() {
let sum = parseInt(forminputs.aaa.value) + parseInt(forminputs.bbb.value);
alert(`Your total is ${sum}`);
}
// set an event handler (for oninput/onchange/etc)
forminputs.aaa.oninput = myFunction;
// call methods on an input
forminputs.xyz.focus();
forminputs.xyz.reportValidity();
Radio widgets work a bit differently:
my_radio = models.IntegerField(
widget=widgets.RadioSelect,
choices=[1, 2, 3]
)
// forminputs.my_radio is a RadioNodeList, not a single <input>
// so you need to loop over all 3 options:
for (let radio of forminputs.my_radio) {
radio.required = false;
}
for (let radio of forminputs.my_radio) {
radio.onclick = function() { alert("radio button changed"); };
}
// but the 'value' attribute works the same way as non-radio widgets
forminputs.my_radio.value = 2;
Multiplayer games¶
Groups¶
You can divide players into groups for multiplayer games. (If you just need groups in the sense of “treatment groups”, where players don’t actually interact with each other, then see Treatments.)
To set the group size, set
C.PLAYERS_PER_GROUP
. For example, for a 2-player game,
set PLAYERS_PER_GROUP = 2
.
If all players should be in the same group,
or if it’s a single-player game, set it to None
:
Each player has an attribute id_in_group
,
which will tell you if it is player 1
, player 2
, etc.
Getting players¶
Group objects have the following methods:
get_players()¶
Returns a list of the players in the group (ordered by id_in_group
).
get_player_by_id(n)¶
Returns the player in the group with the given id_in_group
.
Getting other players¶
Player objects have methods get_others_in_group()
and
get_others_in_subsession()
that return a list of the other players
in the group and subsession.
Roles¶
If each group has multiple roles, such as buyer/seller, principal/agent, etc.,
you can define them in constants. Make their names end with _ROLE
:
class C(BaseConstants):
...
PRINCIPAL_ROLE = 'Principal'
AGENT_ROLE = 'Agent
oTree will then automatically assign each role
to a different player
(sequentially according to id_in_group
).
You can use this to show each role different content, e.g.:
class AgentPage(Page):
@staticmethod
def is_displayed(player):
return player.role == C.AGENT_ROLE
In a template:
You are the {{ player.role }}.
You can also use group.get_player_by_role()
, which is similar to get_player_by_id()
:
def set_payoffs(group):
principal = group.get_player_by_role(C.PRINCIPAL_ROLE)
agent = group.get_player_by_role(C.AGENT_ROLE)
# ...
If you want to switch players’ roles,
you should rearrange the groups, using group.set_players()
, subsession.group_randomly()
,
etc.
Group matching¶
Fixed matching¶
By default, in each round, players are split into groups of C.PLAYERS_PER_GROUP
.
They are grouped sequentially – for example, if there are 2 players per group,
then P1 and P2 would be grouped together, and so would P3 and P4, and so on.
id_in_group
is also assigned sequentially within each group.
This means that by default, the groups are the same in each round,
and even between apps that have the same PLAYERS_PER_GROUP
.
If you want to rearrange groups, you can use the below techniques.
group_randomly()¶
Subsessions have a method group_randomly()
that shuffles players randomly,
so they can end up in any group, and any position within the group.
If you would like to shuffle players between groups but keep players in fixed roles,
use group_randomly(fixed_id_in_group=True)
.
For example, this will group players randomly each round:
def creating_session(subsession):
subsession.group_randomly()
This will group players randomly each round, but keep id_in_group
fixed:
def creating_session(subsession):
subsession.group_randomly(fixed_id_in_group=True)
For the following example, assume that PLAYERS_PER_GROUP = 3
, and that there are 12 participants in the session:
def creating_session(subsession):
print(subsession.get_group_matrix()) # outputs the following:
# [[1, 2, 3],
# [4, 5, 6],
# [7, 8, 9],
# [10, 11, 12]]
subsession.group_randomly(fixed_id_in_group=True)
print(subsession.get_group_matrix()) # outputs the following:
# [[1, 8, 12],
# [10, 5, 3],
# [4, 2, 6],
# [7, 11, 9]]
subsession.group_randomly()
print(subsession.get_group_matrix()) # outputs the following:
# [[8, 10, 3],
# [4, 11, 2],
# [9, 1, 6],
# [12, 5, 7]]
group_like_round()¶
To copy the group structure from one round to another round,
use the group_like_round(n)
method.
The argument to this method is the round number
whose group structure should be copied.
In the below example, the groups are shuffled in round 1, and then subsequent rounds copy round 1’s grouping structure.
def creating_session(subsession):
if subsession.round_number == 1:
# <some shuffling code here>
else:
subsession.group_like_round(1)
get_group_matrix()¶
Subsessions have a method called get_group_matrix()
that
return the structure of groups as a matrix, for example:
[[1, 3, 5],
[7, 9, 11],
[2, 4, 6],
[8, 10, 12]]
set_group_matrix()¶
set_group_matrix()
lets you modify the group structure in any way you want.
First, get the list of players with get_players()
, or the pre-existing
group matrix with get_group_matrix()
.
Make your matrix then pass it to set_group_matrix()
:
def creating_session(subsession):
matrix = subsession.get_group_matrix()
for row in matrix:
row.reverse()
# now the 'matrix' variable looks like this,
# but it hasn't been saved yet!
# [[3, 2, 1],
# [6, 5, 4],
# [9, 8, 7],
# [12, 11, 10]]
# save it
subsession.set_group_matrix(matrix)
You can also pass a matrix of integers.
It must contain all integers from 1 to the number of players
in the subsession. Each integer represents the player who has that id_in_subsession
.
For example:
def creating_session(subsession):
new_structure = [[1,3,5], [7,9,11], [2,4,6], [8,10,12]]
subsession.set_group_matrix(new_structure)
print(subsession.get_group_matrix()) # will output this:
# [[1, 3, 5],
# [7, 9, 11],
# [2, 4, 6],
# [8, 10, 12]]
To check if your group shuffling worked correctly,
open your browser to the “Results” tab of your session,
and look at the group
and id_in_group
columns in each round.
group.set_players()¶
This is similar to set_group_matrix
, but it only shuffles players within a group,
e.g. so that you can give them different roles.
Shuffling during the session¶
creating_session
is usually a good place to shuffle groups,
but remember that creating_session
is run when the session is created,
before players begin playing. So, if your shuffling logic needs to depend on
something that happens after the session starts, you should do the
shuffling in a wait page instead.
You need to make a WaitPage
with wait_for_all_groups=True
and put the shuffling code in after_all_players_arrive
:
class ShuffleWaitPage(WaitPage):
wait_for_all_groups = True
@staticmethod
def after_all_players_arrive(subsession):
subsession.group_randomly()
# etc...
Group by arrival time¶
Wait pages¶
Wait pages are necessary when one player needs to wait for others to take some action before they can proceed. For example, in an ultimatum game, player 2 cannot accept or reject before they have seen player 1’s offer.
If you have a WaitPage
in your sequence of pages,
then oTree waits until all players in the group have
arrived at that point in the sequence, and then all players are allowed
to proceed.
If your subsession has multiple groups playing simultaneously, and you
would like a wait page that waits for all groups (i.e. all players in
the subsession), you can set the attribute
wait_for_all_groups = True
on the wait page.
For more information on groups, see Groups.
after_all_players_arrive¶
after_all_players_arrive
lets you run some calculations
once all players have arrived at the wait
page. This is a good place to set the players’ payoffs
or determine the winner.
You should first define a Group function that does the desired calculations.
For example:
def set_payoffs(group):
for p in group.get_players():
p.payoff = 100
Then trigger this function by doing:
class MyWaitPage(WaitPage):
after_all_players_arrive = set_payoffs
If you set wait_for_all_groups = True
,
then after_all_players_arrive
must be a Subsession function.
If you are using a text editor, after_all_players_arrive
can also be defined directly in the WaitPage:
class MyWaitPage(WaitPage):
@staticmethod
def after_all_players_arrive(group: Group):
for p in group.get_players():
p.payoff = 100
It can also be a string:
class MyWaitPage(WaitPage):
after_all_players_arrive = 'set_payoffs'
is_displayed()¶
Works the same way as with regular pages.
group_by_arrival_time¶
If you set group_by_arrival_time = True
on a WaitPage,
players will be grouped in the order they arrive at that wait page:
class MyWaitPage(WaitPage):
group_by_arrival_time = True
For example, if PLAYERS_PER_GROUP = 2
, the first 2 players to arrive
at the wait page will be grouped together, then the next 2 players, and so on.
This is useful in sessions where some participants might drop out (e.g. online experiments, or experiments with consent pages that let the participant quit early), or sessions where some participants take much longer than others.
A typical way to use group_by_arrival_time
is to put it after an app
that filters out participants. For example, if your session has a consent page
that gives participants the chance to opt out of the study, you can make a “consent” app
that just contains the consent pages, and
then have an app_sequence
like ['consent', 'my_game']
,
where my_game
uses group_by_arrival_time
.
This means that if someone opts out in consent
,
they will be excluded from the grouping in my_game
.
If a game has multiple rounds, you may want to only group by arrival time in round 1:
class MyWaitPage(WaitPage):
group_by_arrival_time = True
@staticmethod
def is_displayed(player):
return player.round_number == 1
If you do this, then subsequent rounds will keep the same group structure as
round 1. Otherwise, players will be re-grouped by their arrival time
in each round.
(group_by_arrival_time
copies the group structure to future rounds.)
Notes:
- If a participant arrives at the wait page but subsequently switches to a different window or browser tab, they will be excluded from grouping after a short period of time.
id_in_group
is not necessarily assigned in the order players arrived at the page.group_by_arrival_time
can only be used if the wait page is the first page inpage_sequence
- If you use
is_displayed
on a page withgroup_by_arrival_time
, it should only be based on the round number. Don’t useis_displayed
to show the page to some players but not others. - If
group_by_arrival_time = True
, then increating_session
, all players will initially be in the same group. Groups are only created “on the fly” as players arrive at the wait page.
If you need further control on arranging players into groups, use group_by_arrival_time_method().
group_by_arrival_time_method()¶
If you’re using group_by_arrival_time
and want more control over
which players are assigned together, you can also use group_by_arrival_time_method()
.
Let’s say that in addition to grouping by arrival time, you need each group to consist of 2 men and 2 women.
If you define a function called group_by_arrival_time_method
,
it will get called whenever a new player reaches the wait page.
The function’s second argument is the list of players who are currently waiting at your wait page.
If you pick some of these players and return them as a list,
those players will be assigned to a group, and move forward.
If you don’t return anything, then no grouping occurs.
Here’s an example where each group has 2 men and 2 women.
It assumes that in a previous app, you assigned participant.category
to each participant.
# note: this function goes at the module level, not inside the WaitPage.
def group_by_arrival_time_method(subsession, waiting_players):
print('in group_by_arrival_time_method')
m_players = [p for p in waiting_players if p.participant.category == 'M']
f_players = [p for p in waiting_players if p.participant.category == 'F']
if len(m_players) >= 2 and len(f_players) >= 2:
print('about to create a group')
return [m_players[0], m_players[1], f_players[0], f_players[1]]
print('not enough players yet to create a group')
Timeouts on wait pages¶
You can also use group_by_arrival_time_method
to put a timeout on the wait page,
for example to allow the participant to proceed individually if they have been waiting
longer than 5 minutes. First, you must record time.time()
on the final page before the app with group_by_arrival_time
.
Store it in a participant field.
Then define a Player function:
def waiting_too_long(player):
participant = player.participant
import time
# assumes you set wait_page_arrival in PARTICIPANT_FIELDS.
return time.time() - participant.wait_page_arrival > 5*60
Now use this:
def group_by_arrival_time_method(subsession, waiting_players):
if len(waiting_players) >= 3:
return waiting_players[:3]
for player in waiting_players:
if waiting_too_long(player):
# make a single-player group.
return [player]
This works because the wait page automatically refreshes once or twice a minute,
which re-executes group_by_arrival_time_method
.
Preventing players from getting stuck on wait pages¶
A common problem especially with online experiments is players getting stuck waiting for another player in their group who dropped out or is too slow.
Here are some things you can do to reduce this problem:
Use group_by_arrival_time
¶
As described above, you can use group_by_arrival_time
so that only
players who are actively playing around the same time get grouped together.
group_by_arrival_time
works well if used after a “lock-in” task.
In other words, before your multiplayer game, you can have a
single-player effort task. The idea is that a
participant takes the effort to complete this initial task, they are
less likely to drop out after that point.
Use page timeouts¶
Use timeout_seconds on each page, so that if a player is slow or inactive, their page will automatically advance. Or, you can manually force a timeout by clicking the “Advance slowest participants” button in the admin interface.
Check timeout_happened¶
You can tell users they must submit a page before its timeout_seconds
,
or else they will be counted as a dropout.
Even have a page that just says “click the next button to confirm you are still playing”.
Then check timeout_happened. If it is True, you can do various things such as
set a field on that player/group to indicate the dropout, and skip the rest of the pages in the round.
Replacing dropped out player with a bot¶
Here’s an example that combines some of the above techniques, so that even if a player drops out,
they continue to auto-play, like a bot.
First, define a participant field called is_dropout
, and set its initial value to
False
in creating_session
. Then use get_timeout_seconds
and before_next_page
on every page,
like this:
class Page1(Page):
form_model = 'player'
form_fields = ['contribution']
@staticmethod
def get_timeout_seconds(player):
participant = player.participant
if participant.is_dropout:
return 1 # instant timeout, 1 second
else:
return 5*60
@staticmethod
def before_next_page(player, timeout_happened):
participant = player.participant
if timeout_happened:
player.contribution = cu(100)
participant.is_dropout = True
Notes:
- If the player fails to submit the page on time, we set
is_dropout
toTrue
. - Once
is_dropout
is set, each page gets auto-submitted instantly. - When a page is auto-submitted, you use
timeout_happened
to decide what value gets submitted on the user’s behalf.
Customizing the wait page’s appearance¶
You can customize the text that appears on a wait page
by setting the title_text
and body_text
attributes, e.g.:
class MyWaitPage(WaitPage):
title_text = "Custom title text"
body_text = "Custom body text"
See also: Custom wait page template.
Chat¶
You can add a chat room to a page so that participants can communicate with each other.
Basic usage¶
In your template HTML, put:
{{ chat }}
This will make a chat room among players in the same Group,
where each player’s nickname is displayed as
“Player 1”, “Player 2”, etc. (based on the player’s id_in_group
).
Customizing the nickname and chat room members¶
You can specify a channel
and/or nickname
like this:
{{ chat nickname="abc" channel="123" }}
Nickname¶
nickname
is the nickname that will be displayed for that user in the chat.
A typical usage would be {{ chat nickname=player.role }}
.
Channel¶
channel
is the chat room’s name, meaning that if 2 players
have the same channel
, they can chat with each other.
channel
is not displayed in the user interface; it’s just used internally.
Its default value is group.id
, meaning all players in the group can chat together.
You can use channel
to instead scope the chat to the current page
or sub-division of a group, etc. (see examples below).
Regardless of the value of channel
,
the chat will at least be scoped to players in the same session and the same app.
Example: chat by role¶
Here’s an example where instead of communication within a group, we have communication between groups based on role, e.g. all buyers can talk with each other, and all sellers can talk with each other.
def chat_nickname(player):
group = player.group
return 'Group {} player {}'.format(group.id_in_subsession, player.id_in_group)
In the page:
class MyPage(Page):
@staticmethod
def vars_for_template(player):
return dict(
nickname=chat_nickname(player)
)
Then in the template:
{{ chat nickname=nickname channel=player.id_in_group }}
Example: chat across rounds¶
If you need players to chat with players who are currently in a different round of the game, you can do:
{{ chat channel=group.id_in_subsession }}
Example: chat between all groups in all rounds¶
If you want everyone in the session to freely chat with each other, just do:
{{ chat channel=1 }}
(The number 1 is not significant; all that matters is that it’s the same for everyone.)
Advanced customization¶
If you look at the page source code in your browser’s inspector,
you will see a bunch of classes starting with otree-chat__
.
You can use CSS or JS to change the appearance or behavior of these elements (or hide them entirely).
You can also customize the appearance by putting it inside a <div>
and styling that parent <div>
. For example, to set the width:
<div style="width: 400px">
{{ chat }}
</div>
Multiple chats on a page¶
You can have multiple {{ chat }}
boxes on each page,
so that a player can be in multiple channels simultaneously.
Exporting CSV of chat logs¶
The chat logs download link will appear on oTree’s regular data export page.
Apps & rounds¶
Apps¶
An oTree app is a folder containing Python and HTML code. A project contains multiple apps. A session is basically a sequence of apps that are played one after the other.
Combining apps¶
You can combine apps by setting your session config’s app_sequence
.
Passing data between apps¶
See participant fields and session fields.
Rounds¶
You can make a game run for multiple rounds by setting C.NUM_ROUNDS
.
For example, if your session config’s app_sequence
is ['app1', 'app2']
,
where app1
has NUM_ROUNDS = 3
and app2
has NUM_ROUNDS = 1
,
then your sessions will contain 4 subsessions.
Round numbers¶
You can get the current round number with player.round_number
(this attribute is present on subsession, group, and player objects).
Round numbers start from 1.
Passing data between rounds or apps¶
Each round has separate subsession, Group
, and Player
objects.
For example, let’s say you set player.my_field = True
in round 1.
In round 2, if you try to access player.my_field
,
you will find its value is None
.
This is because the Player
objects
in round 1 are separate from Player
objects in round 2.
To access data from a previous round or app, you can use one of the techniques described below.
in_rounds, in_previous_rounds, in_round, etc.¶
Player, group, and subsession objects have the following methods:
- in_previous_rounds()
- in_all_rounds()
- in_rounds()
- in_round()
For example, if you are in the last round of a 10-round game,
player.in_previous_rounds()
will return a list with 9 player objects,
which represent the current participant in all previous rounds.
player.in_all_rounds()
is almost the same but the list will have 10 objects,
because it includes the current round’s player.
player.in_rounds(m, n)
returns a list of players representing the same participant from rounds m
to n
.
player.in_round(m)
returns just the player in round m
.
For example, to get the player’s payoff in the previous round,
you would do:
prev_player = player.in_round(player.round_number - 1)
print(prev_player.payoff)
These methods work the same way for subsessions (e.g. subsession.in_all_rounds()
).
They also work the same way for groups, but it does not make sense to use them if you re-shuffle groups between rounds.
Participant fields¶
If you want to access a participant’s data from a previous app,
you should store this data on the participant object,
which persists across apps (see Participant).
(in_all_rounds()
only is useful when you need to access data from a previous
round of the same app.)
Go to settings and define PARTICIPANT_FIELDS
,
which is a list of the names of fields you want to define on your participant.
Then in your code, you can get and set any type of data on these fields:
participant.mylist = [1, 2, 3]
(Internally, all participant fields are stored in a dict called participant.vars
.
participant.xyz
is equivalent to participant.vars['xyz']
.)
Session fields¶
For global variables that are the same for all participants in the session,
add them to the SESSION_FIELDS
, which works the same as PARTICIPANT_FIELDS
.
Internally, all session fields are stored in session.vars
.
Variable number of rounds¶
If you want a variable number of rounds, consider using Live pages.
Alternatively, you can set NUM_ROUNDS
to some high number, and then in your app, conditionally hide the
{{ next_button }}
element, so that the user cannot proceed to the next
page, or use app_after_this_page. But note that having many rounds (e.g. more than 100)
might cause performance problems, so test your app carefully.
Treatments¶
To assign participants to different treatment groups, you
can use creating_session
. For example:
def creating_session(subsession):
import random
for player in subsession.get_players():
player.time_pressure = random.choice([True, False])
print('set time_pressure to', player.time_pressure)
You can also assign treatments at the group level (put the BooleanField
in Group
and change the above code to use get_groups()
and group.time_pressure
).
creating_session
is run immediately when you click the “create session” button,
even if the app is not first in the app_sequence
.
Treatment groups & multiple rounds¶
If your game has multiple rounds, a player could have different treatments in different rounds,
because creating_session
gets executed for each round independently.
To prevent this, set it on the participant, rather than the player:
def creating_session(subsession):
if subsession.round_number == 1:
for player in subsession.get_players():
participant = player.participant
participant.time_pressure = random.choice([True, False])
Balanced treatment groups¶
The above code makes a random drawing independently for each player,
so you may end up with an imbalance.
To solve this, you can use itertools.cycle
:
def creating_session(subsession):
import itertools
pressures = itertools.cycle([True, False])
for player in subsession.get_players():
player.time_pressure = next(pressures)
Choosing which treatment to play¶
In a live experiment, you often want to give a player a random treatment. But when you are testing your game, it is often useful to choose explicitly which treatment to play. Let’s say you are developing the game from the above example and want to show your colleagues both treatments. You can create 2 session configs that are the same, except for one parameter (in oTree Studio, add a “custom parameter”):
SESSION_CONFIGS = [
dict(
name='my_game_primed',
app_sequence=['my_game'],
num_demo_participants=1,
time_pressure=True,
),
dict(
name='my_game_noprime',
app_sequence=['my_game'],
num_demo_participants=1,
time_pressure=False,
),
]
Then in your code you can get the current session’s treatment with:
session.config['time_pressure']
You can even combine this with the randomization approach. You can check
if 'time_pressure' in session.config:
; if yes, then use that; if no,
then choose it randomly.
Configure sessions¶
You can make your session configurable, so that you can adjust the game’s parameters in the admin interface.

For example, let’s say you have a “num_apples” parameter.
The usual approach would be to define it in C
,
e.g. C.NUM_APPLES
.
But to make it configurable, you can instead define it in your session config.
For example:
dict(
name='my_session_config',
display_name='My Session Config',
num_demo_participants=2,
app_sequence=['my_app_1', 'my_app_2'],
num_apples=10
),
When you create a session in the admin interface, there will be a text box to change this number.
You can also add help text with 'doc'
:
dict(
name='my_session_config',
display_name='My Session Config',
num_demo_participants=2,
app_sequence=['my_app_1', 'my_app_2'],
num_apples=10,
doc="""
Edit the 'num_apples' parameter to change the factor by which
contributions to the group are multiplied.
"""
),
In your app’s code, you can do session.config['num_apples']
.
Notes:
- For a parameter to be configurable, its value must be a number, boolean, or string.
- On the “Demo” section of the admin, sessions are not configurable. It’s only available when creating a session in “Sessions” or “Rooms”.
Timeouts¶
Basics¶
timeout_seconds¶
To set a time limit on your page, add timeout_seconds
:
class Page1(Page):
timeout_seconds = 60
After the time runs out, the page auto-submits.
If you are running the production server (prodserver
),
the page will always submit, even if the user closes their browser window.
However, this does not occur if you are running the development server
(zipserver
or devserver
).
If you need the timeout to be dynamically determined, use get_timeout_seconds.
timeout_happened¶
You can check if the page was submitted by timeout:
class Page1(Page):
form_model = 'player'
form_fields = ['xyz']
timeout_seconds = 60
@staticmethod
def before_next_page(player, timeout_happened):
if timeout_happened:
# you may want to fill a default value for any form fields,
# because otherwise they may be left null.
player.xyz = False
get_timeout_seconds¶
This is a more flexible alternative to timeout_seconds
,
so that you can make the timeout depend on player
, player.session
, etc.
For example:
class MyPage(Page):
@staticmethod
def get_timeout_seconds(player):
return player.my_page_timeout_seconds
Or, using a custom session config parameter (see Choosing which treatment to play).
def get_timeout_seconds(player):
session = player.session
return session.config['my_page_timeout_seconds']
Advanced techniques¶
Forms submitted by timeout¶
If a form is auto-submitted because of a timeout,
oTree will try to save whichever fields were filled out at the time of submission.
If a field in the form has an error because it is missing or invalid,
it will be set to 0
for numeric fields, False
for boolean fields, and the empty
string ''
for string fields.
If you want to discard the auto-submitted values, you can just
check if timeout_happened
, and if so, overwrite the values.
If the error_message()
function fails, then the whole form might be invalid,
so the whole form will be discarded.
Timeouts that span multiple pages¶
You can use get_timeout_seconds
to create timeouts that span multiple
pages, or even the entire session. The trick is to define a fixed “expiration time”,
and then on each page, make get_timeout_seconds
return the number of seconds
until that expiration time.
First, choose a place to start the timer. This could be a page called
“Start” that displays text like “Press the button when you’re ready to start”.
When the user clicks the “next” button, before_next_page
will be executed:
class Start(Page):
@staticmethod
def before_next_page(player, timeout_happened):
participant = player.participant
import time
# remember to add 'expiry' to PARTICIPANT_FIELDS.
participant.expiry = time.time() + 5*60
(You could also start the timer in after_all_players_arrive
or creating_session
,
and it could be stored in a session field if it’s the same for everyone in the session.)
Then, each page’s get_timeout_seconds
should be the number of seconds
until that expiration time:
class Page1(Page):
@staticmethod
def get_timeout_seconds(player):
participant = player.participant
import time
return participant.expiry - time.time()
When time runs out, get_timeout_seconds
will return 0 or a negative value,
which will result in the page loading and being auto-submitted right away.
This means all the remaining pages will quickly flash on the participant’s screen,
which is usually undesired. So, you should use
is_displayed
to skip the page if there’s not enough time
for the participant to realistically read the whole page.
def get_timeout_seconds(player):
participant = player.participant
import time
return participant.expiry - time.time()
class Page1(Page):
get_timeout_seconds = get_timeout_seconds
@staticmethod
def is_displayed(player):
return get_timeout_seconds(player) > 3
The default text on the timer says “Time left to complete this page:”.
But if your timeout spans multiple pages, you should word it more accurately,
by setting timer_text
:
class Page1(Page):
timer_text = 'Time left to complete this section:'
@staticmethod
def get_timeout_seconds(player):
...
Customizing the timer¶
Changing the timer’s behavior¶
The timer’s functionality is provided by
jQuery Countdown.
You can change its behavior by attaching and removing event handlers
with jQuery’s .on()
and off()
.
oTree sets handlers for the events update.countdown
and finish.countdown
,
so if you want to modify those, you can detach them with off()
,
and/or add your own handler with on()
.
The countdown element is .otree-timer__time-left
.
For example, to hide the timer until there is only 10 seconds left,
<style>
.otree-timer {
display: none;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function (event) {
$('.otree-timer__time-left').on('update.countdown', function (event) {
if (event.offset.totalSeconds === 10) {
$('.otree-timer').show();
}
});
});
</script>
To avoid copy-pasting this code on every page, put it in an includable template.
Note: even if you turn off the finish.countdown
event handler,
the page will still be submitted on the server side.
So, instead you should use the technique described in Timeout that doesn’t submit the page.
Timeout that doesn’t submit the page¶
If you just want a soft timeout, you don’t need to use the built-in timer at all. Instead, make your own with JavaScript, for example:
setTimeout(
function () {
alert("Time has run out. Please make your decision.");
},
60*1000 // 60 seconds
);
Bots¶
Bots simulate participants playing your app. They click through each page, fill out forms, and make sure that everything works properly.
This feature is designed for lazy people who would prefer for oTree to automatically test their apps for them. And oTree Studio can even design your bot code for you, so the whole process (writing and running bots) involves barely any effort.
Running bots¶
- Add bots to your app (see instructions below)
- In your session config, set
use_browser_bots=True
. - Run your server and create a session. The pages will auto-play with browser bots, once the start links are opened.
Writing tests¶
In oTree Studio, go to the “Tests” section of your app.
Click the button to auto-write bots code.
If you want to refine the code that was generated
(such as adding expect()
statements),
read the below sections.
If you are using a text editor, go to tests.py
.
See examples of how to define tests.py
here.
Submitting pages¶
You should make one yield
per page submission. For example:
yield pages.Start
yield pages.Survey, dict(name="Bob", age=20)
Here, we first submit the Start
page, which does not contain a form.
The following page has 2 form fields, so we submit a dict.
The test system will raise an error if the bot submits invalid input for a page, or if it submits pages in the wrong order.
You use if
statements to play any player or round number. For example:
if self.round_number == 1:
yield pages.Introduction
if self.player.id_in_group == 1:
yield pages.Offer, dict(offer=30)
else:
yield pages.Accept, dict(offer_accepted=True)
Your if
statements can depend on self.player
, self.group
,
self.round_number
, etc.
Ignore wait pages when writing bots.
Rounds¶
Your bot code should just play 1 round at a time.
oTree will automatically execute it NUM_ROUNDS
times.
expect()¶
You can use expect
statements to ensure that your code is working as you expect.
For example:
expect(self.player.num_apples, 100)
yield pages.Eat, dict(apples_eaten=1)
expect(self.player.num_apples, 99)
yield pages.SomeOtherPage
If self.player.num_apples
is not 99, then you will be alerted with an error.
You can also use expect with 3 arguments, like expect(self.player.budget, '<', 100)
.
This will verify that self.player.budget
is less than 100.
You can use the following operators:
'<'
,
'<='
,
'=='
,
'>='
,
'>'
,
'!='
,
'in'
,
'not in'
.
Testing form validation¶
If you use form validation,
you should test that your app is correctly rejecting invalid input from the user,
by using SubmissionMustFail()
.
For example, let’s say you have this page:
class MyPage(Page):
form_model = 'player'
form_fields = ['int1', 'int2']
@staticmethod
def error_message(player, values):
if values["int1"] + values["int2"] != 100:
return 'The numbers must add up to 100'
Here is how to test that it is working properly:
yield SubmissionMustFail(pages.MyPage, dict(int1=99, int2=0))
yield pages.MyPage, dict(int1=99, int2=1)
The bot will submit MyPage
twice. If the first submission succeeds,
an error will be raised, because it is not supposed to succeed.
Checking the HTML¶
self.html
contains the HTML of the page you are about to submit.
You can use this together with expect()
:
if self.player.id_in_group == 1:
expect(self.player.is_winner, True)
print(self.html)
expect('you won the game', 'in', self.html)
else:
expect(self.player.is_winner, False)
expect('you did not win', 'in', self.html)
yield pages.Results
# etc...
self.html
is updated with the next page’s HTML, after every yield
statement.
Linebreaks and extra spaces are ignored.
Automatic HTML checks¶
An error will be raised if the bot is trying to submit form fields that are not actually found in the page’s HTML, or if the page’s HTML is missing a submit button.
However, the bot system is not able to see fields and buttons that are added dynamically with JavaScript.
In these cases, you should disable the HTML check by using Submission
with check_html=False
. For example, change this:
yield pages.MyPage, dict(foo=99)
to this:
yield Submission(pages.MyPage, dict(foo=99), check_html=False)
(If you used Submission
without check_html=False
,
the two code samples would be equivalent.)
Simulate a page timeout¶
You can use Submission
with timeout_happened=True
:
yield Submission(pages.MyPage, dict(foo=99), timeout_happened=True)
Advanced features¶
Live pages¶
Live pages communicate with the server continuously and update in real time, enabling continuous time games. Live pages are a great fit for games with lots of back-and-forth interaction between users.
There are a bunch of examples here.
Sending data to the server¶
In your template’s JavaScript code,
call the function liveSend()
whenever you want to send data to the server.
For example, to submit a bid of 99 on behalf of the user, call:
liveSend(99);
Define a function that will receive this message. Its argument is whatever data was sent.
class MyPage(Page):
@staticmethod
def live_method(player, data):
print('received a bid from', player.id_in_group, ':', data)
If you are using oTree Studio, you must define a player function whose name
starts with live_
.
(Note, live_method
on WaitPage
is not yet supported.)
Sending data to the page¶
To send data back, return a dict whose keys are the IDs of the players to receive a message. For example, here is a method that simply sends “thanks” to whoever sends a message.
def live_method(player, data):
return {player.id_in_group: 'thanks'}
To send to multiple players, use their id_in_group
.
For example, this forwards every message to players 2 and 3:
def live_method(player, data):
return {2: data, 3: data}
To broadcast it to the whole group, use 0
(special case since it is not an actual id_in_group
).
def live_method(player, data):
return {0: data}
In your JavaScript, define a function liveRecv
.
This will be automatically called each time a message is received from the server.
function liveRecv(data) {
console.log('received a message!', data);
// your code goes here
}
Example: auction¶
class Group(BaseGroup):
highest_bidder = models.IntegerField()
highest_bid = models.CurrencyField(initial=0)
class Player(BasePlayer):
pass
def live_method(player, bid):
group = player.group
my_id = player.id_in_group
if bid > group.highest_bid:
group.highest_bid = bid
group.highest_bidder = my_id
response = dict(id_in_group=my_id, bid=bid)
return {0: response}
<table id="history" class="table">
<tr>
<th>Player</th>
<th>Bid</th>
</tr>
</table>
<input id="inputbox" type="number">
<button type="button" onclick="sendValue()">Send</button>
<script>
let history = document.getElementById('history');
let inputbox = document.getElementById('inputbox');
function liveRecv(data) {
history.innerHTML += '<tr><td>' + data.id_in_group + '</td><td>' + data.bid + '</td></tr>';
}
function sendValue() {
liveSend(parseInt(inputbox.value));
}
</script>
(Note, in JavaScript data.id_in_group == data['id_in_group']
.)
Data¶
The data you send and receive can be any data type (as long as it is JSON serializable). For example these are all valid:
liveSend(99);
liveSend('hello world');
liveSend([4, 5, 6]);
liveSend({'type': 'bid', 'value': 10.5});
The most versatile type of data is a dict, since it allows you to include multiple pieces of metadata, in particular what type of message it is:
liveSend({'type': 'offer', 'value': 99.9, 'to': 3})
liveSend({'type': 'response', 'accepted': true, 'to': 3})
Then you can use if
statements to process different types of messages:
def live_method(player, data):
t = data['type']
if t == 'offer':
other_player = data['to']
response = {
'type': 'offer',
'from': player.id_in_group,
'value': data['value']
}
return {other_player: response}
if t == 'response':
# etc
...
History¶
By default, participants will not see messages that were sent before they arrived at the page.
(And data will not be re-sent if they refresh the page.)
If you want to save history, you should store it in the database.
When a player loads the page, your JavaScript can call something like liveSend({})
,
and you can configure your live_method to retrieve the history of the game from the database.
ExtraModel¶
Live pages are often used together with an ExtraModel, which allows you to store each individual message or action in the database.
Keeping users on the page¶
Let’s say you require 10 messages to be sent before the users can proceed to the next page.
First, you should omit the {{ next_button }}
.
(Or use JS to hide it until the task is complete.)
When the task is completed, you send a message:
class Group(BaseGroup):
num_messages = models.IntegerField()
game_finished = models.BooleanField()
class MyPage(Page):
def live_method(player, data):
group = player.group
group.num_messages += 1
if group.num_messages >= 10:
group.game_finished = True
response = dict(type='game_finished')
return {0: response}
Then in the template, automatically submit the page via JavaScript:
function liveRecv(data) {
console.log('received', data);
let type = data.type;
if (type === 'game_finished') {
document.getElementById("form").submit();
}
// handle other types of messages here..
}
By the way, using a similar technique, you could implement a custom wait page, e.g. one that lets you proceed after a certain timeout, even if not all players have arrived.
General advice about live pages¶
Here is some general advice (does not apply to all situations). We recommend implementing most of your logic in Python, and just using JavaScript to update the page’s HTML, because:
- The JavaScript language can be quite tricky to use properly
- Your Python code runs on the server, which is centralized and reliable. JavaScript runs on the clients, which can get out of sync with each other, and data can get lost when the page is closed or reloaded.
- Because Python code runs on the server, it is more secure and cannot be viewed or modified by participants.
Example: tic-tac-toe¶
Let’s say you are implementing a game of tic-tac-toe. There are 2 types of messages your live_method can receive:
- A user marks a square, so you need to notify the other player
- A user loads (or reloads) the page, so you need to send them the current board layout.
For situation 1, you should use a JavaScript event handler like onclick
, e.g. so when the user clicks on square 3,
that move gets sent to the server:
liveSend({square: 3});
For situation 2, it’s good to put some code like this in your template, which sends an empty message to the server when the page loads:
document.addEventListener("DOMContentLoaded", (event) => {
liveSend({});
});
The server handles these 2 situations with an “if” statement:
def live_method(player, data):
group = player.group
if 'square' in data:
# SITUATION 1
square = data['square']
# save_move should save the move into a group field.
# for example, if player 1 modifies square 3,
# that changes group.board from 'X O XX O' to 'X OOXX O'
save_move(group, square, player.id_in_group)
# so that we can highlight the square (and maybe say who made the move)
news = {'square': square, 'id_in_group': player.id_in_group}
else:
# SITUATION 2
news = {}
# get_state should contain the current state of the game, for example:
# {'board': 'X O XX O', 'whose_turn': 2}
payload = get_state(group)
# .update just combines 2 dicts
payload.update(news)
return {0: payload}
In situation 2 (the player loads the page), the client gets a message like:
{'board': 'X OOXX O', 'whose_turn': 2}
In situation 1, the player gets the update about the move that was just made, AND the current state.
{'board': 'X OOXX O', 'whose_turn': 2, 'square': square, 'id_in_group': player.id_in_group}
The JavaScript code can be “dumb”. It doesn’t need to keep track of whose move it is; it just trusts the info it receives from the server. It can even redraw the board each time it receives a message.
Your code will also need to validate user input. For example, if player 1 tries to move when it is actually player 2’s turn, you need to block that. For reasons listed in the previous section, it’s better to do this in your live_method than in JavaScript code.
Summary¶
As illustrated above, the typical pattern for a live_method is like this:
if the user made an action:
state = (get the current state of the game)
if (action is illegal/invalid):
return
update the models based on the move.
news = (produce the feedback to send back to the user, or onward to other users)
else:
news = (nothing)
state = (get the current state of the game)
payload = (state combined with news)
return payload
Note that we get the game’s state twice. That’s because the state changes when we update our models, so we need to refresh it.
Troubleshooting¶
If you call liveSend
before the page has finished loading,
you will get an error like liveSend is not defined
.
So, wait for DOMContentLoaded
(or jQuery document.ready, etc):
window.addEventListener('DOMContentLoaded', (event) => {
// your code goes here...
});
Don’t trigger liveSend
when the user clicks the “next” button, since leaving the page might interrupt
the liveSend
. Instead, have the user click a regular button that triggers a liveSend
, and
then doing document.getElementById("form").submit();
in your liveRecv
.
Server setup¶
If you are just testing your app on your personal computer, you can use
otree devserver
. You don’t need a full server setup.
However, when you want to share your app with an audience, you must use a web server.
Choose which option you need:
You want to launch your app to users on the internet
Use Heroku.
You want the easiest setup
Again, we recommend Heroku.
You want to set up a dedicated Linux server
Ubuntu Linux instructions.
Basic Server Setup (Heroku)¶
Heroku is a commercial cloud hosting provider. It is the simplest way to deploy oTree.
The Heroku free plan is sufficient for testing your app, but once you are ready to launch a study, you should upgrade to a paid server, which can handle more traffic. However, Heroku is quite inexpensive, because you only pay for the time you actually use it. If you run a study for only 1 day, you can turn off your dynos and addons, and then you only pay 1/30 of the monthly cost. Often this means you can run a study for just a few dollars.
Heroku setup¶
To deploy to Heroku, you should use oTree Hub, which automates your server setup and ensures your server is correctly configured.
oTree Hub also offers error/performance monitoring.
Server performance¶
Heroku offers different performance tiers for resources such as your dyno and database. What tier you need depends on how much traffic your app will get, and how it is coded.
Performance is a complicated subject since there are many factors that affect performance. oTree Hub’s Pro plan has a “monitor” section that will analyze your logs to identify performance issues.
General tips:
- Upgrade oTree to the latest version
- Use browser bots to stress-test your app.
- With the higher dyno tiers, Heroku provides a “Metrics” tab. Look at “Dyno load”. If users are experiencing slow page load times and your your dyno load stays above 1, then you should get a faster dyno. (But don’t run more than 1 web dyno.)
- If your dyno load stays under 1 but page load times are still slow, the bottleneck might be something else like your Postgres database.
The most demanding sessions are the ones with a combination of (1) many rounds, (2) players spending just a few seconds on each page, and (3) many players playing concurrently, because these sessions have a high number of page requests per second, which can overload the server. Consider adapting these games to use Live pages, which will result in much faster performance.
Ubuntu Linux Server¶
We typically recommend newcomers to oTree to deploy to Heroku (see instructions here).
However, you may prefer to run oTree on a proper Linux server. Reasons may include:
- Your lab doesn’t have internet
- You want full control over server configuration
- You want better performance (local servers have less latency)
Create a virtualenv¶
It’s a best practice to use a virtualenv:
python3 -m venv venv_otree
To activate this venv every time you start your shell, put this in your .bashrc
or .profile
:
source ~/venv_otree/bin/activate
Once your virtualenv is active, you will see (venv_otree)
at the beginning
of your prompt.
Database (Postgres)¶
Install Postgres and psycopg2, create a new database and set the DATABASE_URL
env var, for example:
to postgres://postgres@localhost/django_db
Reset the database on the server¶
cd
to the folder containing your oTree project.
Install the requirements and reset the database:
pip3 install -r requirements.txt
otree resetdb
Running the server¶
Testing the production server¶
From your project folder, run:
otree prodserver 8000
Then navigate in your browser to your server’s
IP/hostname followed by :8000
.
If you’re not using a reverse proxy like Nginx or Apache,
you probably want to run oTree directly on port 80.
This requires superuser permission, so let’s use sudo,
but add some extra args to preserve environment variables like PATH
,
DATABASE_URL
, etc:
sudo -E env "PATH=$PATH" otree prodserver 80
Try again to open your browser; this time, you don’t need to append :80 to the URL, because that is the default HTTP port.
Unlike devserver
, prodserver
does not restart automatically
when your files are changed.
Set remaining environment variables¶
Add these in the same place where you set DATABASE_URL
:
export OTREE_ADMIN_PASSWORD=my_password
#export OTREE_PRODUCTION=1 # uncomment this line to enable production mode
export OTREE_AUTH_LEVEL=DEMO
(Optional) Process control system¶
Once the server is working as described above, it’s a good practice to use a process control system like Supervisord or Circus. This will restart your processes in case they crash, keep it running if you log out, etc.
Circus¶
Install Circus, then create a circus.ini
in your project folder,
with the following content:
[watcher:webapp]
cmd = otree
args = prodserver 80
use_sockets = True
copy_env = True
Then run:
sudo -E env "PATH=$PATH" circusd circus.ini
If this is working properly, you can start it as a daemon:
sudo -E env "PATH=$PATH" circusd --daemon circus.ini --log-output=circus-logs.txt
To stop circus, run:
circusctl stop
(Optional) Apache, Nginx, etc.¶
You cannot use Apache or Nginx as your primary web server, because oTree must be run with an ASGI server. However, you still might want to use Apache/Nginx as a reverse proxy, for the following reasons:
- You are trying to optimize serving of static files (though oTree uses Whitenoise, which is already fairly efficient)
- You need to host other websites on the same server
- You need features like SSL or proxy buffering
If you set up a reverse proxy, make sure to enable not only HTTP traffic but also websockets.
Troubleshooting¶
If you get strange behavior, such as random changes each time the page reloads, it might be caused by another oTree instance that didn’t shut down. Try stopping oTree and reload again.
Sharing a server with other oTree users¶
You can share a server with other oTree users; you just have to make sure that the code and databases are kept separate, so they don’t conflict with each other.
On the server you should create a different Unix user for each person using oTree. Then each person should follow the same steps described above, but in some cases name things differently to avoid clashes:
- Create a virtualenv in their home directory
- Create a different Postgres database, as described earlier, and set this in the DATABASE_URL env var.
Once these steps are done, the second user can push code to the server,
then run otree resetdb
.
If you don’t need multiple people to run experiments simultaneously,
then each user can take turns running the server on port 80 with otree prodserver 80
.
However, if multiple people need to run experiments at the same time,
then you would need to run the server on multiple ports, e.g. 8000
,
8001
, etc.
Windows Server (advanced)¶
If you are just testing your app on your personal computer, you can use
otree zipserver
or otree devserver
. You don’t need a full server setup as described below,
which is necessary for sharing your app with an audience.
This section is for people who are experienced with setting up web servers. If you would like an easier and quicker way, we recommend using Heroku.
Why do I need to install server software?¶
oTree’s development setup (devserver
)
is not designed for running actual studies.
Database (Postgres)¶
Install Postgres and psycopg2, create a new database and set the DATABASE_URL
env var, for example:
to postgres://postgres@localhost/django_db
resetdb¶
If all the above steps went well, you should be able to run otree resetdb
.
Run the production server¶
Run:
otree prodserver 80
See here for full instructions. The steps are essentially the same as on Linux.
Set environment variables¶
You should set OTREE_ADMIN_PASSWORD
, OTREE_PRODUCTION
, and OTREE_AUTH_LEVEL
.
Admin¶
oTree’s admin interface lets you create, monitor, and export data from sessions.
Open your browser to localhost:8000
or whatever you server’s URL is.
Password protection¶
When you first install oTree, The entire admin interface is accessible without a password. However, when you are ready to deploy to your audience, you should password protect the admin.
If you are launching an experiment and want visitors to only be able to
play your app if you provided them with a start link, set the
environment variable OTREE_AUTH_LEVEL
to STUDY
.
To put your site online in public demo mode where
anybody can play a demo version of your game
(but not access the full admin interface), set OTREE_AUTH_LEVEL
to DEMO
.
The normal admin username is “admin”.
You should set your password in the OTREE_ADMIN_PASSWORD
environment variable
(on Heroku, log into your Heroku dashboard, and define it as a config var).
If you change the admin username or password, you need to reset the database.
Start links¶
There are multiple types of start links you can use.
Single-use links¶
If a room is not suited for your needs, you can use oTree’s single-use links. Every time you create a session, you will need to re-distribute URLs to each participant.
Session-wide link¶
The session-wide link lets you provide
the same start link to all participants in the session.
Note: this may result in the same participant playing twice, unless you use the
participant_label
parameter in the URL (see Participant labels).
Before using the session-wide link, consider using a room, because you can also use a room without a participant label file to allow everyone to play with the same URL. The advantage of using a room is that the URL is simpler to type (doesn’t contain a randomly generated code), and you can reuse it across sessions.
Participant labels¶
Whether or not you’re using a room,
you can append a participant_label
parameter to each participant’s start
URL to identify them, e.g. by name, ID number, or computer workstation.
For example:
http://localhost:8000/room/my_room_name/?participant_label=John
oTree will record this participant label. It
will be used to identify that participant in the
oTree admin interface and the payments page, etc.
You can also access it from your code as participant.label
.
Another benefit of participant labels is that if the participant opens their start link twice, they will be assigned back to the same participant (if you are using a room-wide or session-wide URL). This reduces duplicate participation.
Arrival order¶
oTree will assign the first person who arrives to be P1, the second to be P2, etc., unless you are using single-use links.
Customizing the admin interface (admin reports)¶
You can add a custom tab to a session’s admin page with any content you want; for example:
- A chart/graph with the game’s results
- A custom payments page that is different from oTree’s built-in one
Here is a screenshot:

Here is a trivial example, where we add an admin report that displays a sorted list of payoffs for a given round.
First, define a function vars_for_admin_report
.
This works the same way as vars_for_template().
For example:
def vars_for_admin_report(subsession):
payoffs = sorted([p.payoff for p in subsession.get_players()])
return dict(payoffs=payoffs)
Then create an includable template admin_report.html
in your app, and display whatever variables were passed in vars_for_admin_report
:
<p>Here is the sorted list of payoffs in round {{ subsession.round_number }}</p>
<ul>
{{ for payoff in payoffs }}
<li>{{ payoff }}</li>
{{ endfor }}
</ul>
Notes:
subsession
,session
, andC
are passed to the template automatically.admin_report.html
does not need to use{{ block }}
. The above example is valid as the full contents ofadmin_report.html
.
If one or more apps in your session have an admin_report.html
,
your admin page will have a “Reports” tab. Use the menu to select the app
and the round number, to see the report for that subsession.
Export Data¶
In the admin interface, click on “Data” to download your data as CSV or Excel.
There is also a data export for “page times”, which shows the exact time when users completed every page.
Here
is a Python script you can run that tabulates how much time
is spent on each page. You can modify this script to calculate similar things, such as how much time each
participant spends on wait pages in total.
Custom data exports¶
You can make your own custom data export for an app.
In oTree Studio, go to the “Player” model and click on “custom_export” at the bottom.
(If using a text editor, define the below function.)
The argument players
is a queryset of all the players in the database.
Use a yield
for each row of data.
def custom_export(players):
# header row
yield ['session', 'participant_code', 'round_number', 'id_in_group', 'payoff']
for p in players:
participant = p.participant
session = p.session
yield [session.code, participant.code, p.round_number, p.id_in_group, p.payoff]
Or, you can ignore the players
argument and export some other data instead, e.g.:
def custom_export(players):
# Export an ExtraModel called "Trial"
yield ['session', 'participant', 'round_number', 'response', 'response_msec']
# 'filter' without any args returns everything
trials = Trial.filter()
for trial in trials:
player = trial.player
participant = player.participant
session = player.session
yield [session.code, participant.code, player.round_number, trial.response, trial.response_msec]
Once this function is defined, your custom data export will be available in the regular data export page.
Debug Info¶
When oTree runs in DEBUG
mode (i.e. when the environment variable
OTREE_PRODUCTION
is not set), debug information is displayed
on the bottom of all screens.
Payments¶
If you define a participant field called finished,
then you can set participant.finished = True
when a participant finishes the session,
and this will be displayed in various places such as the payments page.
Chat between participants and experimenter¶
To enable your participants to send you chat messages,
consider using a software like Papercups.
Click on the “Deploy to Heroku” button for 1-click setup of your Papercups server.
Fill out the required config vars and leave the others empty.
BACKEND_URL
and REACT_APP_URL
refer to your Papercups site, not your oTree site.
Login to your site and copy the HTML embedding code to an includable template called papercups.html
.
There is an example called “chat with experimenter” here.
Rooms¶
oTree lets you configure “rooms”, which provide:
- Links that you can assign to participants or lab computers, which stay constant across sessions
- A “waiting room” that lets you see which participants are currently waiting to start a session.
- Short links that are easy for participants to type, good for quick live demos.
Here is a screenshot:

Creating rooms¶
You can create multiple rooms – say, for for different classes you teach, or different labs you manage.
If using oTree Studio¶
In the sidebar, go to “Settings” and then add a room at the bottom.
If using PyCharm¶
Go to your settings.py
and set ROOMS
.
For example:
ROOMS = [
dict(
name='econ101',
display_name='Econ 101 class',
participant_label_file='_rooms/econ101.txt',
use_secure_urls=True
),
dict(
name='econ_lab',
display_name='Experimental Economics Lab'
),
]
If you are using participant labels (see below),
you need a participant_label_file
which is a relative (or absolute) path to a
text file with the participant labels.
Configuring a room¶
Participant labels¶
This is the “guest list” for the room. It should contain one participant label per line. For example:
LAB1
LAB2
LAB3
LAB4
LAB5
LAB6
LAB7
LAB8
LAB9
LAB10
If you don’t specify participant labels, then anyone can join as long as they know the room-wide URL. See If you don’t have a participant_label_file.
use_secure_urls (optional)¶
This setting provides extra security on top of the participant_label_file
.
For example, without secure URLs, your start URLs would look something
like this:
http://localhost:8000/room/econ101/?participant_label=Student1
http://localhost:8000/room/econ101/?participant_label=Student2
If Student1 is mischievous,
he might change his URL’s participant_label from “Student1” to “Student2”,
so that he can impersonate Student2.
However, if you use use_secure_urls
,
each URL gets a unique code like this:
http://localhost:8000/room/econ101/?participant_label=Student1&hash=29cd655f
http://localhost:8000/room/econ101/?participant_label=Student2&hash=46d9f31d
Then, Student1 can’t impersonate Student2 without the secret code.
Using rooms¶
In the admin interface, click “Rooms” in the header bar, and click the room you created. Scroll down to the section with the participant URLs.
If you have a participant_label_file¶
In the room’s admin page, monitor which participants are present, and when you are ready, create a session for the desired number of people.
You can either use the participant-specific URLs, or the room-wide URL.
The participant-specific URLs already contain the participant label. For example:
http://localhost:8000/room/econ101/?participant_label=Student2
The room-wide URL does not contain it:
http://localhost:8000/room/econ101/
So, if you use room-wide URLs, participants will be required to enter their participant label:

If you don’t have a participant_label_file¶
Just have each participant open the room-wide URL. Then, in the room’s admin page, check how many people are present, and create a session for the desired number of people.
Although this option is simple, it is less reliable than using participant labels, because someone could play twice by opening the URL in 2 different browsers.
Reusing for multiple sessions¶
Room URLs are designed to be reused across sessions. In a lab, you can set them as the browser’s home page (using either room-wide or participant-specific URLs).
In classroom experiments, you can give each student their URL that they can use through the semester.
What if not all participants show up?¶
If you’re doing a lab experiment and the number of participants is unpredictable, you can consider using the room-wide URL, and asking participants to manually enter their participant label. Participants are only counted as present after they enter their participant label.
Or, you can open the browsers to participant-specific URLs, but before creating the session, close the browsers on unattended computers.
Participants can join after the session has been created, as long as there are spots remaining.
Pre-assigning participants to labels¶
oTree assigns participants based on arrival time, e.g. the first person to arrive is participant 1. However, in some situations this may be undesirable, for example:
- You want your participant labels to line up with the oTree IDs, in a fixed order, e.g. so that LAB29 will always be participant 29.
- You want Alice/Bob/Charlie to always be participants 1/2/3, so that they get grouped to play together.
Just assign those participant labels in creating_session
:
def creating_session(subsession):
labels = ['alice', 'bob', 'charlie']
for player, label in zip(subsession.get_players(), labels):
player.participant.label = label
If someone opens a start link with participant_label=alice
,
oTree checks if any participant in the session already has that label.
(This is necessary so that clicking a start link twice assigns back to the same participant.)
Passing data about a participant into oTree¶
Currency¶
In many experiments, participants play for currency:
either real money, or points. oTree supports both;
you can switch from points to real money by setting USE_POINTS = False
in your settings.
You can write cu(42)
to represent “42 currency units”.
It works just like a number
(e.g. cu(0.1) + cu(0.2) == cu(0.3)
).
The advantage is that when it’s displayed to users, it will automatically
be formatted as $0.30
or 0,30 €
, etc., depending on your
REAL_WORLD_CURRENCY_CODE
and LANGUAGE_CODE
settings.
Note
cu()
is new in oTree 5. Previously, c()
was used to denote currencies.
Code that already uses c()
will continue to work.
More info here.
Use CurrencyField
to store currencies in the database.
For example:
class Player(BasePlayer):
random_bonus = models.CurrencyField()
To make a list of currency amounts, use currency_range
:
currency_range(0, 0.10, 0.02)
# this gives:
# [$0.00, $0.02, $0.04, $0.06, $0.08, $0.10]
In templates, instead of using the cu()
function, you should use the
|cu
filter.
For example, {{ 20|cu }}
displays as 20 points
.
payoffs¶
Each player has a payoff
field.
If your player makes money, you should store it in this field.
participant.payoff
automatically stores the sum of payoffs
from all subsessions. You can modify participant.payoff
directly,
e.g. to round the final payoff to a whole number.
At the end of the experiment, a participant’s
total profit can be accessed by participant.payoff_plus_participation_fee()
;
it is calculated by converting participant.payoff
to real-world currency
(if USE_POINTS
is True
), and then adding
session.config['participation_fee']
.
Points (i.e. “experimental currency”)¶
If you set USE_POINTS = True
, then currency amounts will be points instead of dollars/euros/etc.
For example, cu(10)
is displayed as 10 points
(or 10 puntos
, etc.)
You can decide the conversion rate to real money
by adding a real_world_currency_per_point
entry to your session config.
Converting points to real world currency¶
You can convert a points amount to money using the method
.to_real_world_currency
. For example:
cu(10).to_real_world_currency(session)
(The session
is necessary because
different sessions can have different conversion rates).
Decimal places¶
Money amounts are displayed with 2 decimal places.
On the other hand, points are integers.
This means amounts will get rounded to whole numbers,
like 10
divided by 3
is 3
.
So, we recommend using point magnitudes high enough that you don’t care about rounding error.
For example, set the endowment of a game to 1000 points, rather than 100.
MTurk & Prolific¶
MTurk¶
Overview¶
oTree provides integration with Amazon Mechanical Turk (MTurk):
- From oTree’s admin interface, you publish your session to MTurk.
- Workers on Mechanical Turk participate in your session.
- From oTree’s admin interface, you send each participant their participation fee and bonus (payoff).
Installation¶
MTurk template¶
Put the following inside your mturk_template.html
:
<script src="https://assets.crowd.aws/crowd-html-elements.js"></script>
<crowd-form>
<div style="padding: 20px">
<p>
This HIT is an academic experiment on decision making from XYZ University....
After completing this HIT, you will receive your reward plus a bonus payment....
</p>
<p>After
you have accepted this HIT, the URL to the study will appear here: <b><a class="otree-link">link</a></b>.
</p>
<p>
On the last page, you will be given a completion code.
Please copy/paste that code below.
</p>
<crowd-input name="completion_code" label="Enter your completion code here" required></crowd-input>
<br>
</div>
</crowd-form>
You can easily test out the appearance by putting it in an .html file on your desktop,
then double-clicking the HTML file to open it in your browser.
Modify the content inside the <crowd-form>
as you wish, but make sure it has the following:
- The link to the study, which should look like
<a class="otree-link">Link text</a>
. Once the user has accepted the assignment, oTree will automatically add thehref
to those links to make them point to your study. - If you want the completion code to be displayed in the oTree Admin interface (Payments tab),
you need a
<crowd-input>
namedcompletion_code
.
Making your session work on MTurk¶
On the last page of your study, give the user a completion code. For example, you can simply display: “You have completed the study. Your completion code is TRUST2020.” If you like, you can generate unique completion codes. You don’t need to worry too much about completion codes, because oTree tracks each worker by their MTurk ID and displays that in the admin interface and shows whether they arrived on the last page. The completion code is just an extra layer of verification, and it gives workers a specific objective which they are used to having.
Extra steps for non-Studio users¶
If you are not using oTree Studio, you need to additionally follow the steps here.
Local Sandbox testing¶
Before launching a study, you must create an employer account with MTurk,
to get your AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY
.
You can obtain these credentials in your AWS Management Console.
To test in the MTurk Sandbox locally, and see how it will appear to workers, you need to store these credentials onto your computer.
If using Windows, search for “environment variables” in the control panel, and create 2 environment variables so it looks like this:

On Mac, put your credentials into your ~/.bash_profile
file like this:
export AWS_ACCESS_KEY_ID=AKIASOMETHINGSOMETHING
export AWS_SECRET_ACCESS_KEY=yoursecretaccesskeyhere
Restart your command prompt and run oTree. From the oTree admin interface, click on “Sessions” and then, on the button that says “Create New Session”, select “For MTurk”:

Set environment variables on your web server¶
If using Heroku, go to your App Dashboard’s “settings”,
and set the config vars AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY
.
Qualification requirements¶
oTree uses boto3 syntax for qualification requirements.
Here is an example with 2 qualification requirements
that you can paste into your qualification_requirements
setting:
[
{
'QualificationTypeId': "3AWO4KN9YO3JRSN25G0KTXS4AQW9I6",
'Comparator': "DoesNotExist",
},
{
'QualificationTypeId': "4AMO4KN9YO3JRSN25G0KTXS4AQW9I7",
'Comparator': "DoesNotExist",
},
]
Here is how you would require workers from the US. (00000000000000000071 is the code for a location-based qualification.)
[
{
'QualificationTypeId': "00000000000000000071",
'Comparator': "EqualTo",
'LocaleValues': [{'Country': "US"}]
},
]
See the MTurk API reference. (However, note that the code examples there are in JavaScript, so you would need to modify the syntax to make it work in Python, e.g. adding quotes around dictionary keys.)
Note: when you are in sandbox mode, oTree ignores qualification requirements.
Preventing retakes (repeat workers)¶
To prevent a worker from participating twice, you can grant a Qualification to each worker in your study, and then block people who already have this Qualification.
Login to your MTurk requester account and create a qualification.
Go to your oTree MTurk settings and paste that qualification ID into grant_qualification_id
.
Then, add an entry to qualification_requirements
:
{
'QualificationTypeId': "YOUR_QUALIFICATION_ID_HERE",
'Comparator': "DoesNotExist",
},
Multiplayer games & dropouts¶
Games that involve wait pages are difficult on Mechanical Turk, because some participants drop out or delay starting the game until some time after accepting the assignment.
To mitigate this, see the recommendations in Preventing players from getting stuck on wait pages.
When you create a session with N participants for MTurk, oTree actually creates (N x 2) participants, because spares are needed in case some MTurk workers start but then return the assignment.
Managing your HITs¶
oTree provides the ability to approve/reject assignments, send bonuses, and expire HITs early.
If you want to do anything beyond this, (e.g. extend expiration date, interact with workers, send custom bonuses, etc), you will need to install the MTurk command-line tools.
Misc notes¶
If you are publishing to MTurk using another service like TurkPrime, you may not need to follow the steps on this page.
Miscellaneous¶
REST¶
oTree has a REST API that enables external programs (such as other websites) to communicate with oTree.
A REST API is just a URL on your server that is designed to be accessed by programs, rather than being opened manually in a web browser.
One project that uses the REST API a lot is oTree HR.
Setup¶
Note
“Where should I put this code?”
This code does not need to go inside your oTree project folder. Since the point of the REST API is to allow external programs and servers to communicate with oTree across the internet, you should put this code in that other program. That also means you should use whatever language that other server uses. The examples on this page use Python, but it’s simple to make HTTP requests using any programming language, or tools like webhooks or cURL.
import requests # pip3 install requests
from pprint import pprint
GET = requests.get
POST = requests.post
# if using Heroku, change this to https://YOURAPP.herokuapp.com
SERVER_URL = 'http://localhost:8000'
REST_KEY = '' # fill this later
def call_api(method, *path_parts, **params) -> dict:
path_parts = '/'.join(path_parts)
url = f'{SERVER_URL}/api/{path_parts}/'
resp = method(url, json=params, headers={'otree-rest-key': REST_KEY})
if not resp.ok:
msg = (
f'Request to "{url}" failed '
f'with status code {resp.status_code}: {resp.text}'
)
raise Exception(msg)
return resp.json()
“oTree version” endpoint¶
Note
New beta feature as of March 2021.
GET URL: /api/otree_version/
Example¶
data = call_api(GET, 'otree_version')
# returns: {'version': '5.0.0'}
“Session configs” endpoint¶
Note
New beta feature as of March 2021.
GET URL: /api/session_configs/
Returns the list of all your session configs, as dicts with all their properties.
Example¶
data = call_api(GET, 'session_configs')
pprint(data)
“Rooms” endpoint¶
Note
New beta feature as of March 2021.
GET URL: /api/rooms/
Example¶
data = call_api(GET, 'rooms')
pprint(data)
Example output (note it includes session_code
if there is currently a session in the room):
[{'name': 'my_room',
'session_code': 'lq3cxfn2',
'url': 'http://localhost:8000/room/my_room'},
{'name': 'live_demo',
'session_code': None,
'url': 'http://localhost:8000/room/live_demo'}]
“Create sessions” endpoint¶
POST URL: /api/sessions/
Here are some examples of how the “create sessions” endpoint can be used:
- Other websites can create oTree sessions automatically
- You can make a fancier alternative to oTree’s Configure sessions interface (e.g. with sliders and visual widgets)
- Process that will create new oTree sessions on some fixed schedule
- Command line script to create customized sessions
(if
otree create_session
is not sufficient)
Example¶
data = call_api(
POST,
'sessions',
session_config_name='trust',
room_name='econ101',
num_participants=4,
modified_session_config_fields=dict(num_apples=10, abc=[1, 2, 3]),
)
pprint(data)
Parameters¶
session_config_name
(required)num_participants
(required)modified_session_config_fields
: an optional dict of session config parameters, as discussed in Configure sessions.room_name
if you want to create the session in a room.
“Get session data” endpoint¶
Note
New feature as of March 2021. In beta until we get sufficient user feedback.
GET URL: /api/sessions/{code}
This API retrieves data about a session and its participants.
If participant_labels
is omitted, it returns data for all participants.
Example¶
data = call_api(GET, 'sessions', 'vfyqlw1q', participant_labels=['Alice'])
pprint(data)
“Session vars” endpoint¶
Note
As of April 2021, this endpoint requires you to pass a session code as a path parameter.
If the session is in a room, you can get the session code with the rooms
endpoint.
POST URL: /api/session_vars/{session_code}
This endpoint lets you set session.vars
.
One use is experimenter input.
For example, if the experimenter does a lottery drawing in the middle of the experiment,
they can input the result by running a script like the one below.
Example¶
call_api(POST, 'session_vars', 'vfyqlw1q', vars=dict(dice_roll=4))
“Participant vars” endpoint¶
POST URL: /api/participant_vars/{participant_code}
Pass information about a participant to oTree, via web services / webhooks.
Example¶
call_api(POST, 'participant_vars', 'vfyqlw1q', vars=dict(birth_year='1995', gender='F'))
“Participant vars for room” endpoint¶
POST URL: /api/participant_vars/
Similar to the other “participant vars” endpoint, but this one can be used when you don’t have the participant’s code. Instead, you identify the participant by the room name and their participant label.
Example¶
call_api(
POST,
'participant_vars',
room_name='qualtrics_study',
participant_label='albert_e',
vars=dict(age=25, is_male=True, x=[3, 6, 9]),
)
Parameters¶
room_name
(required)participant_label
(required)vars
(required): a dict of participant vars to add. Values can be any JSON-serializable data type, even nested dicts/lists.
You will need to give participants a link with a participant_label
,
although this does not need to come from a participant_label_file
.
Authentication¶
If you have set your auth level to DEMO or STUDY, you must authenticate your REST API requests.
Create an env var (i.e. Heroku config var) OTREE_REST_KEY
on the server. Set it to some secret value.
When you make a request, add that key as an HTTP header called otree-rest-key
.
If following the setup example above, you would set the REST_KEY
variable.
Demo & testing¶
For convenience during development, you can generate fake vars to simulate data that, in a real session, will come from the REST API.
In your session config, add the parameter mock_exogenous_data=True
(We call it exogenous data because it originates outside oTree.)
Then define a function with the same name (mock_exogenous_data
)
in your project’s shared_out.py (if you are using a text editor,
you may need to create that file).
Here’s an example:
def mock_exogenous_data(session):
participants = session.get_participants()
for pp in participants:
pp.vars.update(age=20, is_male=True) # or make it random
You can also set participant labels here.
When you run a session in demo mode, or using bots, mock_exogenous_data()
will automatically be run after creating_session
. However, it will not be run
if the session is created in a room.
If you have multiple session configs that require different exogenous data, you can branch like this:
def mock_exogenous_data(session):
if session.config['name'] == 'whatever':
...
if 'xyz' in session.config['app_sequence']:
...
Localization¶
Changing the language setting¶
Go to your settings and change LANGUAGE_CODE
:.
For example:
LANGUAGE_CODE = 'fr' # French
LANGUAGE_CODE = 'zh-hans' # Chinese (simplified)
This will customize certain things such validation messages and formatting of numbers.
Tips and tricks¶
Preventing code duplication¶
As much as possible, it’s good to avoid copy-pasting the same code in multiple places. Although it sometimes takes a bit of thinking to figure out how to avoid copy-pasting code, you will see that having your code in only one place usually saves you a lot of effort later when you need to change the design of your code or fix bugs.
Below are some techniques to achieve code reuse.
Don’t make multiple copies of your app¶
If possible, you should avoid copying an app’s folder to make a slightly different version, because then you have duplicated code that is harder to maintain.
If you need multiple rounds, set NUM_ROUNDS
.
If you need slightly different versions (e.g. different treatments),
then you should use the techniques described in Treatments,
such as making 2 session configs that have a different
'treatment'
parameter,
and then checking for session.config['treatment']
in your app’s code.
How to make many fields¶
Let’s say your app has many fields that are almost the same, such as:
class Player(BasePlayer):
f1 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f2 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f3 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f4 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f5 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f6 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f7 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f8 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f9 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
f10 = models.IntegerField(
choices=[-1, 0, 1], widget=widgets.RadioSelect,
blank=True, initial=0
)
# etc...
This is quite complex; you should look for a way to simplify.
Are the fields all displayed on separate pages? If so, consider converting this to a 10-round game with just one field.
If that’s not possible, then you can reduce the amount of repeated code by defining a function that returns a field:
def make_field(label):
return models.IntegerField(
choices=[1,2,3,4,5],
label=label,
widget=widgets.RadioSelect,
)
class Player(BasePlayer):
q1 = make_field('I am quick to understand things.')
q2 = make_field('I use difficult words.')
q3 = make_field('I am full of ideas.')
q4 = make_field('I have excellent ideas.')
Prevent duplicate pages by using multiple rounds¶
If you have many many pages that are almost the same, consider just having 1 page and looping it for multiple rounds. One sign that your code can be simplified is if it looks something like this:
# [pages 1 through 7....]
class Decision8(Page):
form_model = 'player'
form_fields = ['decision8']
class Decision9(Page):
form_model = 'player'
form_fields = ['decision9']
# etc...
Avoid duplicated validation methods¶
If you have many repetitive FIELD_error_message methods, you can replace them with a single error_message function. For example:
def quiz1_error_message(player, value):
if value != 42:
return 'Wrong answer'
def quiz2_error_message(player, value):
if value != 'Ottawa':
return 'Wrong answer'
def quiz3_error_message(player, value):
if value != 3.14:
return 'Wrong answer'
def quiz4_error_message(player, value):
if value != 'George Washington':
return 'Wrong answer'
You can instead define this function on your page:
@staticmethod
def error_message(player, values):
solutions = dict(
quiz1=42,
quiz2='Ottawa',
quiz3='3.14',
quiz4='George Washington'
)
error_messages = dict()
for field_name in solutions:
if values[field_name] != solutions[field_name]:
error_messages[field_name] = 'Wrong answer'
return error_messages
(Usually error_message
is used to return a single error message as a string, but you can also return a dict.)
Avoid duplicated page functions¶
Any page function can be moved out of the page class, and into a top-level function. This is a handy way to share the same function across multiple pages. For example, let’s say many pages need to have these 2 functions:
class Page1(Page):
@staticmethod
def is_displayed(player: Player):
participant = player.participant
return participant.expiry - time.time() > 0
@staticmethod
def get_timeout_seconds(player):
participant = player.participant
import time
return participant.expiry - time.time()
You can move those functions before all the pages (remove the @staticmethod
),
and then reference them wherever they need to be used:
def is_displayed1(player: Player):
participant = player.participant
return participant.expiry - time.time() > 0
def get_timeout_seconds1(player: Player):
participant = player.participant
import time
return participant.expiry - time.time()
class Page1(Page):
is_displayed = is_displayed1
get_timeout_seconds = get_timeout_seconds1
class Page2(Page):
is_displayed = is_displayed1
get_timeout_seconds = get_timeout_seconds1
(In the sample games, after_all_players_arrive
and live_method
are frequently defined in this manner.)
Improving code performance¶
You should avoid redundant use of get_players()
, get_player_by_id()
, in_*_rounds()
,
get_others_in_group()
, or any other methods that return a player or list of players.
These methods all require a database query,
which can be slow.
For example, this code has a redundant query because it asks the database 5 times for the exact same player:
@staticmethod
def vars_for_template(player):
return dict(
a=player.in_round(1).a,
b=player.in_round(1).b,
c=player.in_round(1).c,
d=player.in_round(1).d,
e=player.in_round(1).e
)
It should be simplified to this:
@staticmethod
def vars_for_template(player):
round_1_player = player.in_round(1)
return dict(
a=round_1_player.a,
b=round_1_player.b,
c=round_1_player.c,
d=round_1_player.d,
e=round_1_player.e
)
As an added benefit, this usually makes the code more readable.
Use BooleanField instead of StringField, where possible¶
Many StringFields
should be broken down into BooleanFields
, especially
if they only have 2 distinct values.
Suppose you have a field called treatment
:
treatment = models.StringField()
And let’s say treatment
it can only have 4 different values:
high_income_high_tax
high_income_low_tax
low_income_high_tax
low_income_low_tax
In your pages, you might use it like this:
class HighIncome(Page):
@staticmethod
def is_displayed(player):
return player.treatment == 'high_income_high_tax' or player.treatment == 'high_income_low_tax'
class HighTax(Page):
@staticmethod
def is_displayed(player):
return player.treatment == 'high_income_high_tax' or player.treatment == 'low_income_high_tax'
It would be much better to break this to 2 separate BooleanFields:
high_income = models.BooleanField()
high_tax = models.BooleanField()
Then your pages could be simplified to:
class HighIncome(Page):
@staticmethod
def is_displayed(player):
return player.high_income
class HighTax(Page):
@staticmethod
def is_displayed(player):
return player.high_tax
field_maybe_none¶
If you access a Player/Group/Subsession field whose value is None
, oTree will raise a TypeError
.
This is designed to catch situations where a user forgot to assign a value to that field,
or forgot to include it in form_fields
.
However, sometimes you need to intentionally access a field whose value may be None
.
To do this, use field_maybe_none
, which will suppress the error:
# instead of player.abc, do:
abc = player.field_maybe_none('abc')
# also works on group and subsession
Note
field_maybe_none
is new in oTree 5.4 (August 2021).
An alternative solution is to assign an initial value to the field so that its value is never None
:
abc = models.BooleanField(initial=False)
xyz = models.StringField(initial='')
Advanced features¶
These are advanced features that are mostly unsupported in oTree Studio.
ExtraModel¶
An ExtraModel is useful when you need to store dozens or hundreds of data points about a single player. For example, a list of bids, or a list of stimuli and reaction times. They are frequently used together with Live pages.
There are a bunch of examples here.
An ExtraModel should link to another model:
class Bid(ExtraModel):
player = models.Link(Player)
amount = models.CurrencyField()
Each time the user makes a bid, you store it in the database:
Bid.create(player=player, amount=500)
Later, you can retrieve the list of a player’s bids:
bids = Bid.filter(player=player)
An ExtraModel can have multiple links:
class Offer(ExtraModel):
sender = models.Link(Player)
receiver = models.Link(Player)
group = models.Link(Group)
amount = models.CurrencyField()
accepted = models.BooleanField()
Then you can query it in various ways:
this_group_offers = Offer.filter(group=group)
offers_i_accepted = Offer.filter(receiver=player, accepted=True)
For more complex filters and sorting, you should use list operations:
offers_over_500 = [o for o in Offer.filter(group=group) if o.amount > 500]
See the example psychology games such as the Stroop task, which show how to generate ExtraModel data from each row of a CSV spreadsheet.
To export your ExtraModel data to CSV/Excel, use Custom data exports.
Reading CSV files¶
Note
This feature is in beta (new in oTree 5.8.2)
To read a CSV file (which can be produced by Excel or any other spreadsheet app),
you can use read_csv()
. For example, if you have a CSV file like this:
name,price,is_organic
Apple,0.99,TRUE
Mango,3.79,FALSE
read_csv()
will output a list of dicts, like:
[dict(name='Apple', price=0.99, is_organic=True),
dict(name='Mango', price=3.79, is_organic=False)]
You call the function like this:
rows = read_csv('my_app/my_data.csv', Product)
The second argument is a class that specifies the datatype of each column:
class Product(ExtraModel):
name = models.StringField()
price = models.FloatField()
is_organic = models.BooleanField()
(Without this info, it would be ambiguous whether TRUE
is supposed to be a bool,
or the string 'TRUE'
, etc.)
read_csv()
does not actually create any instances of that class.
If you want that, you must use .create()
additionally:
rows = read_csv('my_app/my_data.csv', Product)
for row in rows:
Product.create(
name=row['name'],
price=row['price'],
is_organic=row['is_organic'],
# any other args:
player=player,
)
The model can be an ExtraModel
, Player
, Group
, or Subsession
.
It’s fine if it also contains other fields; they will be ignored by read_csv()
.
Templates¶
template_name¶
If the template needs to have a different name from your
page class (e.g. you are sharing the same template for multiple pages),
set template_name
. Example:
class Page1(Page):
template_name = 'app_name/MyPage.html'
CSS/JS and base templates¶
To include the same JS/CSS in all pages of an app, either put it in a static file or put it in an includable template.
Static files¶
Here is how to include images (or any other static file like .css, .js, etc.) in your pages.
At the root of your oTree project, there is a _static/
folder.
Put a file there, for example puppy.jpg
.
Then, in your template, you can get the URL to that file with
{{ static 'puppy.jpg' }}
.
To display an image, use the <img>
tag, like this:
<img src="{{ static 'puppy.jpg' }}"/>
Above we saved our image in _static/puppy.jpg
,
But actually it’s better to make a subfolder with the name of your app,
and save it as _static/your_app_name/puppy.jpg
, to keep files organized
and prevent name conflicts.
Then your HTML code becomes:
<img src="{{ static 'your_app_name/puppy.jpg }}"/>
(If you prefer, you can also put static files inside your app folder,
in a subfolder called static/your_app_name
.)
If a static file is not updating even after you changed it, this is because your browser cached the file. Do a full page reload (usually Ctrl+F5)
If you have videos or high-resolution images, it’s preferable to store them somewhere online and reference them by URL because the large file size can make uploading your .otreezip file much slower.
Wait pages¶
Custom wait page template¶
You can make a custom wait page template.
For example, save this to your_app_name/MyWaitPage.html
:
{{ extends 'otree/WaitPage.html' }}
{{ block title }}{{ title_text }}{{ endblock }}
{{ block content }}
{{ body_text }}
<p>
My custom content here.
</p>
{{ endblock }}
Then tell your wait page to use this template:
class MyWaitPage(WaitPage):
template_name = 'your_app_name/MyWaitPage.html'
Then you can use vars_for_template
in the usual way.
Actually, the body_text
and title_text
attributes
are just shorthand for setting vars_for_template
;
the following 2 code snippets are equivalent:
class MyWaitPage(WaitPage):
body_text = "foo"
class MyWaitPage(WaitPage):
@staticmethod
def vars_for_template(player):
return dict(body_text="foo")
If you want to apply your custom wait page template globally,
save it to _templates/global/WaitPage.html
.
oTree will then automatically use it everywhere instead of the built-in wait page.
Currency¶
To customize the name “points” to something else like “tokens” or “credits”,
set POINTS_CUSTOM_NAME
, e.g. POINTS_CUSTOM_NAME = 'tokens'
.
You can change the number of decimal places in real world currency amounts
with the setting REAL_WORLD_CURRENCY_DECIMAL_PLACES
.
If the extra decimal places show up but are always 0,
then you should reset the database.
Bots: advanced features¶
These are advanced features that are mostly unsupported in oTree Studio.
Command line bots¶
An alternative to running bots in your web browser is to run them in the command line. Command line bots run faster and require less setup.
Run this:
otree test mysession
To test with a specific number of participants
(otherwise it will default to num_demo_participants
):
otree test mysession 6
To run tests for all session configs:
otree test
Exporting data¶
Use the --export
flag to export the results to a CSV file:
otree test mysession --export
To specify the folder where the data is saved, do:
otree test mysession --export=myfolder
Command-line browser bots¶
You can launch browser bots from the command line, using otree browser_bots
.
Make sure Google Chrome is installed, or set
BROWSER_COMMAND
insettings.py
(more info below).Set
OTREE_REST_KEY
env var as described in REST.Run your server
Close all Chrome windows.
Run this:
otree browser_bots mysession
This will launch several Chrome tabs and run the bots. When finished, the tabs will close, and you will see a report in your terminal window.
If Chrome doesn’t close windows properly, make sure you closed all Chrome windows prior to launching the command.
Command-line browser bots on a remote server (e.g. Heroku)¶
If the server is running on a host/port other than the usual http://localhost:8000
,
you need to pass --server-url
.
For example, if it’s on Heroku, you would do like this:
otree browser_bots mysession --server-url=https://YOUR-SITE.herokuapp.com
Choosing session configs and sizes¶
You can specify the number of participants:
otree browser_bots mysession 6
To test all session configs, just run this:
otree browser_bots
Browser bots: misc notes¶
You can use a browser other than Chrome by setting BROWSER_COMMAND
in settings.py
. Then, oTree will open the browser by doing something like
subprocess.Popen(settings.BROWSER_COMMAND)
.
Test cases¶
You can define an attribute cases
on your PlayerBot class
that lists different test cases.
For example, in a public goods game, you may want to test 3 scenarios:
- All players contribute half their endowment
- All players contribute nothing
- All players contribute their entire endowment (100 points)
We can call these 3 test cases “basic”, “min”, and “max”, respectively,
and put them in cases
. Then, oTree will execute the bot 3 times, once for
each test case. Each time, a different value from cases
will be assigned to self.case
in the bot.
For example:
class PlayerBot(Bot):
cases = ['basic', 'min', 'max']
def play_round(self):
yield (pages.Introduction)
if self.case == 'basic':
assert self.player.payoff == None
if self.case == 'basic':
if self.player.id_in_group == 1:
for invalid_contribution in [-1, 101]:
yield SubmissionMustFail(pages.Contribute, {'contribution': invalid_contribution})
contribution = {
'min': 0,
'max': 100,
'basic': 50,
}[self.case]
yield (pages.Contribute, {"contribution": contribution})
yield (pages.Results)
if self.player.id_in_group == 1:
if self.case == 'min':
expected_payoff = 110
elif self.case == 'max':
expected_payoff = 190
else:
expected_payoff = 150
assert self.player.payoff == expected_payoff
Note
If you use cases, it’s better to use Command line bots since browser bots will only execute a single case.
cases
needs to be a list, but it can contain any data type, such as strings,
integers, or even dictionaries. Here is a trust game bot that uses dictionaries
as cases.
class PlayerBot(Bot):
cases = [
{'offer': 0, 'return': 0, 'p1_payoff': 10, 'p2_payoff': 0},
{'offer': 5, 'return': 10, 'p1_payoff': 15, 'p2_payoff': 5},
{'offer': 10, 'return': 30, 'p1_payoff': 30, 'p2_payoff': 0}
]
def play_round(self):
case = self.case
if self.player.id_in_group == 1:
yield (pages.Send, {"sent_amount": case['offer']})
else:
for invalid_return in [-1, case['offer'] * C.MULTIPLICATION_FACTOR + 1]:
yield SubmissionMustFail(pages.SendBack, {'sent_back_amount': invalid_return})
yield (pages.SendBack, {'sent_back_amount': case['return']})
yield (pages.Results)
if self.player.id_in_group == 1:
expected_payoff = case['p1_payoff']
else:
expected_payoff = case['p2_payoff']
assert self.player.payoff == expected_payoff
error_fields¶
When using SubmissionMustFail
on forms with multiple fields, you can
use error_fields
for extra thoroughness.
For example, let’s say we a submit a valid age
, but
an invalid weight
and height
:
yield SubmissionMustFail(
pages.Survey,
dict(
age=20,
weight=-1,
height=-1,
)
)
What’s missing is that the bot system doesn’t tell us exactly why
the submission fails. Is it an invalid weight
, height
, or both?
error_fields
can resolve the ambiguity:
yield SubmissionMustFail(
pages.Survey,
dict(
age=20,
weight=-1,
height=-1,
),
error_fields=['weight', 'height']
)
This will verify that weight
and height
contained errors,
but age
did not.
If error_message returns an error,
then error_fields
will be ['__all__']
.
Misc note¶
In bots, it is risky to assign
player = self.player
(or participant = self.participant
, etc),
even though that kind of code is encouraged elsewhere.
Because if there is a yield
in between, the data can be stale:
player = self.player
expect(player.money_left, cu(10))
yield pages.Contribute, dict(contribution=cu(1))
# don't do this!
# "player" variable still has the data from BEFORE pages.Contribute was submitted.
expect(player.money_left, cu(9))
It’s safer to use self.player.money_left
directly,
because doing self.player
gets the most recent data from the database.
Live pages¶
To test live methods with bots, define call_live_method
as a top-level function in tests.py
.
(Not available in oTree Studio.)
This function should simulate the sequence of calls to your live_method
.
The argument method
simulates the live method on your Player model.
For example, method(3, 'hello')
calls the live method on Player 3 with data
set to 'hello'
.
For example:
def call_live_method(method, **kwargs):
method(1, {"offer": 50})
method(2, {"accepted": False})
method(1, {"offer": 60})
retval = method(2, {"accepted": True})
# you can do asserts on retval
kwargs
contains at least the following parameters.
case
as described in Test cases.page_class
: the current page class, e.g.pages.MyPage
.round_number
call_live_method
will be automatically executed when the fastest bot in the group
arrives on a page with live_method
.
(Other bots may be on previous pages at that point, unless you restrict this with a WaitPage.)
oTree Lite¶
oTree 5 is based on oTree Lite, a new implementation of oTree that runs as a self-contained framework, not dependent on Django.
oTree Lite’s codebase is simpler and more self-contained. This makes it easier for me to add new features, investigate bug reports, and keep oTree simple to use.
Django comes with many features “out of the box”, so being based on Django initially helped oTree add new features and iterate quickly. However, Django brings complexity and restrictions. In the long run, the “framework inside a framework” approach becomes more of a liability.
Other advantages of oTree Lite:
- Simpler error messages
- Fewer dependencies such as Twisted that cause installation problems for some people
- Compatible with more versions of Python
- No need for Redis or second dyno
- Better performance
For the curious people who want to delve into oTree’s internal source code, you will have an easier time navigating oTree Lite.
How can I ensure I stay on oTree 3.x?¶
To ensure that you don’t install oTree Lite, you can specify <5
when you upgrade:
pip3 install -U "otree<5"
For Heroku, use one of the following formats in your requirements.txt
(replace 3.3.7 with whatever 3.x version you want):
otree<5
# or:
otree>=3.3.7,<5
# or:
otree==3.3.7
Upgrading¶
Note
I have set up a live chat on Discord to assist people upgrading from previous versions of oTree to oTree Lite.
oTree Lite is generally compatible with previous oTree apps. However, you will probably see small things that changed, especially in how forms and templates are rendered. This is somewhat inevitable as oTree has undergone a “brain transplant”. Please send any feedback to chris@otree.org.
Here are the most important differences:
Templates¶
The template system is basically compatible with Django templates, except that only the basic tags & filters have been implemented:
- Tags:
{{ if }}
,{{ for }}
,{{ block }}
- Filters:
{{ x|json }}
,{{ x|escape }}
,{{ x|c }}
,{{ x|default("something") }}
There is no floatformat
filter, but there are new rounding filters that replace it.
For example:
{{ pi|floatformat:0 }} -> {{ pi|to0 }}
{{ pi|floatformat:1 }} -> {{ pi|to1 }}
{{ pi|floatformat:2 }} -> {{ pi|to2 }}
The |safe
filter and mark_safe
are not needed anymore, because the new template system does not
autoescape content. However, if you want to escape content (e.g. displaying an untrusted string to a different
player), you should use the |escape
filter.
Method calls must be at the end of the expression, and not followed by more dots.
For example, if you have a Player method called other_player()
,
you can do:
Your partner is {{ player.other_player }}
But you cannot do:
Your partner's decision was {{ player.other_player.decision }}
Forms¶
In templates, if you are doing manual form rendering, you should change
{% form.my_field.errors %}
to {{ formfield_errors 'my_field' }}
.
Older oTree formats¶
oTree Lite does not implement support for certain features found in older oTree
projects. To check you should run otree update_my_code
,
which will tell you the changes you need to make before your code can run on oTree Lite.
(It will also fix a few things automatically.)
A few common issues:
- The
Slider
widget is unavailable. You should instead use Raw HTML widgets (which has been the recommended solution anyway)
Bootstrap¶
Since bootstrap 5 beta just got released, I included it in this package. Certain things are different from bootstrap 4; consult the bootstrap migration docs. In my experience the main things that differed are:
data-*
attributes are renamed todata-bs-*
form-group
no longer exists
Misc¶
- In
get_group_matrix
returns a matrix of integers, rather than a matrix of player objects. To preserve the previous behavior, you should passobjects=True
, like.get_group_matrix(objects=True)
. - Translating an app to multiple languages works differently. See Localization.
- If you try to access a Player/Group/Subsession field whose value is still
None
, oTree will raise an error. More details here: field_maybe_none.
Django¶
This new implementation does not use Django or Channels in any way. So, it will not run any code you got from Django documentation, such as Django views, ModelForms, ORM, etc.
Version history¶
Version 5.10¶
For IntegerField/FloatField/CurrencyField, if min
is not specified, it will be assumed to be 0.
If you need a form field to accept negative values, set min=
to a negative value (or None
).
Benefits of this change:
- Most numeric inputs on mobile can now use the numeric keypad
- Prevents unintended negative inputs from users.
For example, if you forgot to specify
min=0
for your “contribution” field, then a user could ‘hack’ the game by entering a negative contribution.
Other changes:
- MTurk integration works even on Python >= 3.10 (removed dependency on the boto3 library)
- Python 3.11 support
- bots: better error message when bot is on the wrong page
Version 5.9¶
- Improved dropout detection
- Renamed
formInputs
(JavaScript variable) toforminputs
- 5.9.5: fix bug that points inputs allow decimal numbers when they should be whole numbers.
Version 5.8¶
- Better dropout detection with group_by_arrival_time; see here.
- Python 3.10 support
- Fix various websocket-related errors such as ConnectionClosedOK, IncompleteReadError, ClientDisconnect that tend to happen intermittently, especially with browser bots.
Version 5.6¶
- Added access to form inputs through JavaScript.
Version 5.4¶
- PARTICIPANT_FIELDS are now included in data export
- field_maybe_none
- Radio buttons can now be accessed by numeric index, e.g.
{{ form.my_field.0 }}
. - Bugfix with numpy data types assigned to model fields
- Misc improvements and fixes
Version 5.3¶
- Bugfix to deleting sessions in devserver
{{ static }}
tag checks that the file exists- In SessionData tab, fix the “next round”/”previous round” icons on Mac
- Fix to currency formatting in Japanese/Korean/Turkish currency (numbers were displayed with a decimal when there should be none)
- allow error_message to be run on non-form pages (e.g. live pages)
- Better error reporting when an invalid value is passed to
js_vars
- Minor fixes & improvements
Version 5.2¶
- For compatibility with oTree 3.x,
formfield
<input>
elements now prefix theirid
attribute withid_
. If you usegetElementById
/querySelector
/etc. to select any formfield inputs, you might need to update your selectors. - The data export now outputs “time started” as UTC.
- “Time spent” data export has a column name change.
If you have been using the
pagetimes.py
script, you should download the new version.
Version 5.1¶
- Breaking changes to REST API
Version 5.0¶
- oTree Lite
- The no-self format
- The beta method
Player.start()
has been removed. cu()
is now available as an alias forCurrency
.c()
will still work as long as you havefrom otree.api import Currency as c
at the top of your file. More details here.- oTree 3.x used two types of tags in templates:
{{ }}
and{% %}
. Starting in oTree 5, however, you can forget about{% %}
and just use{{ }}
everywhere if you want. More details here. - All REST API calls now return JSON
Version 3.3¶
- BooleanField now uses radio buttons by default (instead of dropdown)
otree zip
can now keep your requirements.txt up to date.- oTree no longer installs sentry-sdk. If you need Sentry on Heroku, you should add it to your requirements.txt manually.
- Faster server
- Faster startup time
- Faster installation
- Data export page no longer outputs XLSX files. Instead it outputs CSV files formatted for Excel
- Admin UI improvements, especially session data tab
Version 3.2¶
- Should use less memory and have fewer memory spikes.
- Enhancements to SessionData and SessionMonitor.
Version 3.1¶
- New way to define Roles
- You can pass a string to
formfield
, for example{{ formfield 'contribution' }}
.
Version 3.0¶
Live pages¶
See Live pages.
Custom data export¶
See Custom data exports.
Other things¶
- Python 3.8 is now supported.
- Speed improvements to devserver & zipserver
- You can now download a single session’s data as Excel or CSV (through session’s Data tab)
- When browser bots complete, they keep the last page open
- group_by_arrival_time: quicker detection if a participant goes offline
- Browser bots use the REST API to create sessions (see REST).
- Instead of
runprodserver
you can now useprodserver
(that will be the preferred name going forward). - “Page time” data export now has more details such as whether it is a wait page.
devserver
andzipserver
now must usedb.sqlite3
as the database.
Version 2.5¶
- Removed old
runserver
command. - Deprecated non-oTree widgets and model fields. See here.
Version 2.4¶
zipserver
command- New MTurk format
- oTree no longer records participants’ IP addresses.
Version 2.3¶
- Various improvements to performance, stability, and ease of use.
- oTree now requires Python 3.7
- oTree now uses Django 2.2.
- Chinese/Japanese/Korean currencies are displayed as 元/円/원 instead of ¥/₩.
- On Windows,
prodserver
just launches 1 worker process. If you want more processes, you should use a process manager. (This is due to a limitation of the ASGI server) prodserver
uses Uvicorn/Hypercorn instead of Daphne- update_my_code has been removed
Version 2.2¶
- support for the
otreezip
format (otree zip
,otree unzip
) - MTurk: in sandbox mode, don’t grant qualifications or check qualification requirements
- MTurk: before paying participants, check if there is adequate account balance.
- “next button” is disabled after clicking, to prevent congesting the server with duplicate page loads.
- Upgrade to the latest version of Sentry
- Form validation methods should go on the model, not the page. See Dynamic form field validation
- app_after_this_page
- Various performance and stability improvements
Version 2.1¶
- oTree now raises an error if you use an undefined variable in your template.
This will help catch typos like
{{ Player.payoff }}
or{{ if player.id_in_gruop }}
. This means that apps that previously worked may now get a template error (previously, it failed silently). If you can’t remove the offending variable, you can apply the|default
filter, like:{{ my_undefined_variable|default:None }}
- oTree now warns you if you use an invalid attribute on a Page/WaitPage.
- CSV/Excel data export is done asynchronously, which will fix timeout issues for large files on Heroku.
- Better performance, especially for “Monitor” and “Data” tab in admin interface
The new no-self format¶
Since 2021, there has been a new optional format for oTree apps.
It replaces models.py
and pages.py
with a single __init__.py
.
The new format unifies oTree’s syntax.
For example, before, you needed to write either player.payoff
, self.payoff
,
or self.player.payoff
, depending on what part of the code you were in.
Now, you can always write player.payoff
.
In fact, the self
keyword has been eliminated entirely
If you use oTree Studio, your code has already been upgraded to the no-self format.
If you use a text editor, you can keep using the existing format, or use the new one if you wish. They both have access to the same features. The models.py format will continue to be fully supported and get access to the newest features.
Note
In January 2022, constants changed format also. See 2022 Constants format change
About the new format¶
- “self” is totally gone from your app’s code.
- Whenever you want to refer to the player, you write player. Same for group and subsession.
- Each method in oTree is changed to a function.
- There is no more models.py and pages.py. The whole game fits into one file (__init__.py).
- Everything else stays the same. All functions and features do the same thing as before.
Here is an example of an __init__.py in the “no self” format (with the dictator game):
class Subsession(BaseSubsession):
pass
class Group(BaseGroup):
pass
class Player(BasePlayer):
kept = models.CurrencyField(
min=0,
max=C.ENDOWMENT,
label="I will keep",
)
# FUNCTIONS
def set_payoffs(group):
player1 = group.get_player_by_id(1)
player2 = group.get_player_by_id(2)
player1.payoff = group.kept
player2.payoff = C.ENDOWMENT - group.kept
# PAGES
class Introduction(Page):
pass
class Offer(Page):
form_model = 'group'
form_fields = ['kept']
def is_displayed(player):
return player.id_in_group == 1
class ResultsWaitPage(WaitPage):
after_all_players_arrive = 'set_payoffs'
class Results(Page):
@staticmethod
def vars_for_template(player):
group = player.group
return dict(payoff=player.payoff, offer=C.ENDOWMENT - group.kept)
So, what has changed?
- As you see, set_payoffs has changed from a group method to a regular function that takes “group” as its argument. This should be clearer to most people.
- is_displayed and vars_for_template are no longer page methods that take an argument ‘self’, but direct functions of the player. Now you can directly write ‘player’ without needing ‘self.’ in front of it. (If you are using a text editor like PyCharm, you should add @staticmethod before vars_for_template and is_displayed to indicate that they are not regular methods.)
- There is no longer any distinction between page methods and model methods. The is_displayed and vars_for_template can freely be moved up into the “FUNCTIONS” section, and reused between pages, or put inside a page class if they only pertain to that class.
- The app folder is simplified from this:
To this:
dictator/
__init__.py
Decide.html
Results.html
Also, the “import” section at the top is simplified.
Before:
# models.py
from otree.api import (
models,
widgets,
BaseConstants,
BaseSubsession,
BaseGroup,
BasePlayer,
Currency as c,
currency_range
)
# pages.py
from otree.api import Currency as c, currency_range
from ._builtin import Page, WaitPage
from .models import Constants
After:
# __init__.py
from otree.api import *
You can see the sample games in the new format here: here.
How does this affect you?¶
This no-self format is only available with oTree Lite. oTree Lite supports both formats. Within the same project, you can have some apps that use the models.py format, and some that use the no-self format.
There is a command “otree remove_self” that can automatically convert the models.py format to the no-self format. This is for people who are curious what their app would look like in the no-self format. Later, I will describe this command and how to use it.
FAQ¶
Q: Do I need to change my existing apps? A: No, you can keep them as is. The “no-self” format is optional.
Q: Will I have to re-learn oTree for this new format? A: No, you don’t really need to relearn anything. Every function, from creating_session, to before_next_page, etc, does the same thing as before. And there are no changes to other parts of oTree like templates or settings.py.
Q: Why didn’t you implement it this way originally? A: The first reason is that oTree got its structure from Django. But now that I made oTree Lite which is not based on Django, I have more freedom to design the app structure the way I see fit. The second reason is that this is quite a tailored design. It was necessary to wait and see how oTree evolved and how people use oTree before I could come up with the most appropriate design.
How to use it¶
First, ensure that you are using oTree Lite:
pip3 install -U otree
Then do one of the following:
- Convert your existing apps using
otree remove_self
, as described in this page. - Create a new project.
There are now 2 branches of the documentation. These docs you are reading now are based on the no-self format (see the note at the top of the page).
Try it out and send me any feedback!
The “otree remove_self” command¶
If you prefer the no-self format, or are curious what your app would look like in this format, follow these steps. First, then install oTree Lite:
pip3 install -U otree
Run:
otree remove_self
otree upcase_constants
Note this command pretty aggressively converts all your model methods to functions,
e.g. changing player.foo()
to foo(player)
.
If you have a lot of custom methods,
you should check that your method calls still work.
Misc notes¶
before_next_page
now takes a second argtimeout_happened
.- You can optionally add a type hint to your function signatures. For example,
change
def xyz(player)
todef xyz(player: Player)
. If you use PyCharm or VS Code, that will mean you get better autocompletion.