About this tutorial

This tutorial will guide you step by step through programming a visual-search experiment in PsychoPy Coder. You will create a task in which participants search for a target object in a real-world scene, respond with the mouse, and save the behavioural data.

You can work through the tutorial with your group, and you are encouraged to help each other if you get stuck. The tutorial should take about two hours. Make sure to save your progress regularly.

By the end of the tutorial, you should have a better understanding of how experiments can be programmed in PsychoPy Coder and how Python code can be used to control stimuli, responses, timing, and data saving.

If you run into problems, first check your code carefully and compare it with the tutorial instructions. You can also ask members of your group, search the PsychoPy forum, or look up the issue online.

This tutorial is based on a PsychoPy Builder tutorial by Aylin Kallmayer, a former PhD student in the Scene Grammar Lab.

You can find more useful PsychoPy and Python resources here:



The Coder interface

Like the Builder interface, the Coder interface has several sub-windows (panes). The panel on the left represents the Source assistant, which lists all files in the current working directory (in the File Browser tab) and information about the Python modules in the current working directory specifically (in the Structure tab).

At the bottom of the Coder interface in the Shelf pane, you’ll find a so-called “Python shell”. You can think of it as a type of command line, but specifically for Python code. You can only run a single line at once, but it’ll show the result immediately.

This Python shell is very useful to debug or try out short code snippets. For example, if you forgot what the function len returns, you can for example run the command len([1, 2]) in the Python shell to find out.

Finally, the last pane in the middle is PsychoPy’s code editor. Here, you can open any plain-text file (not just Python files!) which you can modify and save. In practice, of course, you’ll probably mostly work with Python files in this editor. Like most code editors, the Psychopy code editor also does some code formatting and syntax highlighting.

Your turn!


Create a new Python file (File → New) and save it as VisualSearch.py.

Add some code to your VisualSearch.py file, e.g., print("Hello world"), and run the file.

After clicking the Run experiment button, the Experiment runner window should pop up, displaying something like the following:

#### Running:/Path/to/your/file/VisualSearch.py ####
Hello world
##### Experiment ended. #####

You do not have to use PsychoPy’s built-in Coder interface to write PsychoPy experiments. You can also use other code editors, such as Visual Studio Code, provided that the editor is configured to use the correct Python environment with PsychoPy installed. PsychoPy provides guidance for setting up Visual Studio Code here: https://psychopy.org/developers/environment/vscode.html



About the experiment

In this tutorial, you will create a visual-search experiment using real-world scenes. On each trial, participants will search for a specified target object in a scene. The target will either appear in a logical, or consistent, location within the scene, or in an inconsistent location. For example, if you were looking for an alarm clock in a bedroom, a consistent location would be next to the bed on the nightstand, whereas an inconsistent location would be on the pillow.

In a typical version of this experiment, we would record eye movements to examine how participants search the scene. Here, however, you will perform the search using the mouse.

The trial sequence is as follows. First, a small fixation cross appears at the top of the screen. Participants should “look” at this cross and press the spacebar to start the trial. The fixation cross is then replaced by the name of the target object. After a short pause, the scene appears. Participants should find the target object as quickly and accurately as possible and click on it with the mouse. After each trial, feedback shows the actual location of the target before the next trial begins.



Part 1: Laying the groundwork

Before we start creating the experiment itself, we need to set up the environment in which the experiment will run. This includes importing the required Python packages and collecting basic participant information at the beginning of the experiment.


Step 1.1: Import libraries

The first lines of code import the packages and modules we need for the experiment. Some of these come from PsychoPy, while others are standard Python packages or commonly used scientific packages.

The PsychoPy modules provide the main tools for running the experiment:

  • gui is used to create input boxes, for example to collect participant information.
  • visual is used to create and present visual stimuli.
  • core provides timing functions and allows us to quit the experiment.
  • event and keyboard are used to collect responses.
  • monitors is used to define the properties of the display, such as its physical size and viewing distance.

The remaining packages are used for general experiment management and data handling:

  • os and sys help with file paths and system-level commands.
  • datetime allows us to automatically get the current date.
  • numpy is used for numerical operations.
  • pandas is used for storing and organising data tables.
  • ast is used to safely convert strings into Python objects, for example when reading values from a file that should be interpreted as lists or dictionaries.
# ============================================================
# LIBRARIES
# ============================================================

from psychopy import gui, visual, core, event, monitors
from psychopy.hardware import keyboard

import os
import sys
from datetime import datetime
import numpy as np
import pandas as pd
import ast


Step 1.2: Creating an input box

At the beginning of many experiments, we need to provide the program with basic information about the current session. This usually includes information such as the participant ID, age, date of testing, gender, and handedness.

In Builder experiments, this kind of input box can be created in the Experiment Settings, under the Basic tab. In Coder experiments, the same functionality can be implemented using PsychoPy’s gui module.

The psychopy.gui module contains several classes for creating dialog boxes. One simple option is gui.Dlg(), which allows us to build an input box field by field.

For example, we can create a dialog box that asks for basic participant information before the experiment starts.

# ============================================================
# DIALOG BOX
# ============================================================

# get today's date as a string in ddmmyyyy format
today = datetime.now().strftime("%d%m%Y")

# create participant information dialog
participant = gui.Dlg(title="Visual Search Task", pos=(200,400));

# add input fields to the dialog
participant.addField(key = 0, label = 'Participant ID (s##):', initial=99, tip="starts with 0 for one-digit IDs");
participant.addField(key = 1, label = 'Age:', initial = 99, tip = "Years");
YOUR CODE HERE
YOUR CODE HERE
YOUR CODE HERE
Your turn!


Add fields for date, gender, and handedness to the input box.

The date field should already contain the current date when the input box appears.

The gender field should be a drop-down menu with the options Other, F, and M.

The handedness field should be a drop-down menu with the options R, L, and Ambidextrous.

Next, we want to show the input box. After the input box is shown, we check whether the experimenter clicked OK or Cancel. If Cancel is clicked, the experiment stops immediately.

# show the dialog box
# if the participant/experimenter presses Cancel, end the experiment.
participant.show();
if not participant.OK:
        core.quit()
        sys.exit()
Your turn!
Press Run to start the experiment and check whether the input box appears.

After the input box has been completed, the information entered by the experimenter is stored in participant.data. We then transfer these values into separate variables.

Each item in participant.data is accessed by its position in the input box. Python starts counting at 0, so participant.data[0] contains the first value entered, participant.data[1] contains the second value, and so on.

Some values are converted into integers using int(), such as the participant ID and age. Other values are converted into strings using str(), such as the date, gender, and handedness.

At this point, the participant information has been collected and stored in variables. These variables can later be saved together with the experimental data, ensuring that each data file is linked to the correct participant.

# get info
ID         = int(participant.data[0]) 
Age        = int(participant.data[1])
Date       = str(participant.data[2])
Gender     = str(participant.data[3]) 
Handedness = str(participant.data[4]) 


Step 1.3: Create the output folder

Before the experiment starts, we need to make sure that there is a clear place for saving the data. The code below first sets the working directory to the location of the experiment script. This ensures that relative paths are interpreted relative to the script rather than another folder on the computer.

The code then creates a folder called data. If this folder already exists, nothing changes. If it does not exist yet, it is created automatically using os.makedirs().

# ============================================================
# CREATE OUTPUT FOLDER
# ============================================================

# set the working directory to the folder containing this 
script_path = os.path.dirname(sys.argv[0])
if len(script_path) != 0:
    os.chdir(script_path)

# create folder if it doesn't already exist
datafolder  = 'data'
os.makedirs(datafolder, exist_ok=True)


Step 1.4: Set up the experiment window

Before we can present stimuli, we need to define the display on which the experiment will run.

First, scn_width_pix and scn_height_pix define the screen resolution in pixels. The variable retina specifies whether the experiment is running on a Retina display.

Next, we create a monitor profile called ExpWindow. This profile stores the physical properties of the display, including the screen width, viewing distance, and resolution. The screen width should refer to the visible display area in centimetres, not the diagonal screen size.

# ============================================================
# MONITOR AND WINDOW SETTINGS
# ============================================================

# screen resolution in pixels (change if using a different screen)
scn_width_pix, scn_height_pix = 1512, 982

# set to True for Retina displays, such as many MacBook screens
retina = True

# create a monitor profile
mon = monitors.Monitor('ExpWindow')
mon.setWidth(31.26)                             # screen width in cm
mon.setDistance(60)                             # viewing distance in cm
mon.setSizePix((scn_width_pix, scn_height_pix)) # resolution in pixels
mon.saveMon()                                   # save monitor
Your turn!


Before you run the experiment, check whether the monitor settings match your own setup.

Change the following values if needed:

  • scn_width_pix and scn_height_pix: set these to your screen resolution in pixels.
  • retina: set this to True if you are using a Retina display, and False otherwise.
  • mon.setWidth(31.26): replace 31.26 with the physical width of your visible screen area in centimetres.
  • mon.setDistance(60): replace 60 with the participant’s viewing distance in centimetres.

The next part creates the PsychoPy experiment window.

  • fullscr=True opens the experiment in fullscreen mode.
  • monitor=mon tells PsychoPy to use the monitor profile defined above.
  • winType='pyglet' specifies the window backend.
  • useRetina=retina tells PsychoPy whether to use Retina scaling.
  • checkTiming=True asks PsychoPy to check the screen timing, which is useful for experiments where accurate presentation times matter.
  • color=(-1, -1, -1) sets the background colour to black

The argument units='height' determines how positions and sizes are specified in the experiment window. With height units, the height of the window is treated as 1. This means that the centre of the screen is at (0, 0), the top of the screen is approximately 0.5, and the bottom of the screen is approximately -0.5. The horizontal range depends on the aspect ratio of the screen. For example, on a wider screen, the left and right edges will be further away from zero than -0.5 and 0.5.

We use height units in this tutorial because they scale with the height of the window. This means that the experiment should still look reasonable even if your window has slightly different dimensions from mine.

During testing and debugging, it is often better to set fullscr=False. When an experiment runs in fullscreen mode, there is no easy way to close the window unless you have already implemented a quit option in your experiment. For example, if you accidentally create an infinite loop, you may get stuck inside the PsychoPy window. For this reason, I’d recommend using fullscreen mode only once the experiment is finished and ready to run properly. At that point, set fullscr=True, because fullscreen mode can improve timing precision.

# create the PsychoPy experiment window
win = visual.Window(
    size=[scn_width_pix, scn_height_pix],
    fullscr=True,
    monitor=mon,
    winType='pyglet',
    units='height',
    useRetina=retina,
    checkTiming=True,
    color=(-1, -1, -1))
Your turn!


Try changing the background colour of the experiment window to grey. Then run the experiment and check whether the background colour has changed.

Hint: PsychoPy’s RGB values range from -1 to 1, not from 0 to 255.



Part 2: Setting up task parameters

Now that the general experiment setup is complete, we can define the parameters that are specific to this task. These include the timing of the experiment, the PsychoPy components we need, the condition file, and the images that will be shown during the trial loop.


Step 2.1: Define the timing of the experiment

Computer screens do not update continuously. Instead, they update, or ‘refresh’ a certain number of times per second. This is called the refresh rate. For example, a 60 Hz monitor updates the screen 60 times per second, so one frame lasts approximately 1/60 seconds, or about 16.7 ms.

In PsychoPy, the duration of one frame is stored in win.monitorFramePeriod. This value is already a duration in seconds. For example, if win.monitorFramePeriod is approximately 0.0167, this already corresponds to one frame on a 60 Hz display.

On some systems, especially MacBooks with variable refresh rate, PsychoPy may fail to measure a stable refresh rate. If this happens, first try setting your display to a fixed refresh rate in your system settings. On a Mac, go to System Settings → Displays → Refresh Rate and change it from ProMotion, Adaptive, or Variable to a fixed value, for example 60 Hz or 120 Hz.

# ============================================================
# TIMINGS
# ============================================================

# get duration of a frame (1/refresh rate)
frame_dur = win.monitorFramePeriod

Because the screen refreshes in discrete frames, stimulus durations also have to be implemented as whole numbers of frames. For example, a 60 Hz monitor refreshes the screen 60 times per second. This means that one frame lasts approximately 1/60 seconds, or about 16.67 ms. A stimulus can therefore only be shown for whole numbers of frames: 1 frame, 2 frames, 3 frames, and so on.

This means that some durations can be shown exactly, while others cannot. For example, on a 60 Hz monitor, 100 ms corresponds to 6 frames, because 6 × 16.67 ms = 100 ms. In contrast, 110 ms does not correspond exactly to a whole number of frames, so it would have to be rounded to the nearest possible frame duration.

For experiments where precise visual timing is important, it is useful to define stimulus durations in frames rather than only in seconds. To show a stimulus for 60 frames, for example, we draw the stimulus and flip the window 60 times. On a 60 Hz monitor, this corresponds to approximately 1 second.

In this experiment, we define the durations in seconds first and then convert them into frames. This makes the code easier to read, while still allowing PsychoPy to present stimuli for a whole number of screen refreshes.

# raw durations in seconds
SearchTargetDur   = 1 
FeedbackDur       = 1

# converted into frames
SearchTargetFrames = int(round(SearchTargetDur / frame_dur))
FeedbackFrames     = int(round(FeedbackDur / frame_dur))


Step 2.2: Create PsychoPy components

After setting up the experiment window and timing, we can create the components that will be shown or used during the experiment.

In PsychoPy, components are the building blocks of an experiment. They are the individual objects that make up what the participant sees and how the participant can respond. In Builder, these include text components, image components, keyboard components, and mouse components. In Coder, we create these components directly using PsychoPy classes.

Here, we create four visual components:

  • fixation: a text stimulus showing a fixation cross (+).
  • target_label: a text stimulus that will later be used to show the target label.
  • search_image: an image stimulus showing the visual search display.
  • target_box: a rectangle stimulus placed around the target object. It will be used as the area of interest for mouse-click responses and can later be shown as feedback to highlight the correct target location.

All visual components need to know which window they belong to. This is why each visual stimulus is created with win as the first argument. This tells PsychoPy where the stimulus should be drawn.

For example, visual.TextStim(win, ...) creates a text stimulus in the experiment window. The other arguments define properties of the stimulus, such as the text, size, position, and colour.

The position and size of each stimulus are defined using the unit system selected when creating the window. In this tutorial, we use units='height', so positions and sizes are relative to the height of the window.

Note that the search_image component is created using visual.ImageStim. Unlike a text stimulus, an image stimulus needs a path to an image file. Here, we provide an example image path when creating the component, even though the image itself will be updated later during the experiment.

# ============================================================
# PSYCHOPY COMPONENTS
# ============================================================

fixation     = visual.TextStim(win, text='+', height=0.1, pos=[0,0.4],color='white')
target_label = YOUR CODE HERE
search_image = visual.ImageStim(win,image='images/C_20CON.png',size=(1.2, 0.9))
target_box   = visual.Rect(win, width=0.2, height=0.2, pos=(0, 0), lineColor=None, fillColor=None,lineWidth=6)
Your turn!


The target_label component has not been created yet. Create this component now.

It should start with an empty text field, because the target label will be added later during the experiment.

We also create two response components:

  • kb: a keyboard object for collecting key presses.
  • mouse: a mouse object for collecting mouse responses.

The keyboard object is created using keyboard.Keyboard(). We will use this later to check whether the participant has pressed a key.

The mouse object is created using event.Mouse(win=win). Here, we provide the experiment window using win=win, so that PsychoPy can track the mouse position and clicks relative to the correct window.

kb          = keyboard.Keyboard()
mouse       = event.Mouse(win=win)


Step 2.3: Import condition file

Next, we import the condition file. This file tells PsychoPy which stimuli should be shown on each trial and where the target is located.

In this experiment, we have one independent variable, condition, with two levels: consistent and inconsistent. When you check the images folder, you will see there are two possible image versions: one in which the target appears in a consistent location and one in which the target appears in an inconsistent location. For example, the same scene may have a consistent version called C_4CON.png and an inconsistent version called C_4INCON.png.

Each participant completes both conditions overall, but they should not see both versions of the same scene. For example, a participant should not search through the same bathroom twice, once in the consistent condition and once in the inconsistent condition. To avoid this, the stimuli need to be counterbalanced across participants. We will take care of this later.

This is how the condition file looks like:

The condition file contains the information needed to do this. It has one row per scene and several columns:

  • stimulus: the base name of the scene stimulus, for example C_20.
  • condition_1: the condition assigned to one counterbalancing version.
  • condition_2: the alternative condition assigned to the other counterbalancing version.
  • stim_id: a numeric stimulus ID.
  • target: the name of the object the participant needs to search for.
  • position_con: the target position in the consistent version of the scene.
  • size_con: the size of the target box in the consistent version.
  • position_incon: the target position in the inconsistent version of the scene.
  • size_incon: the size of the target box in the inconsistent version.

The plan is to load this file, shuffle the trial order, and then loop through the rows one by one. On each trial, PsychoPy will use the information from the current row to decide which image to show, which target label to display, and where the target box should be placed.

# ============================================================
# CONDITIONS
# ============================================================

# load the trial file, each row corresponds to one trial
YOUR CODE HERE

# shuffle trial order and reset the row index
# reset_index(drop=True) makes sure the trial numbers run from 0 to ntrials-1
df = df.sample(frac=1).reset_index(drop=True)

# number of trials is determined by the number of rows in the trial file
ntrials = len(df)
Your turn!


Add the missing line of code to import the condition file condition_file.csv.

Store the imported file in a variable called df.


Step 2.4: Preload images

Before the experiment starts, we preload the images that may be shown during the trials.

Normally, PsychoPy would load an image file when it is needed. However, loading images during a trial can take time and may lead to timing problems. To avoid this, we load all possible images in advance and store them in a dictionary called image_cache.

A dictionary stores information as key-value pairs. Here, the key is the image path, for example images/C_20CON.png, and the value is the corresponding PsychoPy ImageStim object.

We loop through all rows of the condition file. Each row contains one scene, but there are two possible counterbalancing conditions: condition_1 and condition_2. We preload both versions because the version shown later depends on the participant ID.

For each row, the code builds the full image path by combining:

  • the image folder: images/
  • the stimulus name, for example C_20
  • the condition suffix, for example CON or INCON
  • the file ending: .png

This creates paths such as images/C_20CON.png or images/C_20INCON.png.

The line if path not in image_cache: checks whether the image has already been loaded. If it has not been loaded yet, PsychoPy creates an ImageStim and stores it in image_cache.

# ============================================================
# IMAGE PRELOADING
# ============================================================

image_cache = {}

# Loop over all rows in the trial file
for _, row in df.iterrows():

    # each trial has two possible counterbalancing conditions.
    # preload both, because which one is used depends on the subject ID.
    for cond_col in ['condition_1', 'condition_2']:
        # get the stimulus name, for example "C_20"
        stimulus = row['stimulus']
        # get the condition suffix, for example "CON" or "INCON"
        condition = row[cond_col]
        # build the full image path, for example "images/C_20CON.png"
        path = f"images/{stimulus}{condition}.png"
        # only load the image if it has not already been loaded before
        if path not in image_cache:
            image_cache[path] = visual.ImageStim(
                win,
                image=path,
                size=(1.2, 0.9))



Part 3: The trial loop

So far, we have created the experiment window, loaded the condition file, preloaded the images, and created the PsychoPy components. The next step is to put these pieces together in a trial loop.

A trial loop repeats the same basic trial structure multiple times. In this experiment, each row of the condition file corresponds to one trial. Therefore, we loop over the rows of the shuffled DataFrame.

The variable ntrials tells Python how many trials there are in total. Because we set ntrials = len(df) earlier, the loop will run once for every row in the condition file.

Importantly, everything that belongs to the trial loop must now be indented. In Python, indentation defines which lines belong inside the loop. Any line that should be repeated on every trial needs to be indented under:

# ============================================================
# TRIAL LOOP
# ============================================================

for trial in range(ntrials):


Step 3.1: Get the information for the current trial

Inside the loop, we first get the information for the current trial.

The expression df.iloc[trial] selects one row from the DataFrame. On the first loop iteration, it selects the first row; on the second loop iteration, it selects the second row; and so on. Because we shuffled the DataFrame earlier, the trials are presented in a random order.

    # --------------------------------------------------------
    # get current trial information from the shuffled data frame
    # --------------------------------------------------------
    
    this_trial  = df.iloc[trial]

Next, we get the target label for the current trial and assign it to the text component. Here, this_trial['target'] reads the target name from the condition file. We then update the text of target_label, so that the correct target is shown on this trial.

    # get and set the target label/text for this trial
    this_target = this_trial['target']
    target_label.text = str(this_target)

We also get the stimulus name from the condition file.

    # get the stimulus/image name from the trial file
    stimulus = this_trial['stimulus']

The experiment has two possible versions of each scene: a consistent version and an inconsistent version. However, a participant should only see one version of each scene. To achieve this, we counterbalance the condition assignment based on the participant ID.

    # counterbalance condition assignment based on subject ID
    # even subject IDs use condition_1, odd subject IDs use condition_2
    if YOUR CODE HERE:
        condition = YOUR CODE HERE 
    else:
        condition = YOUR CODE HERE
Your turn!


Complete the code so that even participant IDs use condition_1 and odd participant IDs use condition_2.

Hint: Use the modulo operator %. For example, ID % 2 returns the remainder after dividing ID by 2. This can be used to check whether a number is even or odd.

Next, we build the image path for the current trial.

For example, if stimulus is C_20 and condition is CON, the resulting path is: images/C_20CON.png

Instead of loading the image from the file during the trial, we retrieve the already preloaded image from image_cache. This helps avoid timing problems during the experiment.

    # build the image path and retrieve the preloaded image stimulus
    path = f"images/{stimulus}{condition}.png"
    search_image = image_cache[path]

Finally, we select the correct target-box position and size for the current condition. We will use this box to check whether the participant clicked on the target, and we will later show it as feedback after the participant responds.

The condition file stores positions and sizes as text. For example, a position may be stored as the string “(0.18, -0.15)”. PsychoPy cannot directly use this as a position, because it is still text. Therefore, we use ast.literal_eval() to convert the text into a Python tuple.

If the current trial is consistent, we use position_con and size_con. If the current trial is inconsistent, we use position_incon and size_incon.

    # --------------------------------------------------------
    # get target-box position and size for the current condition
    # --------------------------------------------------------
    if YOUR CODE HERE: 
        position = ast.literal_eval(this_trial['position_con'])
        size = ast.literal_eval(this_trial['size_con'])
    elif YOUR CODE HERE: 
        position = ast.literal_eval(this_trial['position_incon'])
        size = ast.literal_eval(this_trial['size_incon'])
Your turn!


Complete the code so that the correct target-box position and size are selected for the current condition.

The last three lines update the target_box component for the current trial. We set its position, set its size, and make its outline invisible during the search display by setting target_box.lineColor = None. We will come back to this later.

    # make the target box invisible during the search display
    target_box.setPos(position)
    target_box.setSize(size)
    target_box.lineColor = None


Step 3.2: Show fixation screen

Before the search display appears, we show a fixation cross. This gives the participant a clear starting point for each trial.

First, we move the mouse cursor to the fixation position using mouse.setPos(fixation.pos). This ensures that the mouse starts from the same location on every trial.

Next, we draw the fixation cross using fixation.draw().

    # --------------------------------------------------------
    # fixation screen: wait until participant presses space
    # --------------------------------------------------------
    
    # put the mouse cursor at fixation before the trial starts
    mouse.setPos(fixation.pos) 
    
    # draw fixation cross
    fixation.draw()
Your turn!


Run the experiment and check whether the fixation cross appears.

You may notice that the fixation cross does not appear, even though we call fixation.draw().

This is because PsychoPy separates drawing from showing. When we call fixation.draw(), the fixation cross is drawn to a hidden back buffer. This means that it is prepared, but not yet visible on the screen.

To make the drawn stimulus visible, we need to update the screen using win.flip(). Calling win.flip() moves the back buffer to the front, so that the participant can actually see what has been drawn.

This is also where the timing information from the previous section becomes important. The screen can only update when the monitor refreshes. Each call to win.flip() waits for the next screen refresh and then updates the visible display.

Your turn!


Add win.flip() directly after fixation.draw().

Then run the experiment again and check whether the fixation cross appears.

You may have noticed that the fixation cross was only shown very briefly. However, we want it to remain on the screen until the participant presses the space bar.

For this, we first clear any previous key presses using kb.clearEvents(). This prevents key presses from earlier parts of the experiment from accidentally starting the next trial.

Then, kb.waitKeys() pauses the experiment until the participant presses the space bar. Because nothing else happens while Python is waiting, the fixation cross stays visible on the screen.

    # clear previous key presses
    kb.clearEvents()

    # present fixation cross until key press
    kb.waitKeys(keyList=['space'], waitRelease=False)


Step 3.3: Show target label

After the participant starts the trial via the space-bar press, we show the target label. This tells the participant which object they should search for in the scene.

The target label was already set earlier in the trial loop using: target_label.text = str(this_target)

Now we draw this text stimulus to the screen. We want the target label to be presented for 1 second.

The for loop controls how long the target label is shown. Earlier, we converted the target-label duration from seconds into frames and stored it in SearchTargetFrames.

On each iteration of the loop, target_label.draw() draws the target label to the back buffer, and win.flip() updates the screen at the next refresh.

Because the back buffer is cleared after each flip, the target label needs to be drawn again on every frame. This is why target_label.draw() is inside the for loop.

The line mouse.setPos(fixation.pos) moves the mouse cursor back to the fixation position in case the participant has already started moving the mouse before the search display appears. This ensures that the search always starts from the same mouse position.

Once the loop has finished, the experiment continues to the next part of the trial.

    # --------------------------------------------------------
    # search label presentation
    # --------------------------------------------------------
    
    # present target for fixed number of frames
    for frameN in range(SearchTargetFrames):
        target_label.draw()
        mouse.setPos(fixation.pos) 
        win.flip()
Your turn!


Run the experiment and check whether the fixation cross appears, the space bar starts the trial, and the target label is presented correctly.


Step 3.4: Show search display

Next, we present the search display and wait for the participant to click on the target object.

At this point in the trial, the participant has already seen the target label, so they know which object they need to find. We now show the scene image and collect a mouse response.

We first draw the search_image and the target_box. The target_box is still invisible because we set its line colour to None earlier. Even though it is invisible, we keep drawing it because we will use its position and size later to check whether the participant clicked inside the correct target area.

    # --------------------------------------------------------
    # search display: present image and wait for mouse click
    # --------------------------------------------------------
    
    # prepare first search frame
    search_image.draw()
    target_box.draw()

Next, we reset the mouse timing exactly when the search image appears on the screen.

To do this, we use win.callOnFlip(mouse.clickReset). This tells PsychoPy to call mouse.clickReset() at the moment of the next screen flip. In other words, the mouse timing is reset at the same time as the search display becomes visible. This is important because response times should be measured from the onset of the search display, not from an earlier line in the script.

The following win.flip() then presents the search image and resets the mouse timing at the same moment.

    # clear previous mouse clicks and reset mouse timing
    win.callOnFlip(mouse.clickReset)
    win.flip()

After that, the while True: loop keeps checking for a mouse click while the search display is on the screen.

In this version, the search image has already been drawn and shown before the loop starts. The loop itself only checks whether the participant has clicked the mouse. This is enough because the display stays visible until the next win.flip() call.

The line mouse.getPressed(getTime=True) returns two pieces of information:

  • buttons: which mouse buttons are currently pressed.
  • times: the response times for those button presses.
    # check for mouse click while search image is presented
    while True:
        buttons, times = mouse.getPressed(getTime=True)
        if any(buttons):
            click_pos = mouse.getPos()
            button_idx = buttons.index(1)
            click_rt = times[button_idx]
            break

Within the loop, any(buttons) checks whether any mouse button has been pressed. If a button has been pressed, we save two things:

  • click_pos: the position of the mouse at the time of the click as an x/y coordinate pair.
  • click_rt: the response time, measured from the onset of the search display.

Because we allow any mouse button, we do not know in advance whether the participant clicked the left, middle, or right button. The variable buttons contains one value for each mouse button. A value of 1 means that the button is currently pressed, and a value of 0 means that it is not pressed.

For example: buttons = [0, 0, 1]

This means that the right mouse button was pressed. Python starts counting at 0, so the three positions in the list correspond to:

buttons[0] # left mouse button
buttons[1] # middle mouse button
buttons[2] # right mouse button

Because we allow any mouse button, we first need to find out which button was pressed. The line buttons.index(1) finds the first 1 in the buttons list and returns its index.

In this example: buttons = [0, 0, 1], buttons.index(1) returns 2, because the pressed button is at index 2. We save this index as button_idx: button_idx = buttons.index(1)

We then use the same index to get the matching response time from the times list:

times = [None, None, 0.732]
click_rt = times[button_idx]

Here, button_idx is 2, so click_rt = times[2]. This means that click_rt would be 0.732.

Finally, break exits the loop, so the experiment can continue to the next part of the trial.

Your turn!


Run the experiment and check whether the correct search images are presented after each target label.


Step 3.5: Show feedback

After the participant has clicked on the search image, we need to check whether the click was correct.

The invisible target_box defines the area around the target object. We can use the .contains() method to test whether the participant’s click position falls inside this box.

If the click was inside the target box, the response is counted as correct. We save this by setting accuracy = 1, and we set the feedback box colour to green.

If the click was outside the target box, the response is counted as incorrect. In this case, we set accuracy = 0, and the feedback box colour is set to red.

    # --------------------------------------------------------
    # accuracy check & feedback display
    # --------------------------------------------------------

    # check whether the click position was inside the invisible target box
    if target_box.contains(click_pos):
        accuracy = 1
        box_color = "green"
    else:
        accuracy = 0
        box_color = "red"

Next, we use the accuracy result to show feedback. The target box was invisible during the search display, but now we make it visible and colour it according to whether the click was correct.

If the participant clicked inside the target box, the box will be green. If they clicked outside the target box, it will be red.

We then present the search image together with the feedback box for a fixed number of frames. On each frame, we draw both stimuli and then call win.flip() to update the screen.

    # make target box visible and colour it according to accuracy
    YOUR CODE HERE
    
    # present search image plus feedback box for fixed number of frames
    for YOUR CODE HERE:
        YOUR CODE HERE
        YOUR CODE HERE
        YOUR CODE HERE
Your turn!


Complete the missing lines of code.

The feedback display should show the search image again, together with the target box. The target box should be visible and should be green for correct clicks and red for incorrect clicks.

Then run the experiment and check whether the feedback display works correctly.


Step 3.6: Save trial data

At the end of each trial, we save the relevant information into the data frame df.

Each row of the data frame corresponds to one trial. The variable trial tells Python which row we are currently writing to. For example, df.at[trial, "condition"] = condition saves the condition of the current trial in the column “condition”.

Some information, such as ID, age, date, gender, and handedness, is the same across trials. Other information, such as the click position, response time, and accuracy, can differ from trial to trial.

    # --------------------------------------------------------
    # Save trial data into data frame
    # --------------------------------------------------------
    
    df.at[trial, "ID"]         = ID
    df.at[trial, "Age"]        = Age
    df.at[trial, "Date"]       = Date
    df.at[trial, 'Gender']     = Gender
    df.at[trial, 'Handedness'] = Handedness
    df.at[trial, "condition"]  = condition
    YOUR CODE HERE
    YOUR CODE HERE
    YOUR CODE HERE
    YOUR CODE HERE
Your turn!


Complete the missing lines of code.

Save the horizontal click position, vertical click position, response time, and accuracy in the data frame.

You should save:

  • click_x
  • click_y
  • click_rt
  • accuracy



Part 4: End of experiment

After the trial loop has finished, the experiment is over. The following code should therefore no longer be indented. Code inside the loop runs once per trial; code outside the loop runs once after all trials are finished.


Step 4.1: Saving the data

We save the data frame as a .csv file. The data frame df now contains one row per trial, including the condition, click position, response time, and accuracy.

The output filename includes the participant ID: f"s{ID:02d}.csv"

The :02d part formats the ID with two digits. For example, participant 1 is saved as s01.csv, and participant 12 is saved as s12.csv.

# ============================================================
# SAVE DATA FILE AFTER EXPERIMENT
# ============================================================

outfile = os.path.join(datafolder, f"s{ID:02d}.csv")
df.to_csv(outfile, index=False)


Step 4.2: Qutting the experiment

When Python reaches the end of the script, the PsychoPy window may close automatically and the Python process may finish. However, in PsychoPy experiments it is better to quit explicitly.

First, we close the PsychoPy window using win.close(). Then we stop the experiment using core.quit(). This makes sure that PsychoPy exits cleanly and performs any necessary cleanup.

You should therefore include these lines at the very end of your script:

# ============================================================
# QUITTING THE EXPERIMENT
# ============================================================

win.close()
core.quit()
Your turn!


Run the experiment and check whether the data file is saved correctly.

After the experiment finishes, open the .csv file and make sure that each trial has been stored with the correct condition, click position, response time, and accuracy.



Part 5: Bonus

If you finished early, you can work on the bonus tasks below to extend or improve the experiment.

Bonus task #1


Add an escape key that allows the participant to quit the experiment early during the fixation screen.

Bonus task #2


Add a short break halfway through the experiment. During the break, show a short message and let the participant continue by pressing the space bar.

Bonus task #3


Add summary feedback at the end of the experiment. For example, show the participant their overall accuracy before closing the experiment.