| description | Form groups with GroupCreatingWait, assign roles with after_grouping, access group members, store group data, and create groups manually. |
|---|
Many experiments require participants to interact in pairs or small groups. uproot makes grouping easy with the GroupCreatingWait page type, which automatically forms groups as participants arrive.
Groups are only created when participants reach a GroupCreatingWait page. uproot does not pre-assign groups when a session starts. Grouping happens at runtime as participants arrive. This means the number of groups depends on how many participants actually show up, not on how many were expected.
Create a wait page that groups participants by subclassing GroupCreatingWait and setting group_size:
class WaitForPartner(GroupCreatingWait):
group_size = 2When participants reach this page, they wait until enough others arrive to form a group. Once a group forms, all members advance to the next page together.
A wait page only groups participants who have actually opened it. For example, if you advance participants to the wait page from the admin interface while their browser is closed, they join a group once they come back and the page loads. Participants are also only grouped with others on the same page: if Between gives participants different page orders, a wait page never groups them with someone who is on a different page at the same position.
page_order = [
WaitForPartner,
GamePage,
Results,
]:material-github: See the prisoners_dilemma example
Use the after_grouping callback to assign roles or initialize group members when the group forms:
class GroupPlease(GroupCreatingWait):
group_size = 2
@classmethod
def after_grouping(page, group):
for player, is_dictator in zip(group.players, [True, False]):
player.dictator = is_dictatorThe callback receives the group object and runs exactly once when the group is created. All players in the group can then access their assigned attributes.
A participant only joins a group after the wait page’s own early and before_always_once methods have finished for them. So anything these methods save on player is already there when after_grouping runs.
:material-github: See the dictator_game example · trust_game example · ultimatum_game example · gift_exchange_game example
You can define role values as class attributes for cleaner code:
class GroupPlease(GroupCreatingWait):
group_size = 2
watch_values = (True, False)
@classmethod
def after_grouping(page, group):
for player, watched in zip(group.players, page.watch_values):
player.watched = watched:material-github: See the observed_diary example
Get all players in a group as a StorageBunch using the virtual field:
for player in group.players:
player.payoff = 10The StorageBunch supports iteration, filtering, and bulk operations.
# Sum contributions from all group members
total = sum(p.contribution for p in group.players)
# Unpack directly (e.g. in SynchronizingWait.all_here)
p1, p2 = group.playersThe _ symbol is a field referent: a placeholder that stands for “each player” in a filter expression. When you write _.cooperate == True, uproot builds a comparison object that gets evaluated against each player in the collection.
# Players who cooperated
cooperators = group.players.filter(_.cooperate == True)
# Players with a score above 50
high_scorers = session.players.filter(_.score > 50)
# Combine multiple conditions (all must be true)
eligible = session.players.filter(_.present == True, _.age >= 18)_ supports all comparison operators: ==, !=, >, >=, <, <=. You can also chain attribute access. For instance, _.group.round refers to each player’s group’s round field.
!!! note
_ builds a lazy comparison, so _.active alone (without a comparison operator) tests for truthiness. To check for False, write _.active == False explicitly.
find_one() returns exactly one player matching the criteria. It raises an error if zero or multiple players match.
dictator = group.players.find_one(dictator=True)
leader = group.players.find_one(first_mover=True)each() collects a field from every player into a list:
# Get all contributions as a list
contributions = group.players.each("contribution")
# → [10, 20, 15]
# Multiple fields return named tuples
data = group.players.each("name", "score", simplify=False)
# → [data(name='Alice', score=10), data(name='Bob', score=20)]assign() sets a field on every player from an iterable:
group.players.assign("role", ["buyer", "seller"])apply() calls a function once per player:
group.players.apply(set_payoff)See Synchronizing progress for a full example.
For two-person groups, access the other player via the virtual field:
other = player.other_in_group
if player.cooperate and other.cooperate:
player.payoff = 10Raises an error if the group does not have exactly two members.
:material-github: See the prisoners_dilemma example · twobytwo example
For groups of any size, get all other members (excluding the current player):
for other in player.others_in_group:
notify(player, other, "Hello!")Access the shared group storage with player.group:
class Calculate(SynchronizingWait):
@classmethod
def all_here(page, group):
# Store a value at the group level
group.total_contribution = sum(p.contribution for p in group.players)In templates, access group data:
<p>Your group contributed {{ player.group.total_contribution }} in total.</p>session.groups lists every group in the session. In a multi-app config, pass app= to keep only groups created in that app:
for group in session.groups(app=__name__):
print(group.name, len(group.players))For experiments with more than two players per group, simply increase group_size:
class GroupPlease(GroupCreatingWait):
group_size = 3Use the same patterns for accessing members:
class Sync(SynchronizingWait):
@classmethod
def all_here(page, group):
total = sum(p.contribution for p in group.players)
for player in group.players:
player.payoff = ENDOWMENT - player.contribution + MPCR * total:material-github: See the public_goods_game example · beauty_contest example · minimum_effort_game example
For custom matching logic, you can create groups programmatically using create_group() and create_groups() instead of GroupCreatingWait. This is useful when you need to:
- Match participants based on survey responses
- Form groups of different sizes
- Sort or balance groups by some criteria
Use SynchronizingWait to wait for all participants, then create groups in the all_here callback:
class WaitForEveryone(SynchronizingWait):
synchronize = "session"
@classmethod
def all_here(page, session):
# Get all players and sort alphabetically by name
all_players = sorted(session.players, key=lambda p: p.name)
if len(all_players) == 1:
# Only one player
create_group(session, all_players)
else:
# Split into two groups of roughly equal size
mid = len(all_players) // 2
group_a = all_players[:mid]
group_b = all_players[mid:]
create_groups(session, [group_a, group_b])
class ShowGroup(Page):
@classmethod
def templatevars(page, player):
group_members = sorted(player.group.players, key=lambda p: p.name)
return dict(
group_name=player.group.name,
group_members=group_members,
)
page_order = [
WaitForEveryone,
ShowGroup,
]:material-github: See the grouping example · grouping_arbitrary_size example · grouping_one_spare example · group_with_dropout example
Creates a single group from a list of players:
# Create a group from specific players
gid = create_group(session, [player1, player2])
# With a custom group name
gid = create_group(session, members, gname="custom_name")
# Allow reassigning players already in groups
gid = create_group(session, members, overwrite=True)Creates multiple groups at once:
# Create pairs from a list of players
all_players = list(session.players)
pairs = [[all_players[i], all_players[i+1]] for i in range(0, len(all_players), 2)]
gids = create_groups(session, pairs)Adds players to an existing group. This is useful when latecomers need to join a group that has already been created:
# Add a single player
add_to_group(session.groups[0], player)
# Add multiple players
add_to_group(session.groups[0], [player1, player2])
# Allow reassigning players already in a group
add_to_group(session.groups[0], player, overwrite=True)Players receive sequential member_id values starting after the existing members.
Match participants based on their responses:
class WaitAndMatch(SynchronizingWait):
synchronize = "session"
@classmethod
def all_here(page, session):
# Separate players by their preference
prefer_a = [p for p in session.players if p.preference == "A"]
prefer_b = [p for p in session.players if p.preference == "B"]
# Match players with different preferences
for p1, p2 in zip(prefer_a, prefer_b):
create_group(session, [p1, p2])When a config contains multiple apps that each use GroupCreatingWait, groups from a previous app are automatically cleared when participants reach a new GroupCreatingWait page. This means each app forms its own independent groups without manual cleanup.
Here is a complete two-player game showing group formation, decision collection, and payoff calculation:
DESCRIPTION = "Prisoner's dilemma"
SUGGESTED_MULTIPLE = 2
class C:
PAYOFF_MATRIX = {
# (you cooperate, other cooperates): your payoff
(True, True): 10,
(True, False): 0,
(False, True): 15,
(False, False): 3,
}
class Instructions(Page):
pass
class GroupPlease(GroupCreatingWait):
group_size = 2
class Dilemma(Page):
fields = dict(
cooperate=RadioField(
label="Do you wish to cooperate?",
choices=[(True, "Yes"), (False, "No")],
),
)
class Sync(SynchronizingWait):
@classmethod
def all_here(page, group: GroupType) -> None:
for player in group.players:
player.payoff = C.PAYOFF_MATRIX[
player.cooperate,
player.other_in_group.cooperate,
]
class Results(Page):
pass
page_order = [
Instructions,
GroupPlease,
Dilemma,
Sync,
Results,
]:material-github: See the full prisoners_dilemma example
| Purpose | |
|---|---|
GroupCreatingWait |
Wait page that forms groups automatically |
group_size |
Number of players per group |
after_grouping(page, group) |
Callback when group forms |
group.players |
All players in a group |
player.other_in_group |
The other player (2-person groups) |
player.others_in_group |
All other players in the group |
session.players |
All players in a session |
player.group |
Access group-level storage |
create_group() |
Programmatically create a group |
add_to_group() |
Add players to an existing group |