Skip to content

SmoothOperators

SmoothOperators let you define dynamic page sequences that adapt at runtime. Instead of a fixed list of pages, you can randomize order, repeat sections, or select between alternatives—all declaratively in your page_order.

RandomOrder

RandomOrder shuffles pages into a random order for each participant. This is essential for counterbalancing in within-subjects designs.

page_order = [
    Instructions,
    RandomOrder(
        TaskA,
        TaskB,
        TaskC,
    ),
    Results,
]

Each participant sees Instructions first, then the three tasks in a randomized order, then Results.

See the randomize_pages example

Keeping pages together with Bracket

Sometimes you want to randomize the order of groups of pages while keeping each group’s internal order intact. Use Bracket to treat multiple pages as a single unit:

page_order = [
    RandomOrder(
        Bracket(Intro1, Task1, Debrief1),  # These three stay together
        Bracket(Intro2, Task2, Debrief2),  # These three stay together
        Bracket(Intro3, Task3, Debrief3),  # These three stay together
    ),
]

Participants see all three blocks, but in random order. Within each block, pages appear in their defined sequence.

Use Bracket, not a list

SmoothOperators take only pages and other SmoothOperators. If you write a list such as RandomOrder([Intro1, Task1], [Intro2, Task2]), uproot stops with an error like this:

TypeError: Constraint violation: RandomOrder() takes pages and SmoothOperators, not [<class 'myapp.Intro1'>, <class 'myapp.Task1'>]. Use Bracket() to keep several pages together.

Write Bracket(Intro1, Task1) instead.

Randomizing entire apps

You can randomize the order of entire sub-experiments by unpacking their page_order into brackets:

from . import app1, app2, app3

page_order = [
    RandomOrder(
        Bracket(*app1.page_order),
        Bracket(*app2.page_order),
        Bracket(*app3.page_order),
    ),
]

See the randomize_apps example

Rounds

Rounds repeats a sequence of pages a fixed number of times, automatically tracking the current round.

page_order = [
    Instructions,
    Rounds(
        Decision,
        Feedback,
        n=5,  # Repeat 5 times
    ),
    FinalResults,
]

Participants see Instructions, then cycle through Decision → Feedback five times, then see FinalResults.

Accessing the current round

Inside your pages, access player.round to know which round you are in (1-indexed):

class Decision(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            round_number=player.round,
            rounds_remaining=5 - player.round,
        )
<p>Round {{ round_number }} of 5</p>

Several loops in one app

Most apps have a single Rounds (or Repeat), and you can skip this section. If one app takes participants through several loops—for example, practice rounds followed by paid rounds—give each loop a name with block=:

page_order = [
    Instructions,
    Rounds(Decision, Feedback, n=2, block="practice"),
    Rounds(Decision, Feedback, n=10, block="main"),
    FinalResults,
]

Each block counts player.round from 1, and uproot stores the name of the current block in player.block. If an app has several loops and one of them has no block, uproot run stops right away with an error. See Blocks below for the details.

Dynamic round counts

The number of rounds can come from a constant:

class C:
    NUM_ROUNDS = 10

page_order = [
    Rounds(Trial, n=C.NUM_ROUNDS),
]

Or be calculated dynamically:

page_order = [
    Rounds(Response, n=len(C.ITEMS)),  # One round per item
]

n must be a whole number (an int) of 0 or more. A calculation that results in a decimal number, such as n=len(C.ITEMS) / 2, stops uproot with ValueError: Constraint violation: n must be a non-negative integer. Use // to divide without a remainder (n=len(C.ITEMS) // 2), or wrap the result in int(...).

See the rounds example

Rounds with groups

Rounds works seamlessly with multiplayer experiments. Combine it with SynchronizingWait to create repeated group interactions:

page_order = [
    GroupPlease,
    Rounds(
        Dilemma,
        Sync,
        Results,
        n=C.ROUNDS,
    ),
]

See the prisoners_dilemma_repeated example

Repeat

Repeat repeats a sequence of pages indefinitely until you tell it to stop. Control the repetition by setting player.add_round to True (continue) or False (stop).

page_order = [
    Repeat(
        Trial,
        Feedback,
    ),
    Results,
]

Controlling repetition with player.add_round

Set player.add_round in your page logic to control whether the sequence repeats:

class Feedback(Page):
    @classmethod
    def after_once(page, player):
        if player.score >= 100:
            player.add_round = False  # Stop repeating, proceed to Results
        else:
            player.add_round = True   # Continue to next repetition

Like Rounds, Repeat automatically tracks player.round so you can display progress or use it in your logic. It also accepts the same optional block= argument (see Blocks).

Between

Between randomly selects exactly one option from multiple alternatives. This is perfect for between-subjects designs where each participant sees only one condition.

page_order = [
    Instructions,
    Between(
        ConditionA,
        ConditionB,
        ConditionC,
    ),
    Results,
]

Each participant sees exactly one of the three condition pages.

Selecting between page groups

Use Bracket to select between multi-page sequences:

page_order = [
    Between(
        Bracket(TreatmentIntro, TreatmentTask, TreatmentDebrief),
        Bracket(ControlIntro, ControlTask, ControlDebrief),
    ),
]

Each participant experiences either the full treatment sequence or the full control sequence.

Tracking which condition was shown

uproot records each selection in player.between_showed, which you can use in your pages and in your analysis. It is a list with one entry for each Between the participant went through. Each entry lists the pages of the selected option as "app/PageName". In an app called myapp, a participant who gets the treatment sequence from the example above has:

player.between_showed == [
    ["myapp/TreatmentIntro", "myapp/TreatmentTask", "myapp/TreatmentDebrief"],
]

To use the condition on a later page, look at the last entry:

class Results(Page):
    @classmethod
    def templatevars(page, player):
        treated = "myapp/TreatmentTask" in player.between_showed[-1]

        return dict(treated=treated)

Two details matter when you nest operators:

  • If the selected option contains another Between, that inner Between adds its own entry, and the outer entry leaves out the inner one’s pages. With Between(Between(A, B), C), a participant who sees B has [[], ["myapp/B"]].
  • An option without pages, such as Rounds(A, n=0), gets an empty entry ([]), so that every Between a participant went through has an entry.

Combining operators

SmoothOperators can be nested to create sophisticated experimental designs.

Randomized treatments with random task order

page_order = [
    Instructions,
    Between(
        Bracket(TreatmentIntro, Treatment),  # Treatment condition
        Bracket(ControlIntro, Control),      # Control condition
    ),
    RandomOrder(
        Task1,
        Task2,
        Task3,
    ),
    Results,
]

Repeated rounds with randomized elements

page_order = [
    Rounds(
        RandomOrder(
            StimulusA,
            StimulusB,
        ),
        Response,
        n=10,
    ),
]

Nested operators

You can nest operators to create more complex designs:

page_order = [
    Instructions,
    RandomOrder(
        Between(VersionA, VersionB),  # Randomly select one version
        Bracket(Control1, Control2),  # Keep these two together
        StandaloneTask,
    ),
    Results,
]

Here, participants see three things in random order: one of two versions (selected via Between), a two-page control sequence (kept together via Bracket), and a standalone task.

Blocks

Blocks are optional. You only need them when one app takes participants through more than one Rounds or Repeat.

The problem they solve: if an app runs two loops and both counted rounds the same way, your data would contain two different “round 1”s, and you could not tell which answer belongs to which. A block gives each loop a name. uproot stores that name in player.block, so the app, the block, and the round together identify every round.

Naming blocks

Pass block= to Rounds or Repeat:

page_order = [
    Rounds(Decision, n=2, block="practice"),
    Rounds(Decision, n=10, block="main"),
]

Participants see Decision twice with player.block == "practice" and player.round going 1, 2, then ten more times with player.block == "main" and player.round going from 1 to 10.

Block names must be valid Python identifiers: letters, digits, and underscores, not starting with a digit (e.g., "main" or "part_2", but not "part 2"). A loop without block= has player.block == None.

Returning to a block

Loops with the same block name share one counter. A participant who returns to a block continues where they left off:

page_order = [
    Rounds(Step, n=2, block="red"),   # red rounds 1, 2
    Rounds(Step, n=1, block="blue"),  # blue round 1
    Repeat(Step, block="red"),        # red rounds 3, 4, …
]

Counters start afresh in every app, so different apps can reuse the same block names.

See the blocks example

When uproot asks for blocks

If participants can go through several Rounds or Repeat in one app and one of them has no block, uproot run stops before the server starts and prints an error like this:

ValueError: Constraint violation: App 'myapp' can take participants through several Rounds or Repeat, so each of them needs a block, e.g., block="main". Rounds(Decision) has none. Blocks with the same name continue counting player.round, other blocks start at 1.

Page orders defined as functions

If your page_order is a function instead of a list, uproot cannot check it at startup. The server starts normally, and the check runs only when a participant’s page order is built, right after they join. That participant then sees an “Internal Server Error” page, and the error above appears in the server’s terminal output. Test such apps with a participant before you run a session.

The fix is to add block= to every outermost Rounds and Repeat in that app. A few cases do not count as several loops:

  • Loops inside Between count once, since each participant sees only one alternative. Between(Rounds(A, n=3), Rounds(B, n=5)) needs no blocks.
  • Rounds with n=0 never runs and is ignored.

RandomOrder(Rounds(A, n=3), Rounds(B, n=3)), on the other hand, runs both loops and therefore needs a block for each.

Nested loops

A Rounds or Repeat placed inside another one belongs to the outer loop’s block and cannot name its own. player.round keeps counting through every iteration at every level, and player.round_nested lists the position at each level—[2, 1] means “outer round 2, inner round 1.”

Leaving an app

When a participant moves on to the next app or reaches the end, uproot removes player.round, player.block, and player.round_nested. Reading player.round in a later app that has no loop of its own therefore raises an AttributeError. To look back at earlier rounds, use player.within(), for example player.within(app="game", block="main", round=3).

Summary

Operator Purpose Key parameter
RandomOrder Shuffle pages randomly Pages as arguments
Bracket Group pages as a unit Pages as arguments
Rounds Repeat pages n times n for count; optional block
Repeat Repeat until stopped player.add_round to control; optional block
Between Select one alternative Pages/brackets as arguments