oTree

_images/splash.png

中文 | 日本語

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

Support

For help, post to our forum.

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 of 1000.
  • 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)

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:

_images/session_subsession.png

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:

_images/participant_player.png

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.

payoff

The player’s payoff in this round. See payoffs.

round_number

Gives the current round number.

Session

num_participants

The number of participants in the session.

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:

  • PLAYERS_PER_GROUP (described in Groups)
  • NUM_ROUNDS (described in Rounds)

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

timeout_seconds

See Timeouts

Wait pages

See Wait pages

Randomizing page sequence

You can randomize the order of pages using rounds. An example is here.

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 page
  • group: the group the current player belongs to
  • subsession: the subsession the current player belongs to
  • participant: the participant the current player belongs to
  • session: the current session
  • C
  • 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:

_images/template-example.png

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:

_images/dom-inspector.png

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.

Forms in templates

In your template, you can display the form with:

{{ formfields }}

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 be None.
  • 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 &nbsp;
    {{ for choice in form.pizza }}
        {{ choice }}
    {{ endfor }}
    &nbsp; 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:

_images/yes-no-buttons.png

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

See 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 in page_sequence
  • If you use is_displayed on a page with group_by_arrival_time, it should only be based on the round number. Don’t use is_displayed to show the page to some players but not others.
  • If group_by_arrival_time = True, then in creating_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 to True.
  • 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.

See Chat between participants and experimenter.

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.

_images/edit-config.png

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

Hiding the timer

If you want to hide the timer, use this CSS:

.otree-timer {
    display: none;
}
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

See Bots: 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:

  1. A user marks a square, so you need to notify the other player
  2. 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)

Install apt-get packages

Run:

sudo apt-get install python3-pip git

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.

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:

_images/admin-report.png

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, and C are passed to the template automatically.
  • admin_report.html does not need to use {{ block }}. The above example is valid as the full contents of admin_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:

_images/room-combined.png

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:

_images/room-combined.png

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

See “Participant vars for room” endpoint.

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):

  1. From oTree’s admin interface, you publish your session to MTurk.
  2. Workers on Mechanical Turk participate in your session.
  3. 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:

  1. 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 the href to those links to make them point to your study.
  2. If you want the completion code to be displayed in the oTree Admin interface (Payments tab), you need a <crowd-input> named completion_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:

_images/env-vars.png

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”:

_images/create-mturk-session.png

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.

Prolific

If you’re using Prolific, we recommend setting up oTree HR, which will automatically handle start links, completion URLs, and payments.

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.

Writing your app in multiple languages

You may want your own app to work in multiple languages. For example, let’s say you want to run the same experiment with English, French, and Chinese participants.

For an example, see the “multi_language” app here.

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 in settings.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 to data-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 pass objects=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) to forminputs
  • 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 their id attribute with id_. If you use getElementById/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 for Currency. c() will still work as long as you have from 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.

REST API

See REST

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 use prodserver (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 and zipserver now must use db.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

  1. “self” is totally gone from your app’s code.
  2. Whenever you want to refer to the player, you write player. Same for group and subsession.
  3. Each method in oTree is changed to a function.
  4. There is no more models.py and pages.py. The whole game fits into one file (__init__.py).
  5. 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?

  1. 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.
  2. 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.)
  3. 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.
  4. 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:

  1. Convert your existing apps using otree remove_self, as described in this page.
  2. 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 arg timeout_happened.
  • You can optionally add a type hint to your function signatures. For example, change def xyz(player) to def xyz(player: Player). If you use PyCharm or VS Code, that will mean you get better autocompletion.

Indices and tables

(Thank you to contributors)