22  Randomization in Experimental Design

learning goals
  1. Implement random assignment to allocate participants to experimental conditions using URL parameters
  2. Randomize trial order using built-in methods and custom functions with constraints
  3. Randomize block order across participants while maintaining experimental control
  4. Programmatically assign stimuli to conditions with balanced or constrained randomization
  5. Test and verify randomization procedures to ensure methodological rigor

22.1 Introduction

When you run an experiment, you’re trying to isolate the effect of your independent variable on your dependent variable. But the world is messy, and participants differ from each other in countless ways, stimuli have unique characteristics, and the order in which things happen can influence responses. Without proper randomization, these factors can become confounding variables that make it impossible to draw clear conclusions from your data.

Randomization is your primary tool for controlling these potential confounds. It helps ensure that any differences you observe between conditions are due to your experimental manipulation, not to systematic biases in how participants were assigned, how stimuli were selected, or how trials were ordered.

22.1.1 Randomization vs. Counterbalancing

Before we dive into implementation, it’s important to distinguish between randomization and counterbalancing, as both are mentioned in experimental design textbooks but require different technical approaches.

Randomization means using a random process to determine assignment or order for each participant independently. For example, each participant might be randomly assigned to a condition, or each participant might see trials in a randomly shuffled order. This is straightforward to implement in jsPsych using JavaScript’s built-in random functions.

Counterbalancing means systematically varying the order of conditions across participants according to a predetermined scheme (like complete counterbalancing or Latin square designs). For example, if you have conditions A and B, you might want exactly half your participants to experience A-then-B and the other half to experience B-then-A. This requires tracking how many participants have been assigned to each order and making assignments accordingly. This typically requires server-side code because you need coordinate across participants.

In this chapter, we’ll focus on randomization techniques that can be fully implemented in jsPsych without server-side coordination. While counterbalancing is often preferred in experimental design (especially for within-subjects experiments with few conditions), randomization is more practical for online experiments and still provides good control over confounds, particularly with larger sample sizes. If you need true counterbalancing for your research, you’ll need to implement server-side logic beyond the scope of this textbook.

22.1.2 Types of Randomization in jsPsych Experiments

In online experiments built with jsPsych, you’ll typically need to implement three distinct types of randomization:

  1. Random assignment of participants to conditions: In between-subjects designs, ensuring that participants are equivalently distributed across experimental conditions
  2. Randomization of trial and block order: Preventing order effects by randomizing the sequence in which participants experience trials and blocks, and implementing counterbalancing strategies for within-subjects designs
  3. Random assignment of stimuli to conditions: Controlling for item-level differences by randomly selecting or assigning specific stimuli from your stimulus pool

By the end of this chapter, you’ll be able to implement each of these randomization strategies in jsPsych, building experiments that properly control for confounds through strategic use of randomization, an important skill for conducting rigorous experimental research.

Note: While randomization is a powerful tool, remember that it’s not magic. Random assignment works better with larger samples, and even with proper randomization, you should always check whether your groups are balanced on key variables. The inferential statistics you’ll use to analyze your data account for the probabilistic nature of random assignment, but good experimental design starts with thoughtful implementation of these randomization principles.

22.2 Random Assignment of Participants to Conditions

In between-subjects designs, where each participant experiences only one level of your independent variable, you need to randomly assign participants to conditions. This ensures that the groups are equivalent on average in that they should have similar distributions of age, motivation, fatigue, prior knowledge, and countless other variables you haven’t even measured.

For example, if you’re testing whether studying with music affects memory performance, you might randomly assign half your participants to study with music and half to study in silence. Random assignment means that any pre-existing differences in memory ability should be distributed equally across both groups.

Key Principle: Random assignment controls for participant-level confounds in between-subjects designs.

22.2.1 Implementing Random Assignment

We have already learned some techniques for controlling what participants do in the experiment using conditional functions. For instance, we can show/skip the ‘music’ versus ‘no music’ condition based on which condition they were assigned to like this:

let assigned_condition = "music"

let music_trials = {
    timeline: [/* trials */],
    conditional_function: function(){
      if(assigned_condition == "music"){
        return true
      } else {
        return false
      }
    }
}

let no_music_trials = {
    timeline: [/* trials */],
    conditional_function: function(){
      if(assigned_condition == "no music"){
        return true
      } else {
        return false
      }
    }
}

jsPsych.run([
  introduction,
  music_trials,
  no_music_trials,
  debrief
])

We can even control what version of the task they see using simpler if else logic. For instance, I could simply change what is placed in jsPsych.run like this:

if(assigned_condition === "music"){
  
  jsPsych.run([
    introduction,
    music_trials,
    debrief
  ])
  
} else if(assigned_condition === "no music"){
  
  jsPsych.run([
    introduction,
    no_music_trials,
    debrief
  ])
  
}

But, how exactly do we determine whether the current participant should be assigned to the ‘music’ or ‘no music’ group?

22.2.2 Simple Solution: Math.random()

The most straightforward approach is to use JavaScript’s built-in Math.random() function to randomly assign each participant when they load the experiment. This function returns a random decimal between 0 and 1, which we can use to make assignment decisions.

For a simple two-condition experiment:

// Randomly assign to one of two conditions
let assigned_condition;

if(Math.random() < 0.5){
  assigned_condition = "music";
} else {
  assigned_condition = "no music";
}

For experiments with more than two conditions, you can extend this logic:

// Randomly assign to one of three conditions
let assigned_condition;
let random_number = Math.random();

if(random_number < 0.33){
  assigned_condition = "music";
} else if(random_number < 0.67){
  assigned_condition = "no music";
} else {
  assigned_condition = "white noise";
}

jsPsych also has built-in randomization methods, which you can use for cleaner code. For instance, we use the sampleWithoutReplacement to select one item from a conditions array:

// Randomly select one condition from an array
let conditions = ["music", "no music", "white noise"];
let assigned_condition = jsPsych.randomization.sampleWithoutReplacement(conditions, 1)[0];

The advantages to using these approaches is that it is simple to implement, requires no external services, and works immediately.

There are some limitations, however:

First, you have no control over how many participants end up in each condition. With small samples, you might end up with unequal group sizes (e.g., 7 in one condition and 13 in another). This is generally not a serious problem, but it’s less statistically efficient than equal group sizes. Typically, if you wanted at least 50 participants per group, you’d have to keep collecting data until both groups reach a minimum of 50, and you may end up with something like 54 in one group and 50 in the other.

A more important limitation, however, is that this approach doesn’t actually randomly assign participants to conditions! Instead, it randomly assigns sessions to a conditions. Each time someone reloads the webpage, they are randomly assigned again. This means a single participant could reload the page multiple times and experience different conditions, either accidentally or intentionally. This violates the principle of random assignment, where each participant should be assigned to exactly one condition.

To give one example where this could be a serious limitation, consider a ‘reward’ versus ‘no reward’ manipulation. In the introductory instructions one group receives the prompt “You will have the opportunity to earn up to $5!”, but the no reward group does not receive that instruction. A participant could reload the page multiple times until they get the instruction version they would prefer.

For casual testing or demonstrations this may be acceptable, but for research purposes, this is a significant limitation.

22.2.3 Preferred Solution: External ID in URL

Many online research platforms (like Prolific, MTurk, or SONA) can pass a participant ID through the URL when launching your experiment. You can use this ID to determine condition assignment in a reproducible way.

Before we dive into the code, let’s understand what URL parameters are. You’ve probably seen URLs that look like this: https://www.example.com/search?query=psychology&sort=recent

Everything after the question mark (?) consists of URL parameters (also called query parameters). These are key-value pairs separated by ampersands (&). In the example above:

  • query=psychology (the key is “query” and the value is “psychology”)
  • sort=recent (the key is “sort” and the value is “recent”)

URL parameters are a way to pass information to a webpage. When you click a link or type a URL with parameters, the webpage can read those values and use them to customize what it displays or how it behaves.

For online experiments, recruitment platforms can automatically add a participant ID to your experiment’s URL. For example: https://yourstudy.com/experiment.html?participant=12345

Or with multiple parameters: https://yourstudy.com/experiment.html?participant=12345&session=2&study=memory

This ID is linked to a particular participant, and does not change when they reload the page. That means we can use it as a way of assigning a participant to a condition!

jsPsych provides a convenient method for reading URL parameters: jsPsych.data.getURLVariable(). This method takes the name of the parameter you want to retrieve and returns its value. If your URL is https://yourstudy.com/experiment.html?participant=12345

// URL: https://yourstudy.com/experiment.html?participant=12345
// Extract the participant ID from the URL
const participant_id = jsPsych.data.getURLVariable('participant');

console.log(participant_id); // Will print: 12345

If a parameter doesn’t exist in the URL, getURLVariable()returns undefined:

// URL: https://yourstudy.com/experiment.html
const participant_id = jsPsych.data.getURLVariable('participant'); // undefined if not in URL
const session_num = jsPsych.data.getURLVariable('session'); // undefined if not in URL

Now that we can extract the participant ID from the URL, we can use it to determine condition assignment. However, the exact approach depends on the format of the participant ID provided by your recruitment platform.

22.2.3.1 Numeric IDs (e.g., SONA)

SONA and some other platforms provide numeric participant IDs. These can be used directly with the modulo operator:

// // URL: https://yourstudy.com/experiment.html?id=12345
// Get participant ID from URL
const participant_id = jsPsych.data.getURLVariable('id');

// Use the ID to determine condition
// Even IDs get music, odd IDs get no music
let assigned_condition;
if(participant_id % 2 === 0){
  assigned_condition = "music";
} else {
  assigned_condition = "no music";
}

// Save id and assignment to data
jsPsych.data.addProperties({
  participant_id: participant_id,
  assigned_condition: assigned_condition
});

The modulo operator (%) gives us the remainder after division. So participant_id % 2 will be 0 for even numbers and 1 for odd numbers. This creates a simple alternating pattern of condition assignment.

For more than two conditions:

// Assign to one of three conditions based on ID
let conditions = ["music", "no music", "white noise"];
let assigned_condition = conditions[participant_id % 3];

Here’s how this works:

  • If participant_id is 12345, then 12345 % 3 = 0, so they get conditions[0] = “music”
  • If participant_id is 12346, then 12346 % 3 = 1, so they get conditions[1] = “no music”
  • If participant_id is 12347, then 12347 % 3 = 2, so they get conditions[2] = “white noise”
  • If participant_id is 12348, then 12348 % 3 = 0, so they get conditions[0] = “music” (the pattern repeats)

There’s one problem with the code above: if someone opens your webpage without the ID in their URL, the experiment will crash. We can add a fallback to handle this:

// Get participant ID from URL, or generate random one if not found
let participant_id = jsPsych.data.getURLVariable('participant') || jsPsych.randomization.randomInt(1, 100000);

// Assign to one of three conditions based on ID
let conditions = ["music", "no music", "white noise"];
let assigned_condition = conditions[participant_id % 3];

The || operator (logical OR) works as a fallback: if getURLVariable('participant') returns undefined, it will use the randomly generated ID instead.

This approach is useful during development and testing, but remember that visitors without a proper ID will be randomly assigned each time they reload the page (the same limitation as the Math.random() approach).

22.2.3.2 Alphanumeric IDs (e.g., Prolific)

Some platforms like Prolific use alphanumeric IDs that look like 5f8d9a2b3c4e5f6a7b8c9d0e. These cannot be used directly with the modulo operator because they’re strings, not numbers. You need to convert them to numbers first.

A reliable approach is to sum the character codes of all characters in the string:

// Function to convert any string to a number
function stringToNumber(str) {
  let sum = 0;
  for (let i = 0; i < str.length; i++) {
    sum += str.charCodeAt(i);
  }
  return sum;
}

// Get participant ID from URL, or generate random one if not found
let participant_id = jsPsych.data.getURLVariable('participant') || jsPsych.randomization.randomInt(1, 100000).toString();

// Convert to number and assign condition
const numeric_id = stringToNumber(participant_id);
let conditions = ["music", "no music", "white noise"];
let assigned_condition = conditions[numeric_id % 3];

// Save id and assignment to data
jsPsych.data.addProperties({
  participant_id: participant_id,
  assigned_condition: assigned_condition
});

This works because:

  • Each character has a numeric code (e.g., ‘a’ = 97, ‘b’ = 98, ‘5’ = 53)
  • Summing these codes gives us a unique number for each unique string
  • As long as the platform generates IDs randomly, the sums will be evenly distributed
  • The modulo operator then distributes these evenly across conditions

This solution also includes the fallback for missing URL parameters. Note that we convert the random integer to a string using .toString() so it matches the format of IDs from recruitment platforms. The stringToNumber() function works for both numeric IDs (like “12345”) and alphanumeric IDs (like “5f8d9a2b3c4e”), making it a flexible solution for any recruitment platform.

22.2.4 Assigning Multiple Conditions

Sometimes we have multiple between-subjects conditions and participants need to be assigned to a combination of conditions. For example, you might manipulate both background music (music vs. no music) and block order (ABC vs. BCA vs. CAB), creating a 2 × 3 factorial design with 6 total condition combinations.

The key challenge is ensuring that these assignments are independent—that is, participants assigned to the “music” condition should be equally likely to receive any of the three block orders, and vice versa. If we simply used the participant ID directly for both assignments, they would be correlated.

The solution is to use different “seeds” by adding different strings to the participant ID before converting it to a number:

function stringToNumber(str) {
  let sum = 0;
  for (let i = 0; i < str.length; i++) {
    sum += str.charCodeAt(i);
  }
  return sum;
}

let participant_id = jsPsych.data.getURLVariable('participant') || 
                     jsPsych.randomization.randomInt(1, 100000).toString();

// Use ID with different "seeds" for independent assignments
let music_hash = stringToNumber(participant_id + "_music");
let font_hash = stringToNumber(participant_id + "_font");

let music_conditions = ["music", "no_music"];
let assigned_music = music_conditions[music_hash % 2];

let font_conditions = ["large", "small"];
let assigned_font = font_conditions[font_hash % 2];

// Save both assignments to data
jsPsych.data.addProperties({
  participant_id: participant_id,
  music_condition: assigned_music,
  font_condition: assigned_font
});

By adding “_music” and “_font” to the participant ID before hashing, we create two different numbers from the same ID. These numbers will be uncorrelated, ensuring independent random assignment to each factor.

This approach scales to any number of factors. For example, if you also wanted to randomly assign text color (red vs. blue), you could add:

let color_hash = stringToNumber(participant_id + "_color");
let color_conditions = ["red", "blue"];
let assigned_color = color_conditions[color_hash % 2];

Each factor uses a unique seed string, ensuring all assignments are independent while remaining consistent for each participant ID. This creates a fully crossed factorial design where participants are randomly assigned to one combination of all factors, and the assignment remains stable across sessions.

22.2.5 Handling Missing IDs: Allow Access or Restrict?

The examples above use a fallback that generates a random ID when none is provided in the URL. This is convenient for testing, but you have another option for actual data collection: restricting access entirely to participants with valid IDs.

let participant_id = jsPsych.data.getURLVariable('participant');

// evaluates to true if present or false if undefined/null
if(participant_id){

  // Valid ID - run the actual experiment
  jsPsych.run([experiment]);
  
} else {

  // No valid ID - show error message
  let no_experiment = {
    type: jsPsychHtmlKeyboardResponse,
    stimulus: `
      <p>This study is only accessible through SONA/Prolific.</p>
      <p>Please return to the recruitment platform and use the provided link.</p>
    `,
    choices: "NO_KEYS"
  };

  jsPsych.run([no_experiment]);

}

This ensures that only participants with valid IDs from your recruitment platform can complete the study, preventing accidental or unauthorized access.

Which approach should you use? During development, the fallback with random assignment is most convenient for testing. For actual data collection, restricting access provides the most control and ensures data quality by preventing unauthorized access.

22.2.6 Testing with URL Parameters

When you’re developing your experiment locally, you can test different conditions by manually adding parameters to your URL. For example:

file:///path/to/your/experiment.html?participant=100
file:///path/to/your/experiment.html?participant=101
file:///path/to/your/experiment.html?participant=5f8d9a2b3c4e

Or if you’re using a local server:

http://localhost:8000/experiment.html?participant=100
http://localhost:8000/experiment.html?participant=101
http://localhost:8000/experiment.html?participant=5f8d9a2b3c4e

You can also test the fallback behavior by opening the URL without any parameters:

http://localhost:8000/experiment.html

This lets you verify that each condition works correctly before deploying your experiment.

22.2.7 How Recruitment Platforms Use URL Parameters

When you set up your study on platforms like SONA or Prolific, you’ll provide them with your experiment’s base URL. They will automatically append the participant ID (and sometimes other information) to this URL when directing participants to your study.

For example, on SONA you might configure your study URL as:

https://yourstudy.com/experiment.html?id=%SURVEY_CODE%

SONA will replace %SURVEY_CODE% with each participant’s unique ID. On Prolific, you might use:

https://yourstudy.com/experiment.html?participant={{%PROLIFIC_PID%}}

Each platform has its own syntax for these placeholders so you should check their documentation for the exact format.

Advantages:

  1. Assignment is reproducible—the same ID always gets the same condition, which can be helpful for debugging.
  2. This ensures that a single participant is assigned to exactly one condition. If a participant drops out and restarts, or accidentally reloads the page, they’ll get the same condition because their participant ID doesn’t change. This is true random assignment of participants (not just sessions) to conditions.
  3. You can plan your sample size to ensure equal groups (e.g., recruit participants in multiples of your number of conditions).

Limitations: Requires that you’re using a platform that provides participant IDs. The assignment is only pseudo-random, so it depends on the order in which the platform assigns IDs to participants, though in practice this is rarely a concern.

22.2.8 Key Principles for Participant Assignment

When implementing participant assignment to conditions:

  1. Use URL parameters for consistency: Extract participant IDs from the URL to ensure participants always see the same condition across sessions
  2. Handle different ID formats: Account for both numeric (e.g., SONA) and alphanumeric (e.g., Prolific) ID systems using appropriate conversion methods
  3. Implement fallback logic: Decide whether to allow access with a random ID (for testing) or restrict access (for data collection) when IDs are missing
  4. Use modulo for balanced assignment: The modulo operator ensures equal distribution across conditions as participant numbers grow
  5. Save assignments to data: Always record the assigned condition and participant ID in your data file for later analysis
  6. Test with multiple IDs: Verify that your assignment logic produces the expected distribution across a range of participant IDs
  7. Use independent hash seeds: When making multiple assignments from the same ID, add different strings before hashing to ensure assignments are uncorrelated

By following these principles, you can implement robust participant assignment procedures that ensure balanced, consistent, and reproducible condition assignments across your study.

22.3 Randomization of Trial Order

The order in which trials and blocks appear can profoundly affect participant responses. As discussed in the textbook excerpt, order effects can take several forms:

  • Practice effects: Performance improves because participants get better at the task
  • Fatigue effects: Performance declines because participants get tired or bored
  • Context effects: Responses to one stimulus are influenced by what came immediately before it

These effects can occur both within blocks (trial-to-trial) and across blocks. For example, if your experiment has a memory block followed by an attention block, participants might perform differently than if the attention block came first.

Order randomization (at both trial and block levels) helps control for these order effects and carryover effects. For within-subjects designs with many trials, you typically randomize the trial order for each participant. When you have multiple blocks or tasks, you may also need to randomize or counterbalance the block order.

22.3.1 Basic Trial Randomization with randomize_order

We have already been using a simple method for randomizing the order of trials. It’s worth revisiting that method so that we fully understand what it does:

let sart = {
  timeline: [
    {
    type: jsPsychHtmlKeyboardResponse,
    stimulus: jsPsych.timelineVariable("number"),
    choices: [" "],
    trial_duration: 1300
    }
  ],
  timeline_variables: [
    {number: 0}, 
    {number: 1}, 
    {number: 2}, 
    {number: 3}, 
    {number: 4},
    {number: 5},  
    {number: 6}, 
    {number: 7}, 
    {number: 8}, 
    {number: 9}
  ],
  randomize_order: true,
  repetitions: 2
}

By adding randomize_order: true, we are instructing jsPsych to randomly shuffle the order of our timeline_variables so that one participant might see 1,6,7,8,2,5,3,0,4,9 and the next might see 0,4,1,9,2,8,6,7,3,5.

But what happens when we add repetitions: 2? Of course, we are instructing jsPsych to repeat through our timeline_variables twice, but how are they randomized?

It turns out that jsPsych treats each repetition as a separate block. In practice, that means if you’re randomizing the order and repeating twice, you first complete one randomized list of 0-9, then you complete another randomized list of 0-9. That’s a small but quite important detail. Even though we’re asking for two repetitions in a random order, the two repetitions are kept separate. Therefore, you can never receive two 9s in a row (one from the end of the first repetition and one from the start of the second), even though each number is presented twice overall.

That, of course, might be exactly what you want to happen. But it may not be. Perhaps you really want the possibility of the same number appearing on consecutive trials. This method does not accomplish that. In fact, if we use this method, we have no control over how the trials are randomized since we can only shuffle the order within each repetition block.

22.3.2 Custom Randomization with sample

Fortunately, jsPsych provides a method for defining custom randomization functions through the sample parameter. This gives us complete freedom to arrange the trials in any way we see fit.

The sample parameter accepts an object with two properties:

  • type: The sampling method to use (set to ‘custom’ for custom functions)
  • fn: A custom function that determines the trial order

The custom function receives an array of indices (position numbers) as input and must return an array of indices that specifies the order in which trials should be presented.

For example, if you have 5 timeline_variables, jsPsych passes your function the array[0, 1, 2, 3, 4]. Each number corresponds to a position in your timeline_variables array:

  • 0 refers to the first timeline variable
  • 1 refers to the second timeline variable
  • 2 refers to the third timeline variable
  • And so on…

Whatever array of indices you return determines the exact order trials will be presented. For example:

  • Returning [0, 1, 2, 3, 4] presents trials in their original order
  • Returning [4, 3, 2, 1, 0] presents trials in reverse order
  • Returning [0, 0, 1, 1, 2, 2, 3, 3, 4, 4] presents each trial twice in sequence

Let’s look at a simple example using words to make this concrete:

let lexical_decision = {
  timeline: [
    {
      type: jsPsychHtmlKeyboardResponse,
      stimulus: jsPsych.timelineVariable("word"),
      choices: ['f', 'j'],
      prompt: '<p>Press F for word, J for non-word</p>'
    }
  ],
  timeline_variables: [
    {word: "table"},   // index 0
    {word: "blirk"},   // index 1
    {word: "chair"},   // index 2
    {word: "glorp"},   // index 3
    {word: "house"}    // index 4
  ],
  sample: {
    type: 'custom',
    fn: function(indices) {
      // indices = [0, 1, 2, 3, 4]
      // Let's return them in reverse order
      return [4, 3, 2, 1, 0];
      // This will present: "house", then "glorp", then "chair", then "blirk", then "table"
    }
  }
}

In this example, indices is [0, 1, 2, 3, 4], and we return [4, 3, 2, 1, 0], which means:

  1. First trial: show timeline_variables[4] (which is {word: “house”})
  2. Second trial: show timeline_variables[3] (which is {word: “glorp”})
  3. Third trial: show timeline_variables[2] (which is {word: “chair”})
  4. Fourth trial: show timeline_variables[1] (which is {word: “blirk”})
  5. Fifth trial: show timeline_variables[0] (which is {word: “table”})

Now let’s look at several practical examples of how we can use custom sampling to achieve different randomization goals.

You’ll notice across these examples, we’re going to be taking advantage of the built-in array methods you can review in the Appendix, and the jsPsych randomization functions, you can read more about here.

22.3.3 Example 1: Two Repetitions as One Fully Randomized Block

What if we want two repetitions but truly randomized as one block, allowing the same number to appear consecutively?

let lexical_decision = {
  timeline: [
    {
      type: jsPsychHtmlKeyboardResponse,
      stimulus: jsPsych.timelineVariable("word"),
      choices: ['f', 'j'],
      prompt: '<p>Press F for word, J for non-word</p>'
    }
  ],
  timeline_variables: [
    {word: "table"},   // index 0
    {word: "blirk"},   // index 1
    {word: "chair"},   // index 2
    {word: "glorp"},   // index 3
    {word: "house"}    // index 4
  ],
  sample: {
    type: 'custom',
    fn: function(indices) {
      // indices = [0, 1, 2, 3, 4
      // Duplicate the array to get two of each index
      let doubled = indices.concat(indices);
      // doubled = [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]

      // Shuffle the entire combined array
      return jsPsych.randomization.shuffle(doubled);
      // Might return something like: [3, 0, 0, 1, 2, 4, 1, 3, 2, 4]
    }
  }
}

This approach:

  1. Takes the original array of indices [0, 1, 2, 3, 4]
  2. Concatenates it with itself to create two copies: [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]
  3. Shuffles the entire combined array as one block
  4. Now consecutive trials can be the same number (e.g., you might see 3 followed by 3)

Note that I’m using some of JavaScript’s built-in functions for arrays. You can read more about those in the Appendix.

22.3.4 Example 2: Always Start with a Specific Trial

What if we always want to start the block with 0, but randomize the rest of the trials?

let lexical_decision = {
  timeline: [
    {
      type: jsPsychHtmlKeyboardResponse,
      stimulus: jsPsych.timelineVariable("word"),
      choices: ['f', 'j'],
      prompt: '<p>Press F for word, J for non-word</p>'
    }
  ],
  timeline_variables: [
    {word: "table"},   // index 0
    {word: "blirk"},   // index 1
    {word: "chair"},   // index 2
    {word: "glorp"},   // index 3
    {word: "house"}    // index 4
  ],
  sample: {
    type: 'custom',
    fn: function(indices) {
      // remove the first one and put it in first_trial
      let first_trial = indices.shift()
      
      // shuffle the rest of the trials
      let trials = jsPsych.randomization.shuffle(indices)
      
      // put the first trial at the beginning
      let all_trials = [first_trial].concat(trials)
    
      // return all trials
      return all_trials
      // might return something like 0,4,2,3,1 
    }
  }
}

22.3.5 Example 3: Unequal Repetitions

What if we want half the trials to appear twice and half to appear once, all in a random order? BUT we want to randomly pick which ones appear twice, so that each participant gets different items that repeat?

let sart = {
  timeline: [
    {
      type: jsPsychHtmlKeyboardResponse,
      stimulus: jsPsych.timelineVariable("number"),
      choices: [" "],
      trial_duration: 1300
    }
  ],
  timeline_variables: [
    {number: 0}, 
    {number: 1}, 
    {number: 2}, 
    {number: 3}, 
    {number: 4},
    {number: 5},  
    {number: 6}, 
    {number: 7}, 
    {number: 8},
    {number: 9}
  ],
  sample: {
    type: 'custom',
    fn: function(indices) {
      // indices will be = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

      // shuffle the full array
      let shuffled = jsPsych.randomization.shuffle(indices);

      // get both halves of the shuffled array
      let half1 = shuffled.slice(0, Math.floor(indices.length / 2));
      let half2 = shuffled.slice(Math.floor(indices.length / 2), indices.length );
      
      // randomize and repeat each item twice
      half1 = jsPsych.randomization.repeat(half1, 2)
      
      // Combine both halves
      let combined = half1.concat(half2);
      // Shuffle the entire array
      combined = jsPsych.randomization.shuffle(combined);
    
      return combined
    }
  }
}

22.4 Randomization of Block Order

In many experiments, you need to present multiple blocks or tasks to participants. The order in which these blocks appear can influence performance due to practice effects, fatigue, or carryover effects between tasks. Randomizing block order helps control for these sequential effects.

22.4.1 Basic Block Randomization with shuffle()

The simplest way to randomize block order is to store your blocks in an array and shuffle them:

// Define your experimental blocks
let memory_block = {
  timeline: [/* memory task trials */]
};

let attention_block = {
  timeline: [/* attention task trials */]
};

let reasoning_block = {
  timeline: [/* reasoning task trials */]
};

// Put blocks in an array
let blocks = [memory_block, attention_block, reasoning_block];

// Shuffle the order
blocks = jsPsych.randomization.shuffle(blocks);

// Add shuffled order to a new timeline
let all_tasks = {
  timeline: blocks
}

// Run
jsPsych.run([
   welcome,
   all_tasks,
   debrief
])

Each participant will now see the three blocks in a different random order, while the welcome and debrief screens remain in fixed positions.

22.4.2 Randomizing Block Order Across Participants

Just as we used participant IDs to assign conditions, we can use them to determine block order. This ensures that each participant consistently sees the same block order if they need to return to the experiment:

// Get participant ID from URL
let participant_id = jsPsych.data.getURLVariable('participant') ||
                     jsPsych.randomization.randomInt(1, 100000).toString();

// Convert to number (using our stringToNumber function for alphanumeric IDs) if you need to
function stringToNumber(str) {
  let sum = 0;
  for (let i = 0; i < str.length; i++) {
    sum += str.charCodeAt(i);
  }
  return sum;
}

let numeric_id = stringToNumber(participant_id);

// Define blocks
let memory_block = {
  timeline: [/* memory task trials */]
};

let attention_block = {
  timeline: [/* attention task trials */]
};

let reasoning_block = {
  timeline: [/* reasoning task trials */]
};

// Create array of possible block orders
let block_orders = [
  [memory_block, attention_block, reasoning_block],
  [memory_block, reasoning_block, attention_block],
  [attention_block, memory_block, reasoning_block],
  [attention_block, reasoning_block, memory_block],
  [reasoning_block, memory_block, attention_block],
  [reasoning_block, attention_block, memory_block]
];

// Assign block order based on participant ID using modulo
let assigned_blocks = block_orders[numeric_id % block_orders.length];

// Add to timeline
let all_tasks = {
  timeline: assigned_blocks
}

// Run
jsPsych.runs([
  welcome,
  all_tasks,
  debrief
])

This approach ensures that block order is balanced across participants (each order appears equally often) and remains consistent for each participant ID.

22.4.3 Independent Randomization of Condition and Block Order

You can use the same participant ID to independently assign both experimental condition and block order by using different “seeds” as we discussed in the participant assignment section:

let participant_id = jsPsych.data.getURLVariable('participant') || 
                     jsPsych.randomization.randomInt(1, 100000).toString();

function stringToNumber(str) {
  let sum = 0;
  for (let i = 0; i < str.length; i++) {
    sum += str.charCodeAt(i);
  }
  return sum;
}

// Use ID with different seeds for independent assignments
let condition_hash = stringToNumber(participant_id + "_condition");
let order_hash = stringToNumber(participant_id + "_order");

// Assign experimental condition (e.g., music vs. no music)
let conditions = ["music", "no_music"];
let assigned_condition = conditions[condition_hash % 2];

// Assign block order
let block_orders = [
  [memory_block, attention_block, reasoning_block],
  [memory_block, reasoning_block, attention_block],
  [attention_block, memory_block, reasoning_block],
  [attention_block, reasoning_block, memory_block],
  [reasoning_block, memory_block, attention_block],
  [reasoning_block, attention_block, memory_block]
];
let assigned_blocks = block_orders[order_hash % block_orders.length];

// Save assignments to data
jsPsych.data.addProperties({
  participant_id: participant_id,
  condition: assigned_condition,
  block_order: order_hash % block_orders.length
});


// Add to timeline
let all_tasks = {
  timeline: assigned_blocks
}

// Run
jsPsych.runs([
  welcome,
  all_tasks,
  debrief
])

22.4.4 Key Principles for Block Order Randomization

  1. Store blocks in arrays: Organize your blocks as array elements to enable programmatic reordering
  2. Choose the right randomization method: Use shuffle() or ID-based assignment for between-participant consistency
  3. Create independent assignments: When assigning both condition and block order, use different hash seeds to ensure they’re uncorrelated
  4. Document block orders: Save the block order assignment to your data so you can analyze order effects if needed
  5. Test thoroughly: Verify that all possible block orders work correctly and that randomization produces the expected distribution

22.5 Random Assignment of Stimuli to Conditions

Even within a single participant’s experience, you often need to randomly assign which specific stimuli appear in which conditions. This is particularly important in within-subjects designs, where participants see multiple trials or items.

Consider a word memory experiment where you want to test whether concrete nouns (like “dog” or “table”) are remembered better than abstract nouns (like “truth” or “justice”). You might have 50 concrete nouns and 50 abstract nouns in your stimulus set. But what if, by chance, all your concrete nouns happen to be shorter, more common, or more emotionally charged than your abstract nouns? These differences could drive any effect you observe, rather than the concreteness itself.

The solution is to randomly select which specific words from your larger stimulus pool get shown to each participant, and potentially to randomly assign individual words to conditions when you have stimuli that could plausibly fit into multiple categories.

The key principle is that random stimulus assignment helps control for item-level confounds; characteristics of individual stimuli that might influence your results independent of your experimental manipulation.

22.5.1 Programmatically Generating timeline_variables

In order to understand how we can randomly assign stimuli, it’s worth taking a moment to understanding that the goal, which is to create our timeline_variables array, which is an array of objects { }, in a programmtic way. That is, we build the array with it’s individual components using JavaScript logic, rather than writing out the array manually.

For instance, let’s say we’re changing the color of the words to see if the color influences response times. If we have 4 words, then we want to randomly assign 2 to be ‘red’ and the other 2 to be ‘green’.

The end result is that we want a timeline_variables array of objects that can be used inside a jsPsych trial, like this:

let stims = [
  {word: "house", color: "red"},
  {word: "nurse", color: "red"},
  {word: "hate", color: "blue"},
  {word: "truck", color: "blue"}
]

let trials = {
  type: jsPsychHtmlKeyboardResponse,
  stimulus: function(){
    return `<p style="color:${jsPsych.evaluateTimelineVariable("color")}"> ${jsPsych.evaluateTimelineVariable("word")}</p>`
  },
  timeline_variables: stims
}

But if we’re interested in the effects of color on response time, we want to randomize which words are assigned to each color to control for word-specific effects. For example, “hate” might be a word that people always take longer to respond to because it’s negative. If we make “hate” blue for every participant, we might erroneously conclude that blue words cause people to slow down. Instead, we want about half the participants to see “hate” in blue and the other half in red, so that the effect of “hate” is averaged out across both color conditions across participants.

22.5.1.1 Building Arrays Programmatically

To accomplish random assignment, we need to work backwards. We need to decide on the array structure we want to end up with, separate out the parts, and put them back together. Here’s how we can recreate the same array programmatically:

// Goal: recreate this array of objects
// let stims = [
//  {word: "house", color: "red"},
//  {word: "nurse", color: "red"},
//  {word: "hate", color: "blue"},
//  {word: "truck", color: "blue"}
// ]

// Step 1: Separate out the parts
let words = ["house", "nurse", "hate", "truck"]
let colors = ["red", "red", "blue", "blue"]

// Step 2: Use a loop to create each object and add them to an array
let stims = []
for(i = 0; i < words.length; i++){
  let combo = {
    word: words[i], 
    color: colors[i]
    }
    
  stims.push(combo)
}

// Step 3: Use the programmatically created array
let trials = {
  type: jsPsychHtmlKeyboardResponse,
  stimulus: function(){
    return `<p style="color:${jsPsych.evaluateTimelineVariable("color")}"> ${jsPsych.evaluateTimelineVariable("word")}</p>`
  },
  timeline_variables: stims
}

This code produces exactly the same result as writing the array manually, but now we have the flexibility to modify it programmatically.

22.5.1.2 Adding Randomization

Once we understand how to recreate the timeline_variables array programmatically, we can add randomization:

// Goal: Create the same array structure BUT with random word-color pairings

// Step 1: Separate out the parts
let words = ["house", "nurse", "hate", "truck"]
let colors = ["red", "red", "blue", "blue"]

// Step 2: Randomize the order of words!
words = jsPsych.randomization.shuffle(words)

// Step 3: Use a loop to pair each word with a color
let stims = []
for(i = 0; i < words.length; i++){
  let combo = {
    word: words[i], 
    color: colors[i]
    }
    
  stims.push(combo)
}

// One possible result:
// Array(4) [ {…}, {…}, {…}, {…} ]
// 0: Object { word: "hate", color: "red" }
// 1: Object { word: "house", color: "red" }
// 2: Object { word: "truck", color: "blue" }
// 3: Object { word: "nurs", color: "blue" }


// Step 4: Use the programmatically created array
let trials = {
  type: jsPsychHtmlKeyboardResponse,
  stimulus: function(){
    return `<p style="color:${jsPsych.evaluateTimelineVariable("color")}"> ${jsPsych.evaluateTimelineVariable("word")}</p>`
  },
  timeline_variables: stims
}

By shuffling the words array before pairing it with the colors array, each participant gets a different random assignment of words to colors. The first two words (whatever they happen to be after shuffling) will be red, and the last two will be blue.

To accomplish these randomization goals, we’ll need to make use of common JavaScript array methods and jsPsych’s built-in randomization functions. JavaScript provides powerful array methods like .slice(), .concat(), .push(), and .filter() that allow us to manipulate arrays in flexible ways (see the Appendix for a reference guide to these methods). jsPsych provides specialized randomization functions like jsPsych.randomization.shuffle() and jsPsych.randomization.sampleWithoutReplacement() that are specifically designed for experimental randomization needs (see the jsPsych randomization documentation for a complete list). By combining these tools, we can implement sophisticated stimulus assignment schemes that meet the specific needs of our experimental designs.

22.5.2 Example 1: Randomly Select a Subset of Stimuli and Assign to Conditions

Often you have a large pool of stimuli but only want to use a subset for each participant. This helps ensure that any effects you observe aren’t driven by the specific items you happened to choose.

Let’s say you have 20 words but only want to show 8 of them, 4 in red and 4 in blue:

// Large pool of possible words
let word_pool = [
  "house", "nurse", "hate", "truck", "table", "chair", 
  "love", "anger", "peace", "storm", "garden", "window",
  "friend", "enemy", "light", "shadow", "music", "silence",
  "hope", "fear"
];

// Randomly select 8 words from the pool
let selected_words = jsPsych.randomization.sampleWithoutReplacement(word_pool, 8);

// Create color assignments (4 red, 4 blue)
let colors = ["red", "red", "red", "red", "blue", "blue", "blue", "blue"];

// Shuffle the words so they're randomly paired with colors
selected_words = jsPsych.randomization.shuffle(selected_words);

// Build the timeline_variables array
let stims = [];
for(let i = 0; i < selected_words.length; i++){
  stims.push({
    word: selected_words[i],
    color: colors[i]
  });
}

// Result: 8 randomly selected words, with 4 randomly assigned to red and 4 to blue

This approach ensures that:

  1. Each participant sees a different random subset of words
  2. Words are randomly assigned to color conditions
  3. You maintain equal numbers in each condition

22.5.3 Example 2: Randomly Assign Stimuli to “Old” and “New” Lists

In recognition memory experiments, you typically show participants a study list of items, then test them with both old items (from the study list) and new items (not previously seen). You want to randomly determine which items serve which role.

Keep in mind, we’ll actually need two timeline_variables for this example. We need a timeline_variables array that will only have the old words for the study phase, and a second timeline_variables array that will have both the old and the new words for the testing phase.

// Pool of 20 words
let word_pool = [
  "house", "nurse", "hate", "truck", "table", "chair", 
  "love", "anger", "peace", "storm", "garden", "window",
  "friend", "enemy", "light", "shadow", "music", "silence",
  "hope", "fear"
];

// Randomly shuffle the pool
let shuffled_words = jsPsych.randomization.shuffle(word_pool);

// First 10 words will be "old" (studied), last 10 will be "new" (not studied)
let old_words = shuffled_words.slice(0, 10);
let new_words = shuffled_words.slice(10, 20);

// Create study phase timeline_variables (only old words)
let study_stims = [];
for(let i = 0; i < old_words.length; i++){
  study_stims.push({
    word: old_words[i],
    item_type: "study"
  });
}

// Create test phase timeline_variables (both old and new words)
let test_stims = [];

// Add old words
for(let i = 0; i < old_words.length; i++){
  test_stims.push({
    word: old_words[i],
    correct_response: "old"
  });
}

// Add new words
for(let i = 0; i < new_words.length; i++){
  test_stims.push({
    word: new_words[i],
    correct_response: "new"
  });
}

// Shuffle test items so old and new are intermixed
test_stims = jsPsych.randomization.shuffle(test_stims);

// Now use study_stims for the study phase and test_stims for the test phase

22.5.4 Example 3: Random Pairing of Stimuli

In many experiments, you need to create random associations between two sets of stimuli. For instance, in a face-name learning task, you might want to randomly pair faces with names so that each participant learns different associations. This controls for the possibility that certain faces are easier to associate with certain names.

Let’s create a simple associative learning task where participants learn which object goes with which location:

// Two sets of stimuli to pair
let objects = ["apple", "book", "clock", "lamp", "phone", "plant"];
let locations = ["kitchen", "bedroom", "office", "garage", "bathroom", "garden"];

// Shuffle one of the arrays (or both, but shuffling one is sufficient)
let shuffled_locations = jsPsych.randomization.shuffle(locations);

// Pair them up by matching indices
let study_pairs = [];
for(let i = 0; i < objects.length; i++){
  study_pairs.push({
    object: objects[i],
    location: shuffled_locations[i],
    phase: "study"
  });
}

// Result might be:
// [
//   {object: "apple", location: "garage", phase: "study"},
//   {object: "book", location: "kitchen", phase: "study"},
//   {object: "clock", location: "bathroom", phase: "study"},
//   ...
// ]

// Verify the pairings
console.log("Object-Location Pairings:");
study_pairs.forEach(pair => {
  console.log(`${pair.object} -> ${pair.location}`);
});

22.5.5 Example 4: Balanced Random Assignment with Constraints

Sometimes you need random assignment but with specific constraints. For example, you might want to ensure that certain types of stimuli are evenly distributed across conditions.

Let’s say you have positive and negative words, and you want to ensure each color condition has an equal mix of both:

// Separate pools for positive and negative words
let positive_words = ["love", "peace", "hope", "friend", "light", "music"];
let negative_words = ["hate", "anger", "fear", "enemy", "shadow", "silence"];

// Shuffle each pool
positive_words = jsPsych.randomization.shuffle(positive_words);
negative_words = jsPsych.randomization.shuffle(negative_words);

// Take 2 positive and 2 negative for red condition
let red_words = positive_words.slice(0, 2).concat(negative_words.slice(0, 2));

// Take 2 positive and 2 negative for blue condition
let blue_words = positive_words.slice(2, 4).concat(negative_words.slice(2, 4));

// Shuffle within each condition so positive/negative are intermixed
red_words = jsPsych.randomization.shuffle(red_words);
blue_words = jsPsych.randomization.shuffle(blue_words);

// Build timeline_variables with proper labels
let stims = [];

for(let i = 0; i < red_words.length; i++){
  // Check if word is positive or negative
  let valence = positive_words.includes(red_words[i]) ? "positive" : "negative";

  stims.push({
    word: red_words[i],
    color: "red",
    valence: valence
  });
}

for(let i = 0; i < blue_words.length; i++){
  // Check if word is positive or negative
  let valence = positive_words.includes(blue_words[i]) ? "positive" : "negative";

  stims.push({
    word: blue_words[i],
    color: "blue",
    valence: valence
  });
}

// Final shuffle so red and blue trials are intermixed
stims = jsPsych.randomization.shuffle(stims);

// Result: Each color has 2 positive and 2 negative words, randomly selected and assigned
// Each stimulus object now includes: word, color, and valence labels

This approach ensures that your conditions are balanced on an important stimulus dimension (valence) while still maintaining randomization. By including the valence label in each stimulus object, you can later analyze your data by valence (e.g., to check whether positive words were responded to faster than negative words) or verify that the balancing worked correctly.

22.5.6 Verifying Your Randomization

When implementing complex stimulus assignment procedures, it’s important to verify that your code is working as intended before running participants. JavaScript’s console.log() function is invaluable for this purpose, allowing you to inspect your arrays at different stages of construction.

22.5.6.1 Basic Verification with console.log()

The simplest approach is to print your timeline_variables array to the console:

// After building your stims array
console.log(stims);

This will display the entire array in your browser’s developer console (usually accessible by pressing F12). You can expand the array to inspect individual objects and verify that:

  1. Words are assigned to the correct conditions
  2. The number of trials matches your expectations
  3. All required properties are present in each object

Using console.log(), you can refresh the page a few times to see how the assignments are changing.

22.5.6.2 Counting Assignments

For more complex designs, you might want to verify that your randomization maintains the intended balance. Here’s how to count how many stimuli fall into each condition:

// Count stimuli by color
let red_count = stims.filter(s => s.color === "red").length;
let blue_count = stims.filter(s => s.color === "blue").length;

console.log("Red trials:", red_count);
console.log("Blue trials:", blue_count);

// For Example 3, also count by valence within each color
let red_positive = stims.filter(s => s.color === "red" && s.valence === "positive").length;
let red_negative = stims.filter(s => s.color === "red" && s.valence === "negative").length;
let blue_positive = stims.filter(s => s.color === "blue" && s.valence === "positive").length;
let blue_negative = stims.filter(s => s.color === "blue" && s.valence === "negative").length;

console.log("Red positive:", red_positive);
console.log("Red negative:", red_negative);
console.log("Blue positive:", blue_positive);
console.log("Blue negative:", blue_negative);

22.5.6.3 Creating a Summary Table

For a more organized view, you can create a summary table that displays the distribution of your stimuli:

// Create a summary table of stimulus assignments

let summary = {};

stims.forEach(function(trial) {
  // Create a key combining condition properties
  let key = `${trial.color}_${trial.valence}`;

  // Initialize counter if this combination hasn't been seen
  if (!summary[key]) {
    summary[key] = 0;
  }

  // Increment counter
  summary[key]++;
});

console.table(summary);


// Output will look like:
// ┌─────────────────┬────────┐
// │     (index)     │ Values │
// ├─────────────────┼────────┤
// │  red_positive   │   2    │
// │  red_negative   │   2    │
// │  blue_positive  │   2    │
// │  blue_negative  │   2    │
// └─────────────────┴────────┘

The console.table() function provides a nicely formatted table view that makes it easy to verify your design at a glance.

22.5.6.4 Listing Specific Assignments

You might also want to see exactly which words were assigned to which conditions:

// Group words by condition
let red_words = stims.filter(s => s.color === "red").map(s => s.word);
let blue_words = stims.filter(s => s.color === "blue").map(s => s.word);

console.log("Red words:", red_words);
console.log("Blue words:", blue_words);

// Output might be:
// Red words: ["hope", "anger", "light", "fear"]
// Blue words: ["love", "shadow", "peace", "enemy"]

22.5.7 Key Principles for Stimulus Assignment

When implementing random stimulus assignment:

  1. Identify what needs to be randomized: Which stimuli? Which conditions? Which participants?
  2. Separate the components: Break your stimuli and conditions into separate arrays
  3. Apply randomization strategically: Use shuffle() for random ordering, sampleWithoutReplacement() for random selection
  4. Recombine programmatically: Use loops to pair stimuli with conditions
  5. Maintain balance when needed: Ensure equal numbers across conditions if that’s important for your design
  6. Test thoroughly: Print your arrays to the console to verify the randomization works as intended

The flexibility of programmatic stimulus assignment allows you to implement sophisticated randomization schemes that control for confounds while maintaining the experimental manipulations you care about.