19 Controlling the Flow
- Understand how jsPsych evaluates flow control at different levels (trial, timeline, experiment)
- Use conditional functions to show/hide individual trial components (e.g., feedback)
- Implement conditional timelines to include/exclude entire phases based on participant data
- Create looping timelines that repeat until performance criteria are met
- Combine conditional and looping logic to build adaptive experimental designs
19.1 Introduction
Throughout this book, we’ve been building experiments by arranging trials and timelines in a specific order. For instance, in a Stroop task, we might show a fixation cross, then the stimulus, then move on to the next trial:
let stroop = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p style="font-size:48px">+</p>`,
choices: "NO_KEYS",
trial_duration: 500
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ["r", "b", "g", "y"]
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: blue">RED</p>`},
{stimulus: `<p style="font-size:48px; color: red">RED</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLUE</p>`},
{stimulus: `<p style="font-size:48px; color: red">BLUE</p>`}
]
};Similarly, we control the order of entire phases or tasks by arranging them in the array we pass to jsPsych.run():
This works great for experiments where the sequence is always the same. But what if you need the flow to change based on what happens during the experiment?
Consider these scenarios:
- Show feedback only on incorrect trials
- Repeat a practice block until accuracy reaches 80%
- Skip the French language task if the participant indicated English as their first language
- Show a warning message after three consecutive missed responses
All of these require dynamic flow control, the ability to make decisions about what happens next based on data collected during the experiment. That’s what we’ll learn in this chapter.
19.2 It’s Timelines All the Way Down
Before we dive into flow control, let’s clarify an important concept: in jsPsych, everything is a timeline.
When we talk casually about “trials” and “tasks” and “phases,” it’s easy to think of them as fundamentally different things. But to jsPsych, they’re all just timelines. A single trial is a timeline with one object. A task is a timeline with multiple trials. An entire experiment is a timeline containing other timelines.
This means we can nest timelines inside timelines, inside more timelines, as deep as we need:
let welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Welcome to the experiment!</p>`
};
let goodbye = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Thank you for participating!</p>`
};
let stroop = {
timeline: [/* stroop trials */]
};
let flanker = {
timeline: [/* flanker trials */]
};
let simon = {
timeline: [/* simon trials */]
};
// Group all the cognitive tasks together
let allTasks = {
timeline: [
stroop,
flanker,
simon
]
};
// The complete experiment
let everything = {
timeline: [
welcome,
allTasks,
goodbye
]
};
jsPsych.run([everything]);This code will run in the following order:
- welcome
- stroop
- flanker
- simon
- goodbye
In this case, it is equivalent to the following code:
Why does this matter? Because the flow control tools we’re about to learn work the same way at every level. Whether you’re controlling a single feedback trial or an entire task phase, you’ll use the same mechanisms. Understanding that it’s “timelines all the way down” helps make sense of how these tools work.
As we will see, by grouping some trials/tasks together, we will gain more control over whether those groups of trials/tasks are shown or not show, repeat, etc.
19.3 A Side-Note About Organization
Now that we understand the nested nature of timelines, let’s take a moment to talk about code organization. Understanding that “it’s timelines all the way down” opens up different ways to structure your code, and choosing the right approach can make your experiments much easier to read and modify.
19.3.1 Two Ways to Write the Same Thing
Consider our Stroop task example from earlier. We wrote it like this:
let stroop = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p style="font-size:48px">+</p>`,
choices: "NO_KEYS",
trial_duration: 500
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ["r", "b", "g", "y"]
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: blue">RED</p>`},
{stimulus: `<p style="font-size:48px; color: red">RED</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLUE</p>`},
{stimulus: `<p style="font-size:48px; color: red">BLUE</p>`}
]
};But we could have written it this way instead:
let fixation = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p style="font-size:48px">+</p>`,
choices: "NO_KEYS",
trial_duration: 500
};
let stroop_stimulus = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ["r", "b", "g", "y"]
};
let stroop = {
timeline: [fixation, stroop_stimulus],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: blue">RED</p>`},
{stimulus: `<p style="font-size:48px; color: red">RED</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLUE</p>`},
{stimulus: `<p style="font-size:48px; color: red">BLUE</p>`}
]
};These two approaches are completely equivalent. jsPsych doesn’t care whether you define trials inline (inside the timeline array) or separately (as named variables that you reference in the timeline array).
We can even move our timeline_variables off into their own variable and refer to it inside our stroop trial:
let fixation = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p style="font-size:48px">+</p>`,
choices: "NO_KEYS",
trial_duration: 500
};
let stroop_stimulus = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ["r", "b", "g", "y"]
};
let stroop_timeline_variables = [
{stimulus: `<p style="font-size:48px; color: blue">RED</p>`},
{stimulus: `<p style="font-size:48px; color: red">RED</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLUE</p>`},
{stimulus: `<p style="font-size:48px; color: red">BLUE</p>`}
]
let stroop = {
timeline: [fixation, stroop_stimulus],
timeline_variables: stroop_timeline_variables
};19.3.2 When to Use Each Approach
Use inline definitions (first approach) when:
- The trial is simple and short
- You want to see the entire task structure at a glance
- The task only has a few components
Use separate definitions (second approach) when:
- Trial definitions are getting long or complex
- You want to clearly see the flow and structure of your task
- You’re building tasks with many components
- You want to focus on one piece at a time while coding
19.3.3 Example: Simple Task - Inline Works Fine
For a simple task with just a couple of short trials, inline definitions are perfectly readable:
let simple_task = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Press any key to see a word</p>`
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('word'),
choices: ['f', 'j']
}
],
timeline_variables: [
{word: 'HAPPY'},
{word: 'SAD'}
]
};This is easy to read because everything is short and straightforward.
19.3.4 Example: Complex Task - Separate Definitions Help
But imagine your trials start getting more complex - maybe they have custom styling, data properties, callback functions, and dynamic parameters. The inline approach becomes harder to read:
let complex_task = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 24px; color: #333; background-color: #f0f0f0; padding: 20px; border-radius: 10px;">
<p>Get ready...</p>
</div>`,
choices: "NO_KEYS",
trial_duration: 1000,
post_trial_gap: 200
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p style="font-size:48px">+</p>`,
choices: "NO_KEYS",
trial_duration: 500,
post_trial_gap: 0
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let stim = jsPsych.timelineVariable('stimulus');
let color = jsPsych.timelineVariable('color');
return `<div style="font-size: 48px; color: ${color}; font-weight: bold;">
${stim}
</div>`;
},
choices: ['r', 'b', 'g', 'y'],
data: {
task: 'stroop',
condition: jsPsych.timelineVariable('condition'),
correct_response: jsPsych.timelineVariable('correct_key')
},
on_finish: function(data) {
data.correct = data.response == data.correct_response;
},
post_trial_gap: 250
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
if (last_trial.correct) {
return `<p style="color: green; font-size: 24px;">Correct!</p>`;
} else {
return `<p style="color: red; font-size: 24px;">Incorrect</p>`;
}
},
choices: "NO_KEYS",
trial_duration: 800
}
],
timeline_variables: [
{stimulus: 'RED', color: 'red', condition: 'congruent', correct_key: 'r'},
{stimulus: 'RED', color: 'blue', condition: 'incongruent', correct_key: 'r'},
// ... more stimuli
]
};That’s a lot to take in! You have to scroll through all the trial details to understand the basic structure. It’s hard to see at a glance that this task has four parts: a warning, a fixation, a stimulus, and feedback.
Now compare this to the separate approach:
// ============================================
// STROOP COMPONENTS
// ============================================
// Warning screen before each trial
let warning = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 24px; color: #333; background-color: #f0f0f0; padding: 20px; border-radius: 10px;">
<p>Get ready...</p>
</div>`,
choices: "NO_KEYS",
trial_duration: 1000,
post_trial_gap: 200
};
// Fixation cross
let fixation = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p style="font-size:48px">+</p>`,
choices: "NO_KEYS",
trial_duration: 500,
post_trial_gap: 0
};
// Main Stroop stimulus
let stroop_stimulus = {
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let stim = jsPsych.timelineVariable('stimulus');
let color = jsPsych.timelineVariable('color');
return `<div style="font-size: 48px; color: ${color}; font-weight: bold;">
${stim}
</div>`;
},
choices: ['r', 'b', 'g', 'y'],
data: {
task: 'stroop',
condition: jsPsych.timelineVariable('condition'),
correct_response: jsPsych.timelineVariable('correct_key')
},
on_finish: function(data) {
data.correct = data.response == data.correct_response;
},
post_trial_gap: 250
};
// Feedback display
let feedback = {
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
if (last_trial.correct) {
return `<p style="color: green; font-size: 24px;">Correct!</p>`;
} else {
return `<p style="color: red; font-size: 24px;">Incorrect</p>`;
}
},
choices: "NO_KEYS",
trial_duration: 800
};
// ============================================
// STROOP TIMELINE
// ============================================
let stroop = {
timeline: [warning, fixation, stroop_stimulus, feedback],
timeline_variables: [
{stimulus: 'RED', color: 'red', condition: 'congruent', correct_key: 'r'},
{stimulus: 'RED', color: 'blue', condition: 'incongruent', correct_key: 'r'},
// ... more stimuli
]
};Now the structure is immediately clear: timeline: [warning, fixation, stroop_stimulus, feedback]. You can see the flow of the task at a glance. If you need to understand the details of any component, you can look at its definition separately. If you need to modify the feedback, you know exactly where to find it.
19.3.5 Example: Understanding Task Flow
The separate approach is especially helpful when you’re trying to understand or explain the sequence of events in your experiment:
// It's immediately clear what happens in each block
let practice_block = {
timeline: [instructions, practice_trial, feedback]
};
let main_block = {
timeline: [fixation, stimulus, response_prompt]
};
let experiment = {
timeline: [
welcome,
consent,
practice_block,
break_screen,
main_block,
demographics,
debrief
]
};Compare this to having all those components defined inline - you’d have to scroll through hundreds of lines of code to see the overall structure.
19.3.6 A Practical Guideline
Here’s a simple rule of thumb: If a trial definition is longer than about 10 lines, or if your timeline has more than 2-3 components, consider using separate definitions.
Your code should be organized in a way that makes it easy to understand the flow and structure of your experiment. When someone (including future you!) looks at your code, they should be able to quickly answer:
- What are the main phases of this experiment?
- What happens in each trial?
- What’s the sequence of events?
The separate approach often makes these questions easier to answer, especially as your experiments grow more complex.
However, your code should be organized in a way that makes it easy for you (and others) to understand and modify. There’s no single “right” way. You should choose the approach that makes the most sense for your specific experiment.
As we move forward with flow control, you’ll see that the separate approach often makes it easier to add conditional and loop functions, because you can clearly see which component you’re adding the function to. But both approaches work, and you can mix them in the same experiment if that makes sense for your design.
19.4 Two Levels of Flow Control
jsPsych provides two main mechanisms for controlling flow dynamically:
- Conditional Functions (conditional_function): Control whether individual trials or entire timelines run
- Loop Functions (loop_function): Control whether timelines repeat
These mechanisms can operate at different scales:
- Trial-level control: Show/hide feedback, adjust what appears on screen, change available responses
- Timeline-level control: Include/exclude practice blocks, repeat phases, skip entire tasks
Let’s start small with trial-level control, then scale up to timeline-level control.
19.5 Conditional Trial Components
The simplest form of flow control is showing or hiding parts of a single trial. One common use case is conditional feedback, where we show feedback only when participants make errors. When participants are performing well, we may not want to interrupt them every trial with a feedback message, but we still want to provide corrective feedback if they’re making errors.
Notice that this requires some if-then logic: “If they were incorrect on the previous trial, then show feedback. If they were correct on the previous trial then skip the feedback. Another way to say this, is that whether the feedback is shown is conditional on on something being true or false.
A conditional_function is a property you can add to any timeline object. It’s a function that returns either true (run this timeline) or false (skip this timeline). jsPsych calls this function right before it would run the timeline, giving you a chance to decide whether it should actually run.
Here’s the basic structure:
let some_timeline = {
timeline: [/* your trials here */],
conditional_function: function() {
// Your logic here
// Return true to show this timeline
// Return false to skip this timeline
}
};A common mistake is trying to put a conditional_function directly on a trial object. This won’t work:
// This DOESN'T work
let feedback = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Incorrect!</p>`,
conditional_function: function() {
// This will be ignored!
return false;
}
};Instead, you need to wrap the trial in a timeline, then put the conditional_function on the timeline:
// This DOES work
let feedback = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Incorrect!</p>`
}
],
conditional_function: function() {
// This will work!
return false;
}
};Remember: conditional functions control timelines, not individual trials. Even if your timeline only contains one trial, you still need that timeline wrapper.
19.5.1 Example: Feedback Only on Errors
Let’s build a complete example where feedback appears only when participants make mistakes. We’ll break it down step by step.
19.5.1.1 Step 1: Create the main trial
First, we need a trial that records whether the response was correct:
let trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ['w', 'n'],
data: {
correct_response: jsPsych.timelineVariable('correct_key'),
task: 'Lexical Decision Task'
},
on_finish: function(data) {
// Check if the response matches the correct response
data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response);
}
};Notice that we’re using on_finish to add a correct property to the data. This will be either true or false, and we’ll use it in our conditional function.
19.5.1.2 Step 2: Create the feedback trial
Now let’s create a feedback trial that will only appear sometimes. Remember, we need to wrap it in a timeline to use conditional_function:
let feedback = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
// Get the data from the trial that just finished
let last_trial = jsPsych.data.get().last(1).values()[0];
// Create feedback message
return `<p style="color: red; font-size: 24px;">Incorrect!</p>
<p>You pressed: ${last_trial.response}</p>
<p>Correct response: ${last_trial.correct_response}</p>`;
},
choices: "NO_KEYS",
trial_duration: 1500
}
],
conditional_function: function() {
// Get the data from the trial that just finished
let last_trial = jsPsych.data.get().last(1).values()[0];
// Only show feedback if they got it wrong
if (last_trial.correct === true) {
return false; // Don't show feedback
} else {
return true; // Show feedback
}
}
};Let’s break down what’s happening:
- The feedback is wrapped in a timeline array (even though it’s just one trial)
- The conditional_function is at the timeline level, not inside the trial
- Inside the conditional function:
jsPsych.data.get()- Gets all the data collected so far.last(1)- Gets just the most recent trial.values()[0]- Extracts the data object from that trial- We check if
last_trial.correct === true - Return
falseto skip the feedback,trueto show it
19.5.1.3 Step 3: Combine them into a timeline
let trial_with_feedback = {
timeline: [trial, feedback],
timeline_variables: [
{stimulus: "HOUSE", correct_key: "w"},
{stimulus: "BLARB", correct_key: "n"}
]
};Now when you run this timeline, every trial will show the stimulus and wait for a response. The feedback trial will only appear if the participant was incorrect. If they were correct, jsPsych skips the feedback trial entirely and moves to the next trial
We can see it in action right here:
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
// ============================================
// Instructions
// ============================================
const welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `Welcome to the Experiment! Press any key to begin.`,
choices: "ALL_KEYS",
post_trial_gap: 250
}
// ============================================
// Lexical Decision Task
// ============================================
// Stimulus
let trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
choices: ["w", "n"],
data: {
correct_response: jsPsych.timelineVariable("correct_key"),
task: "Lexical Decision Task"
},
on_finish: function(data) {
// Check if the response matches the correct response
data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response);
}
};
// Conditional Feedback
let feedback = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
// Get the data from the trial that just finished
let last_trial = jsPsych.data.get().last(1).values()[0];
// Create feedback message
return `<p style="color: red; font-size: 24px;">Incorrect!</p>
<p>You pressed: ${last_trial.response}</p>
<p>Correct response: ${last_trial.correct_response}</p>`;
},
choices: "NO_KEYS",
trial_duration: 1500
}
],
conditional_function: function() {
// Get the data from the trial that just finished
let last_trial = jsPsych.data.get().last(1).values()[0];
// Only show feedback if they got it wrong
if (last_trial.correct === true) {
return false; // Dont show feedback
} else {
return true; // Show feedback
}
}
};
// Lexical Decision Timeline
let trial_with_feedback = {
timeline: [trial, feedback],
timeline_variables: [
{stimulus: "HOUSE", correct_key: "w"},
{stimulus: "BLARB", correct_key: "n"}
]
};
// ============================================
// Experiment Timeline
// ============================================
jsPsych.run([
welcome,
trial_with_feedback
]); 19.5.1.4 Simplifying the Conditional Logic
You might notice that our conditional function is a bit verbose. There are always multiple ways to write the same code. In this case, we could write it in at least three different ways:
conditional_function: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
// These three versions do the same thing:
// Version 1: Explicit if/else
if (last_trial.correct === true) {
return false;
} else {
return true;
}
// Version 2: Return the opposite of correct
return !last_trial.correct; // ! means "not"
// Version 3: Check if correct is false
return last_trial.correct === false;
}All three versions work identically. Use whichever makes the most sense to you. The ! operator (called “not”) flips true to false and false to true, which is handy for these situations.
As a beginner, I’d stick with the code that is the clearest to you, even if that means it’s more verbose.
19.5.2 Example: Skip Based on Survey Response
Let’s look at another simple example. Imagine you want to ask participants if they wear glasses, and only show a follow-up question if they answer “Yes”. I won’t walk through the steps of creating this example, but the general principles are the same as the previous.
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<script src="jspsych/plugin-survey-text.js"></script>
<script src="jspsych/plugin-survey-multi-choice.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
// ============================================
// Instructions
// ============================================
const welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `Welcome to the Experiment! Press any key to begin.`,
choices: "ALL_KEYS",
post_trial_gap: 250
}
const debrief = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `Thank you for completing our survey!`,
choices: "NO_KEYS",
post_trial_gap: 250
}
// ============================================
// Vision Questionnaire
// ============================================
let glasses_question = {
type: jsPsychSurveyMultiChoice,
questions: [
{
prompt: "Do you wear glasses or contact lenses?",
options: ["Yes", "No"],
required: true,
name: "wears_glasses"
}
]
};
let glasses_followup = {
timeline: [
{
type: jsPsychSurveyText,
questions: [
{
prompt: "How many years have you been wearing glasses or contacts?",
name: "years_wearing_glasses",
required: true
}
]
}
],
conditional_function: function() {
// Get the response from the previous question
let last_response = jsPsych.data.get().last(1).values()[0];
// Only show this follow-up if they answered "Yes"
if (last_response.response.wears_glasses === "Yes") {
return true; // Show the follow-up
} else {
return false; // Skip the follow-up
}
}
};
// Run both in sequence
let vision_questions = {
timeline: [glasses_question, glasses_followup]
};
// ============================================
// Experiment Timeline
// ============================================
jsPsych.run([
welcome,
vision_questions,
debrief
]); Let’s break this down:
- First question: We ask if they wear glasses, storing the response with the name ‘wears_glasses’
- Follow-up question: We wrap it in a timeline so we can add a conditional_function
- Conditional logic:
- We get the last trial’s data (the glasses question)
- We check if their response to wears_glasses was “Yes”
- If yes, return true to show the follow-up
- If no, return false to skip it
This is a common pattern in surveys where you only want to ask certain questions based on previous answers. Participants who answered “No” will never see the follow-up question, and the experiment will simply continue to whatever comes next.
19.5.3 Example: Conditional Inattention Warning
Here’s a more complicated, but practical example for online studies. The Sustained Attention to Response Task (SART) is designed to measure attention lapses. Participants see a series of digits (1-9) presented one at a time, and their job is to press the spacebar for every digit except the number 3. When they see a 3, they should withhold their response.
The key feature of the SART is that trials don’t wait for a response. Each digit appears for a fixed duration (typically around 1 second) and then the next digit appears automatically. This means participants can miss trials, which could indicate an attention lapse.
However, in online studies, we can’t monitor participants directly. We don’t know if they’re texting on their phone, if they’ve opened another browser tab, or if they’ve simply walked away from the computer. While missing one or two trials in a row might reflect normal attention lapses (which is what we’re trying to measure), missing many trials in a row suggests the participant isn’t doing the task at all.
One solution is to pause the task and show a warning if participants miss more trials than we’d expect from normal attention lapses. This helps ensure they’re actually engaged with the task:
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
// ============================================
// Instructions
// ============================================
const welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `Welcome to the Experiment! Press any key to begin.`,
choices: "ALL_KEYS",
post_trial_gap: 250
}
// ============================================
// Sustained Attention to Response Task
// ============================================
// Variable to track consecutive misses
let missed_in_a_row = 0;
// sart trial
let sart_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("digit"),
choices: [" "], // Spacebar
trial_duration: 1000, // Trial proceeds automatically after 1 second
response_ends_trial: false, // response does NOT end the trial
data: {
digit: jsPsych.timelineVariable("digit"),
is_target: jsPsych.timelineVariable("is_target")
},
on_finish: function(data) {
// Correct response: press space for non-targets (1-2, 4-9), withhold for target (3)
if (data.is_target) {
data.correct = data.response === null; // Should NOT press for target
// If it was a target, reset counter
missed_in_a_row = 0;
} else {
data.correct = data.response === " "; // Should press for non-target
// Track consecutive misses (only track on non-target trials)
if (data.response === null) {
missed_in_a_row++;
} else {
missed_in_a_row = 0; // Reset if they responded
}
}
}
};
// inattention warning
let inattention_warning = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="color: orange; font-size: 24px;">
<p>⚠️ Attention Check ⚠️</p>
<p>You've missed ${missed_in_a_row} trials in a row.</p>
<p>Please stay focused and respond to the digits.</p>
<p>Press any key to continue.</p>
</div>`,
choices: "ALL_KEYS"
}
],
conditional_function: function() {
// Show warning if they've missed 3 or more in a row
if (missed_in_a_row >= 3) {
return true;
} else {
return false;
}
},
on_finish: function() {
missed_in_a_row = 0; // Reset counter after showing warning
}
};
// sart task timeline
let sart_task = {
timeline: [sart_trial, inattention_warning],
timeline_variables: [
{digit: `<p style="font-size: 72px;">1</p>`, is_target: false},
{digit: `<p style="font-size: 72px;">2</p>`, is_target: false},
{digit: `<p style="font-size: 72px;">3</p>`, is_target: true},
{digit: `<p style="font-size: 72px;">4</p>`, is_target: false},
{digit: `<p style="font-size: 72px;">5</p>`, is_target: false},
{digit: `<p style="font-size: 72px;">6</p>`, is_target: false},
{digit: `<p style="font-size: 72px;">7</p>`, is_target: false},
{digit: `<p style="font-size: 72px;">8</p>`, is_target: false},
{digit: `<p style="font-size: 72px;">9</p>`, is_target: false}
],
randomize_order: true,
repetitions: 10 // 90 trials total
};
// ============================================
// Experiment Timeline
// ============================================
jsPsych.run([
welcome,
sart_task
]); Let’s break down how this works:
- Tracking misses: The missed_in_a_row variable keeps count of consecutive trials where the participant didn’t respond
- Trial proceeds automatically: With trial_duration: 1000, the trial moves on after 1 second whether they respond or not
- Updating the counter: In on_finish:
- If data.response === null (no response), we increment the counter
- If they responded, we reset the counter to 0
- Conditional warning: The warning timeline only appears when missed_in_a_row >= 3
- Resetting after warning: After showing the warning, we reset the counter so they get a fresh start
This pattern helps distinguish between normal attention lapses (which we want to measure) and complete task disengagement (which we want to prevent). The warning pauses the task and requires the participant to actively press a key to continue, ensuring they’re still present and engaged. If a participant continues to not respond even after multiple warnings, you might want to consider ending the experiment early or flagging their data for exclusion during analysis.
19.6 Conditional Timelines
Everything we just learned about conditional functions works exactly the same way for entire timelines. Instead of showing or hiding a single feedback trial, we can show or hide entire phases of the experiment.
19.6.1 Understanding Timeline-Level Conditionals
When you put a conditional_function on a timeline that contains multiple trials, jsPsych evaluates the condition once, before running any of the trials in that timeline. If the function returns false, the entire timeline is skipped.
This is perfect for scenarios like showing different versions of a task based on participant characteristics, skipping optional sections based on previous performance, or branching to different experimental paths.
The general principles from the previous section apply here in exactly the same way. The only difference is that we’ll control larger timelines that contain entire tasks, rather than single parts of one task.
19.6.2 Example: Language-Specific Tasks
Let’s build an experiment that shows different Stroop tasks based on the participant’s first language. For this first example, we’ll walk through the steps of creating it:
19.6.2.1 Step 1: Ask about language
// ============================================
// Language Check
// ============================================
let language_check = {
type: jsPsychSurveyMultiChoice,
questions: [
{
prompt: "What is your first language?",
options: ["English", "French"],
required: true,
name: 'first_language'
}
],
data: {
phase: "Language Check"
}
};19.6.2.2 Step 2: Create language-specific tasks
Now, we’ll create our language-specific tasks with conditional functions. In this case, we’re checking to see what their response was in the language check phase.
Since conditional functions only allow us to either show or skip a particular timeline, we’ll need a conditional function on each version of the task. The logic works like this: if the response was “English”, we show the English Stroop and skip the French Stroop. But if the response was “French”, we skip the English version and show the French version.
This means each task needs its own conditional function that checks for a different language:
// ============================================
// French Stroop
// ============================================
let french_stroop = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ['r', 'b', 'v', 'j']
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: red">ROUGE</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLEU</p>`},
{stimulus: `<p style="font-size:48px; color: green">VERT</p>`},
{stimulus: `<p style="font-size:48px; color: yellow">JAUNE</p>`}
],
conditional_function: function() {
// Get the response from the language check
let language_data = jsPsych.data.get().filter({phase: 'Language Check'}).values()[0];
let selected_language = language_data.response.first_language;
// Only run this timeline if they selected French
return selected_language === "French";
},
data: {
phase: "French Stroop"
}
};
// ============================================
// English Stroop
// ============================================
let english_stroop = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ['r', 'b', 'g', 'y']
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: red">RED</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLUE</p>`},
{stimulus: `<p style="font-size:48px; color: green">GREEN</p>`},
{stimulus: `<p style="font-size:48px; color: yellow">YELLOW</p>`}
],
conditional_function: function() {
let language_data = jsPsych.data.get().filter({phase: 'Language Check'}).values()[0];
let selected_language = language_data.response.first_language;
return selected_language === "English";
},
data: {
phase: "English Stroop"
}
};19.6.2.3 Step 3: Run the experiment
Finally, we put BOTH tasks into our jsPsych.run to the conditional functions can control whether they are displayed or not.
// ============================================
// Experiment Timeline
// ============================================
jsPsych.run([
welcome,
language_check,
french_stroop, // Only runs if French selected
english_stroop, // Only runs if English selected
debrief
]);Here’s the full working example. Try selecting a language, refreshing and selecting the other language.
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<script src="jspsych/plugin-survey-multi-choice.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
// ============================================
// Instructions
// ============================================
const welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `Welcome to the Experiment! Press any key to begin.`,
choices: "ALL_KEYS",
post_trial_gap: 250
}
const debrief = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `Thank you for completing our experiment!`,
choices: "NO_KEYS",
post_trial_gap: 250
}
// ============================================
// Language Check
// ============================================
let language_check = {
type: jsPsychSurveyMultiChoice,
questions: [
{
prompt: "What is your first language?",
options: ["English", "French"],
required: true,
name: "first_language"
}
],
data: {
phase: "Language Check"
}
};
// ============================================
// French Stroop
// ============================================
let french_stroop = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
choices: ["r", "b", "v", "j"]
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: red">ROUGE</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLEU</p>`},
{stimulus: `<p style="font-size:48px; color: green">VERT</p>`},
{stimulus: `<p style="font-size:48px; color: yellow">JAUNE</p>`}
],
conditional_function: function() {
// Get the response from the language check
let language_data = jsPsych.data.get().filter({phase: "Language Check"}).values()[0];
let selected_language = language_data.response.first_language;
// Only run this timeline if they selected French
return selected_language === "French";
},
data: {
phase: "French Stroop"
}
};
// ============================================
// English Stroop
// ============================================
let english_stroop = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
choices: ["r", "b", "g", "y"]
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: red">RED</p>`},
{stimulus: `<p style="font-size:48px; color: blue">BLUE</p>`},
{stimulus: `<p style="font-size:48px; color: green">GREEN</p>`},
{stimulus: `<p style="font-size:48px; color: yellow">YELLOW</p>`}
],
conditional_function: function() {
let language_data = jsPsych.data.get().filter({phase: "Language Check"}).values()[0];
let selected_language = language_data.response.first_language;
return selected_language === "English";
},
data: {
phase: "English Stroop"
}
};
// ============================================
// Experiment Timeline
// ============================================
jsPsych.run([
welcome,
language_check,
french_stroop, // Only runs if French selected
english_stroop, // Only runs if English selected
debrief
]); 19.6.3 Example: Language-Specific Tasks Expanded
In the previous example, we just swapped out the task based on first language. Of course, if someone speaks French as a first language, we’d probably want all of the instructions and debrief in French too!
Going back to the idea that it’s ‘timelines all the way down’, we can expand our logic to create more complex, nested timelines so that each task gets its own set of instructions and debrief in the appropriate language.
Let’s update the experiment code to do that:
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<script src="jspsych/plugin-survey-multi-choice.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
// ============================================
// Language Check
// ============================================
let language_check = {
type: jsPsychSurveyMultiChoice,
questions: [
{
prompt: "What is your first language? / Quelle est votre langue maternelle?",
options: ["English", "Français"],
required: true,
name: "first_language"
}
],
data: {
phase: "Language Check"
}
};
// ============================================
// French Version (Instructions + Task + Debrief)
// ============================================
let french_instructions = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 20px;">
<p>Bienvenue à l'expérience Stroop!</p>
<p>Vous allez voir des mots de couleur affichés à l'écran.</p>
<p>Appuyez sur la touche correspondant à la COULEUR du mot:</p>
<ul style="list-style-type: none;">
<li>R pour Rouge</li>
<li>B pour Bleu</li>
<li>V pour Vert</li>
<li>J pour Jaune</li>
</ul>
<p>Appuyez sur n'importe quelle touche pour commencer.</p>
</div>`,
choices: "ALL_KEYS"
};
let french_stroop_task = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
choices: ["r", "b", "v", "j"],
data: {
correct_response: jsPsych.timelineVariable("correct_key")
},
on_finish: function(data) {
data.correct = data.response === data.correct_response;
}
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: red">ROUGE</p>`, correct_key: "r"},
{stimulus: `<p style="font-size:48px; color: blue">BLEU</p>`, correct_key: "b"},
{stimulus: `<p style="font-size:48px; color: green">VERT</p>`, correct_key: "v"},
{stimulus: `<p style="font-size:48px; color: yellow">JAUNE</p>`, correct_key: "j"},
{stimulus: `<p style="font-size:48px; color: blue">ROUGE</p>`, correct_key: "b"},
{stimulus: `<p style="font-size:48px; color: red">BLEU</p>`, correct_key: "r"}
],
randomize_order: true
};
let french_debrief = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 20px;">
<p>Merci d'avoir participé à notre expérience!</p>
<p>Appuyez sur n'importe quelle touche pour terminer.</p>
</div>`,
choices: "NO_KEYS"
};
// Combine all French components into one timeline
let french_experiment = {
timeline: [french_instructions, french_stroop_task, french_debrief],
conditional_function: function() {
let language_data = jsPsych.data.get().filter({phase: "Language Check"}).values()[0];
let selected_language = language_data.response.first_language;
return selected_language === "Français";
}
};
// ============================================
// English Version (Instructions + Task + Debrief)
// ============================================
let english_instructions = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 20px;">
<p>Welcome to the Stroop experiment!</p>
<p>You will see color words displayed on the screen.</p>
<p>Press the key corresponding to the COLOR of the word:</p>
<ul style="list-style-type: none;">
<li>R for Red</li>
<li>B for Blue</li>
<li>G for Green</li>
<li>Y for Yellow</li>
</ul>
<p>Press any key to begin.</p>
</div>`,
choices: "ALL_KEYS"
};
let english_stroop_task = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
choices: ["r", "b", "g", "y"],
data: {
correct_response: jsPsych.timelineVariable("correct_key")
},
on_finish: function(data) {
data.correct = data.response === data.correct_response;
}
}
],
timeline_variables: [
{stimulus: `<p style="font-size:48px; color: red">RED</p>`, correct_key: "r"},
{stimulus: `<p style="font-size:48px; color: blue">BLUE</p>`, correct_key: "b"},
{stimulus: `<p style="font-size:48px; color: green">GREEN</p>`, correct_key: "g"},
{stimulus: `<p style="font-size:48px; color: yellow">YELLOW</p>`, correct_key: "y"},
{stimulus: `<p style="font-size:48px; color: blue">RED</p>`, correct_key: "b"},
{stimulus: `<p style="font-size:48px; color: red">BLUE</p>`, correct_key: "r"}
],
randomize_order: true
};
let english_debrief = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 20px;">
<p>Thank you for participating in our experiment!</p>
<p>Press any key to finish.</p>
</div>`,
choices: "NO_KEYS"
};
// Combine all English components into one timeline
let english_experiment = {
timeline: [english_instructions, english_stroop_task, english_debrief],
conditional_function: function() {
let language_data = jsPsych.data.get().filter({phase: "Language Check"}).values()[0];
let selected_language = language_data.response.first_language;
return selected_language === "English";
}
};
// ============================================
// Experiment Timeline
// ============================================
jsPsych.run([
language_check,
french_experiment, // Only runs if French selected
english_experiment // Only runs if English selected
]); Notice what we’ve done here:
- Created complete language versions: Each language now has its own instructions, task, and debrief
- Nested timelines: We created french_experiment and english_experiment, each containing multiple components
- Single conditional check: Instead of checking the language for each component separately, we check once at the experiment level
- Cleaner structure: The main experiment timeline is now very simple - just the language check followed by two conditional experiment versions
This demonstrates the power of nested timelines. The conditional_function on french_experiment controls whether the entire French version runs (instructions, task, and debrief). If it returns false, jsPsych skips all of those components and moves on to check the English version. The conditional_function on english_experiment controls whether the entire English version runs (instructions, task, and debrief). If it returns false, jsPsych skips all of those components and moves on. We could have added further languages, each with their own conditional function if we required more variants.
This pattern is much more maintainable than putting conditional functions on every single component. If you need to add a new section (like a practice block), you just add it to the appropriate language timeline, and it will automatically be included or excluded based on the participant’s language selection.
19.6.4 Example: Time-Restricted Experiment
Although up until now, we have been checking a previous response to determine whether something should be displayed, it’s important to remember that we can put whatever logic we want inside of the conditional function.
Here’s an example that will only display the experiment if the participant’s local time is between 5 PM and 10 PM:
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
let main_experiment = {
timeline: [
// Your experiment trials here
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let now = new Date();
let current_time = now.toLocaleTimeString();
return `<div style="font-size: 20px;">
<p>The current local time is ${current_time}</p>
<p>Welcome to the experiment!</p>
</div>`;
},
choices: "NO_KEYS"
}
],
conditional_function: function() {
// Get the current time
let now = new Date();
let current_hour = now.getHours(); // Returns 0-23
// Check if it's between 5 PM (17) and 10 PM (20)
if (current_hour >= 17 && current_hour < 20) {
return true; // Show the experiment
} else {
return false; // Skip the experiment
}
}
};
let time_restriction_message = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let now = new Date();
let current_time = now.toLocaleTimeString();
return `<div style="font-size: 20px;">
<p>The current local time is ${current_time}.</p>
<p>This experiment is only available between 5:00 PM and 10:00 PM.</p>
<p>Please return during these hours to participate.</p>
</div>`;
},
choices: "NO_KEYS"
}
],
conditional_function: function() {
let now = new Date();
let current_hour = now.getHours();
// Show this message if it's NOT between 5 PM and 10 PM
if (current_hour < 17 || current_hour >= 20) {
return true; // Show the restriction message
} else {
return false; // Skip the message
}
}
};
jsPsych.run([
main_experiment, // Only runs during allowed hours
time_restriction_message // Only runs outside allowed hours
]); 19.6.5 Example: Random Assignment to Conditions
Sometimes you want to randomly assign participants to different experimental conditions. We’ll dive deeper into randomization later, and discuss why this probably isn’t the best way to randomize your conditions.
However, as a demonstration of conditional functions, here’s an example that randomly shows one of two different instruction sets determine be randomly selecting a number between 0 and 1.
Click the ‘refresh’ button to see it randomly switch between instruction prompts.
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
// Generate random assignment once at the start
let condition = Math.random() <= 0.5 ? "standard" : "detailed";
let standard_instructions = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 20px;">
<p>In this task, respond as quickly as possible.</p>
<p>Press any key to begin.</p>
</div>`,
data: {
instruction_condition: "standard"
}
}
],
conditional_function: function() {
return condition === "standard";
}
};
let detailed_instructions = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div style="font-size: 20px;">
<p>In this task, respond as quickly as possible.</p>
<p>Speed is more important than accuracy.</p>
<p>Don't worry if you make mistakes.</p>
<p>Press any key to begin.</p>
</div>`,
data: {
instruction_condition: "detailed"
}
}
],
conditional_function: function() {
return condition === "detailed";
}
};
jsPsych.run([
standard_instructions, // Shows if condition === "standard"
detailed_instructions // Shows if condition === "detailed"
]);
In this example, we generate a random number between 0 and 1, and if it’s less than 0.5 than we store “standard” in the condition variable. If it is greater than 0.5, then we store “detailed” in the condition variable.
Later, inside the conditional function, we check what condition is to determine which should be displayed.
19.7 Looping Timelines
A loop_function is similar to a conditional_function, but it’s evaluated after a timeline completes, not before. It receives the data from all the trials that just ran, and returns either true (run the timeline again) or false (move on to the next thing).
Just like conditional_function, the loop_function must be placed on a timeline object, not directly on a trial object. The function should return true to repeat the timeline, or false to move on.
Here’s the basic structure for loop functions:
let some_timeline = {
timeline: [/* your trials here */],
loop_function: function() {
// Your logic here
// Return true to repeat this timeline
// Return false to move on
}
};Remember: loop functions control timelines, not individual trials. Even if your timeline only contains one trial, you still need that timeline wrapper.
19.7.1 Example: Audio Check
In online studies, we can’t verify that participants have their audio working. Here’s an audio check that loops until they correctly identify a spoken word:
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<script src="jspsych/plugin-audio-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
let welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Press any key to begin the sound check.</p>`,
choices: "ALL_KEYS"
}
let audio_check = {
timeline: [
{
type: jsPsychAudioKeyboardResponse,
stimulus: "media/APPLE.mp3",
prompt: `<p>Please listen to the word</p>`,
choices: "NO_KEYS",
trial_duration: 2000,
trial_ends_after_audio: false
},
{
type: jsPsychSurveyMultiChoice,
questions: [
{
prompt: "Which word did you hear?",
options: [
"Table", "Apple", "Chair", "Window", "Bottle",
"Paper", "Garden", "Candle", "Marble", "Tiger"
],
required: true,
name: "heard_word"
}
],
on_finish: function(data) {
data.correct = data.response.heard_word === "Apple";
}
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
if (last_trial.correct) {
return `<p style="color: green;">Correct! Your audio is working.</p>
<p>Press any key to continue.</p>`;
} else {
return `<p style="color: red;">That's not correct.</p>
<p>Please check your audio settings and try again.</p>
<p>Press any key to retry.</p>`;
}
}
}
],
loop_function: function() {
// Get the response from the multiple choice question
let last_response = jsPsych.data.get().last(2).values()[0];
// Loop if they got it wrong
if (last_response.correct) {
return false; // Correct, move on
} else {
return true; // Incorrect, loop back
}
}
};
jsPsych.run([
welcome,
audio_check
])
19.7.2 Example: Comprehension Check with Attention Trap
Here’s another example with an attention check to make sure participants read the instructions carefully. If they fail the check, they are prompted to carefully read through the instructions again.
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<script src="jspsych/plugin-survey-multi-choice.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
let comprehension_check = {
timeline: [
{
type: jsPsychSurveyMultiChoice,
questions: [
{
prompt: `<div style="text-align: left; max-width: 600px; margin: auto;">
<p><strong>Instructions:</strong></p>
<p>In this task, you will see a series of shapes on the screen.</p>
<p>Your job is to press the spacebar whenever you see a blue circle.</p>
<p>Do NOT press anything for red squares or green triangles.</p>
<p>Speed and accuracy are both important.</p>
<br>
<p><strong>Question: What should you do when you see a blue circle?</strong></p>
<p><em>Note: To demonstrate that you have read these instructions carefully,
please select "I did not read the instructions" below, regardless of the question above.</em></p>
</div>`,
options: [
"Press the spacebar",
"Do nothing",
"Press any key",
"I did not read the instructions"
],
required: true,
name: "comprehension"
}
],
on_finish: function(data) {
// Correct answer is the "trap" option
data.correct = data.response.comprehension === "I did not read the instructions";
}
},
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
if (last_trial.correct) {
return `<p style="color: green;">Excellent! You read the instructions carefully.</p>
<p>Press any key to begin the task.</p>`;
} else {
return `<p style="color: red;">Please read the instructions more carefully.</p>
<p>Pay attention to ALL of the text, including notes at the end.</p>
<p>Press any key to try again.</p>`;
}
}
}
],
loop_function: function() {
let last_response = jsPsych.data.get().last(2).values()[0];
return !last_response.correct; // Loop until they select the correct option
}
};
jsPsych.run([comprehension_check])
This type of check is particularly effective for catching participants who are just clicking through without reading. If they only skim the question, they’ll select “Press the spacebar” (which seems like the obvious answer). Only participants who read all the way to the end will know to select the counterintuitive option.
19.7.3 Example: Practice Until Proficient
We can expand the basic principles above to loop over more complex timelines. We can also introduce more complicated logic to determine whether the loop should continue or not.
Let’s create a practice block that repeats until the participant reaches 80% accuracy:
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
let welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Welcome to the Experiment!</p>
<p>Press any key to begin.</p>`,
choices: "ALL_KEYS",
post_trial_gap: 250
}
let practice_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
prompt: `<p>Categorize the words using "F" or "J". You must get 80% correct to move on.</p>`,
choices: ["f", "j"],
data: {
correct_response: jsPsych.timelineVariable("correct_key"),
phase: "practice",
trial_part: "stimulus"
},
on_finish: function(data) {
data.correct = data.response == data.correct_response;
}
};
let practice_feedback = {
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
if (last_trial.correct) {
return `<p style="color: green;">Correct!</p>`;
} else {
return `<p style="color: red;">Incorrect. The correct answer was: ${last_trial.correct_response}</p>`;
}
},
choices: "NO_KEYS",
trial_duration: 1000,
data: {
phase: "practice",
trial_part: "feedback"
}
};
let practice_block = {
timeline: [practice_trial, practice_feedback],
timeline_variables: [
{stimulus: `<p>CAT</p>`, correct_key: "f"},
{stimulus: `<p>TABLE</p>`, correct_key: "j"},
{stimulus: `<p>DOG</p>`, correct_key: "f"},
{stimulus: `<p>CHAIR</p>`, correct_key: "j"},
{stimulus: `<p>BIRD</p>`, correct_key: "f"},
{stimulus: `<p>DESK</p>`, correct_key: "j"}
],
randomize_order: true,
loop_function: function(data) {
// data only contains the previous timeline data. We just need to filter it
let trials = data.filter({trial_part: "stimulus"})
let correct_trials = trials.filter({correct: true});
let accuracy = correct_trials.count() / trials.count();
console.log(`Practice accuracy: ${accuracy}`);
// If accuracy is below 80%, repeat the block
if (accuracy < 0.80) {
return true; // Loop again
} else {
return false; // Move on
}
}
};
let practice_end = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Great job finishing the practice block!</p>`,
choices: "NO_KEYS"
}
jsPsych.run([
welcome,
practice_block,
practice_end
])
19.8 Avoiding Infinite Loops
One danger with loop functions is creating infinite loops. What if a participant just can’t reach the threshold? We don’t want participants infinitely looping through our experiment. For every looping function we add, we should be including a fallback to prevent an infinite loop.
For the previous example, let’s add a maximum number of attempts before we allow participants to move forward. Now they will complete the practice phase either by reaching our threshold or by hitting the maximum attempts. We could use this later to exclude participants who failed to reach our accuracy threshold, but more importantly, all participants will be able to complete the experiment, even if they don’t reach our threshold.
To accomplish this, we need to keep track of their attempts outside of the jsPsych trials. We can do that by creating variables outside the trial and updating them inside the trial.
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
const jsPsych = initJsPsych();
// variables for tracking practice attempts
let practice_attempts = 0;
const MAX_PRACTICE_ATTEMPTS = 3;
let welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Welcome to the Experiment!</p>
<p>Press any key to begin.</p>`,
choices: "ALL_KEYS",
post_trial_gap: 250
}
let practice_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
prompt: `<p>Categorize the words using "F" or "J". You must get 80% correct to move on.</p>`,
choices: ["f", "j"],
data: {
correct_response: jsPsych.timelineVariable("correct_key"),
phase: "practice",
trial_part: "stimulus"
},
on_finish: function(data) {
data.correct = data.response == data.correct_response;
}
};
let practice_feedback = {
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
if (last_trial.correct) {
return `<p style="color: green;">Correct!</p>`;
} else {
return `<p style="color: red;">Incorrect. The correct answer was: ${last_trial.correct_response}</p>`;
}
},
choices: "NO_KEYS",
trial_duration: 1000,
data: {
phase: "practice",
trial_part: "feedback"
}
};
let practice_block = {
timeline: [practice_trial, practice_feedback],
timeline_variables: [
{stimulus: `<p>CAT</p>`, correct_key: "f"},
{stimulus: `<p>TABLE</p>`, correct_key: "j"},
{stimulus: `<p>DOG</p>`, correct_key: "f"},
{stimulus: `<p>CHAIR</p>`, correct_key: "j"},
{stimulus: `<p>BIRD</p>`, correct_key: "f"},
{stimulus: `<p>DESK</p>`, correct_key: "j"}
],
randomize_order: true,
loop_function: function(data) {
practice_attempts++; // Increment attempt counter
// data only contains the previous timeline data. We just need to filter it
let trials = data.filter({trial_part: "stimulus"})
let correct_trials = trials.filter({correct: true});
let accuracy = correct_trials.count() / trials.count();
console.log(`Practice accuracy: ${accuracy}`);
// Stop if they reached the threshold OR hit max attempts
if (accuracy >= 0.80 || practice_attempts >= MAX_PRACTICE_ATTEMPTS) {
return false; // Stop looping
} else {
return true; // Keep practicing
}
}
};
let practice_end = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Great job finishing the practice block!</p>`,
choices: "NO_KEYS"
}
jsPsych.run([
welcome,
practice_block,
practice_end
])
19.9 Combining Conditional and Loop Logic
The real power comes from combining conditional functions and loop functions to create sophisticated experimental designs. Let’s build a complete example that uses both.
19.9.1 Example: Adaptive Practice with Warnings
Imagine we want to:
- Show warnings if participants miss too many trials in a row
- Repeat practice until they reach 80% accuracy
- Give up after 3 attempts
- Show different feedback depending on why practice ended
<!DOCTYPE html>
<html>
<head>
<title>Demo</title>
<!-- jsPsych -->
<script src="jspsych/jspsych.js"></script>
<link href="jspsych/jspsych.css" rel="stylesheet" type="text/css" />
<!-- jPsych plugins -->
<script src="jspsych/plugin-html-keyboard-response.js"></script>
<!-- custom CSS -->
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<!-- custom JS -->
<script src="exp.js"></script>
</body>
</html>
// ============================================
// Initiate jsPsych
// ============================================
const jsPsych = initJsPsych();
// ============================================
// Instructions
// ============================================
let welcome = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>Welcome to the Experiment!</p>
<p>Press any key to begin.</p>`,
choices: "ALL_KEYS",
post_trial_gap: 250
}
let end_practice = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>You are finished the experiment.</p>`,
choices: "NO_KEYS",
post_trial_gap: 250
}
// ============================================
// Practice Trials
// ============================================
// variables to track performance
let missed_in_a_row = 0;
let practice_attempts = 0;
const MAX_PRACTICE_ATTEMPTS = 3;
// practice trial
let practice_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: jsPsych.timelineVariable("stimulus"),
choices: ["f", "j"],
trial_duration: 1500,
data: {
correct_response: jsPsych.timelineVariable("correct_key"),
phase: "practice trial"
},
on_finish: function(data) {
data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response);
// Track missed trials
if (data.response === null) {
data.miss = true
missed_in_a_row++;
} else {
data.miss = false
missed_in_a_row = 0; // Reset if they responded
}
}
};
// warning display
let warning = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
return `<p style="color: orange; font-size: 24px;">
Please try to respond faster!
</p>
<p>You've missed 3 trials in a row.</p>
<p>Press any key to continue.</p>`;
}
}
],
conditional_function: function() {
return missed_in_a_row >= 3;
},
on_finish: function() {
missed_in_a_row = 0; // Reset after showing warning
},
data: {
phase: "practice warning"
}
};
// feedback display
let feedback = {
type: jsPsychHtmlKeyboardResponse,
stimulus: function() {
let last_trial = jsPsych.data.get().last(1).values()[0];
if (last_trial.response === null) {
return `<p style="color: orange;">Too slow!</p>`;
} else if (last_trial.correct) {
return `<p style="color: green;">Correct!</p>`;
} else {
return `<p style="color: red;">Incorrect</p>`;
}
},
choices: "NO_KEYS",
trial_duration: 800,
data: {
phase: "practice feedback"
}
};
// practice block
let practice_block = {
timeline: [practice_trial, feedback, warning],
timeline_variables: [
{stimulus: `<p>CAT</p>`, correct_key: "f"},
{stimulus: `<p>TABLE</p>`, correct_key: "j"},
{stimulus: `<p>DOG</p>`, correct_key: "f"},
{stimulus: `<p>CHAIR</p>`, correct_key: "j"},
{stimulus: `<p>BIRD</p>`, correct_key: "f"},
{stimulus: `<p>DESK</p>`, correct_key: "j"}
],
randomize_order: true,
loop_function: function(data) {
practice_attempts++;
let practice_trials = data.filter({phase: "practice trial"})
// Calculate accuracy (only counting trials where they responded)
let responded_trials = data.filter({miss: false});
let correct_trials = practice_trials.filter({correct: true}).count();
let accuracy = correct_trials / responded_trials.count();
console.log(`Attempt ${practice_attempts}: ${Math.round(accuracy * 100)}% accuracy`);
// Stop if they reached threshold OR hit max attempts
if (accuracy >= 0.80 || practice_attempts >= MAX_PRACTICE_ATTEMPTS) {
return false;
} else {
return true;
}
}
};
// practice success message
let practice_success_message = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p style="color: green; font-size: 24px;">Excellent!</p>
<p>You're ready for the main task.</p>
<p>Press the space bar to continue.</p>`,
choices: [" "]
}
],
conditional_function: function() {
// Check if they passed on their last attempt
let last_block = jsPsych.data.get().filter({phase: "practice trial"}).last(6);
let correct = last_block.filter({correct: true}).count();
let total = last_block.count();
return (correct / total) >= 0.80;
}
};
// practice failure message
let practice_failure_message = {
timeline: [
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `<p>You've completed the maximum practice attempts.</p>
<p>Let's move on to the main task anyway.</p>
<p>Remember: Press "f" for animals, "j" for furniture.</p>
<p>Press the space bar to continue.</p>`,
choices: [" "]
}
],
conditional_function: function() {
let last_block = jsPsych.data.get().filter({phase: "practice trial"}).last(6);
let correct = last_block.filter({correct: true}).count();
let total = last_block.count();
return (correct / total) < 0.80;
}
};
// ============================================
// jsPsych Run
// ============================================
jsPsych.run([
welcome,
practice_block,
practice_success_message,
practice_failure_message,
end_practice
]);
19.10 Common Patterns and Best Practices
For a quick reference, here are some commonly applied patterns that you may need using conditional and loop functions.
19.10.1 Pattern 1: Checking Previous Trial Data
This is the most common pattern - looking at what just happened:
19.10.2 Pattern 2: Checking Specific Trial Types
When you need to find a specific earlier trial:
19.10.3 Pattern 3: Calculating Performance Metrics
For loop functions that check performance:
19.10.4 Pattern 4: Using External Variables
Sometimes you need to track state across multiple timelines:
19.11 Best Practices
- Always test your conditions. Use
console.log()to verify your conditional logic is working as expected - Be careful with data access. Make sure the data you’re trying to access actually exists. If you try to access trial 5 when only 3 trials have run, you’ll get an error.
- Use descriptive variable names.
let accuracy = correct / totalis much clearer thanlet a = c / t - Comment your logic. Conditional logic can get complex. Add comments explaining what you’re checking and why.
- Consider the user experience. If practice might repeat multiple times, let participants know why and give them encouragement.
19.12 Summary
In this chapter, we learned how to make our experiments dynamic using two powerful tools:
- Conditional functions (
conditional_function): Control whether trials or timelines run based on previous data - Loop functions (
loop_function): Control whether timelines repeat based on performance
These tools work at any level, from showing a single feedback trial to controlling entire experimental phases. The key is understanding that jsPsych evaluates these functions at specific times:
- Conditional functions are evaluated before a timeline runs
- Loop functions are evaluated after a timeline completes
By combining these tools with the data access methods we learned earlier, we can create experiments that adapt to each participant’s performance, provide targeted feedback, and branch to different paths based on responses.