Pi Pico Reaction Time Tester

by malcolmtb11 in Circuits > Raspberry Pi

152 Views, 2 Favorites, 0 Comments

Pi Pico Reaction Time Tester

Screenshot 2026-05-25 at 10.16.29 am.png

In this project, you will make a reaction time game/test, using a Raspberry Pi Pico. The game tests how fast you can react to a signal (the word 'GO' displayed on a screen) by measuring your response time once clicked a button. This is set up for 3 attempts each round, showing a reaction time after each attempt.


Research

https://www.instructables.com/DIY-Pi-Pico-Reaction-Light-Game/

This shows a simple game, similar to mine, using a screen and buttons to record the time of the participant, this one however is a 2 player game.


https://www.tomshardware.com/how-to/raspberry-pi-pico-reaction-game

This one is a reaction timer, which also incorporates a buzzer, which rings when the timer starts rather than displaying a message on a screen. Similar idea to mine.


https://www.instructables.com/Fatleys-Pi-Pico-Reaction-Time-Game/

This one is a simple two player setup, using a screen for directions and two buttons, one per person.

Supplies

Bill of Materials

Raspberry Pi Pico x1

16x2 LCD Screen x1

Push Button x1

Wires


Tools

Computer

Thonny Program

Wiring Diagram

Screenshot 2026-05-24 at 5.32.19 pm.png

Follow along with this diagram for your wiring, the necessary pins have been labeled on the Pi Pico.

Wiring the Screen

Screenshot 2026-05-25 at 10.58.41 am.png

Following the wiring diagram, wire the LCD screen to the Pico. Keep in mind the order for the wires of the LCD screen to the Pico.


The screen should have 4 pins, with 4 wires connecting into the Pico. On the screen, the GND pin should go to the top ground pin on the top right of the Pico, or 3rd pin from the top (based off the wiring diagram orientation). VCC should connect to VBUS, or the top right pin, SDA to GP4, the 6th left pin from the top, and SCL to GP5, the 7th left pin from the top.

Wiring the Button

Screenshot 2026-05-25 at 10.32.25 am.png

Following the wiring diagram, now wire the button to the Pico.


The button should have one pin connected to 3V3, or the 5th pin down on the right go the Pico (based off the wiring diagram orientation) and the diagonal opposite from the 3V3 of the button should be wired to GP14, or the second bottom pin on the left.


While testing on the breadboard, you can move the wires further across the breadboard, as long as they still line up with the button to give yourself more room, making clicking the button easier.

Screen Code

Here is all the code to run it. Save this first piece of code in Thonny, by clicking new command. Save this code to the Pico, as lcd_api.py, this is the first bit of code that will run the screen.


import time

class LcdApi:

# Implements the API for talking with HD44780 compatible character LCDs.
# This class only knows what commands to send to the LCD, and not how to get
# them to the LCD.
#
# It is expected that a derived class will implement the hal_xxx functions.
#
# The following constant names were lifted from the avrlib lcd.h header file,
# with bit numbers changed to bit masks.

# HD44780 LCD controller command set
LCD_CLR = 0x01 # DB0: clear display
LCD_HOME = 0x02 # DB1: return to home position

LCD_ENTRY_MODE = 0x04 # DB2: set entry mode
LCD_ENTRY_INC = 0x02 # DB1: increment
LCD_ENTRY_SHIFT = 0x01 # DB0: shift

LCD_ON_CTRL = 0x08 # DB3: turn lcd/cursor on
LCD_ON_DISPLAY = 0x04 # DB2: turn display on
LCD_ON_CURSOR = 0x02 # DB1: turn cursor on
LCD_ON_BLINK = 0x01 # DB0: blinking cursor

LCD_MOVE = 0x10 # DB4: move cursor/display
LCD_MOVE_DISP = 0x08 # DB3: move display (0-> move cursor)
LCD_MOVE_RIGHT = 0x04 # DB2: move right (0-> left)

LCD_FUNCTION = 0x20 # DB5: function set
LCD_FUNCTION_8BIT = 0x10 # DB4: set 8BIT mode (0->4BIT mode)
LCD_FUNCTION_2LINES = 0x08 # DB3: two lines (0->one line)
LCD_FUNCTION_10DOTS = 0x04 # DB2: 5x10 font (0->5x7 font)
LCD_FUNCTION_RESET = 0x30 # See "Initializing by Instruction" section

LCD_CGRAM = 0x40 # DB6: set CG RAM address
LCD_DDRAM = 0x80 # DB7: set DD RAM address

LCD_RS_CMD = 0
LCD_RS_DATA = 1

LCD_RW_WRITE = 0
LCD_RW_READ = 1

def __init__(self, num_lines, num_columns):
self.num_lines = num_lines
if self.num_lines > 4:
self.num_lines = 4
self.num_columns = num_columns
if self.num_columns > 40:
self.num_columns = 40
self.cursor_x = 0
self.cursor_y = 0
self.implied_newline = False
self.backlight = True
self.display_off()
self.backlight_on()
self.clear()
self.hal_write_command(self.LCD_ENTRY_MODE | self.LCD_ENTRY_INC)
self.hide_cursor()
self.display_on()

def clear(self):
# Clears the LCD display and moves the cursor to the top left corner
self.hal_write_command(self.LCD_CLR)
self.hal_write_command(self.LCD_HOME)
self.cursor_x = 0
self.cursor_y = 0

def show_cursor(self):
# Causes the cursor to be made visible
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR)

def hide_cursor(self):
# Causes the cursor to be hidden
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY)

def blink_cursor_on(self):
# Turns on the cursor, and makes it blink
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR | self.LCD_ON_BLINK)

def blink_cursor_off(self):
# Turns on the cursor, and makes it no blink (i.e. be solid)
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR)

def display_on(self):
# Turns on (i.e. unblanks) the LCD
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY)

def display_off(self):
# Turns off (i.e. blanks) the LCD
self.hal_write_command(self.LCD_ON_CTRL)

def backlight_on(self):
# Turns the backlight on.

# This isn't really an LCD command, but some modules have backlight
# controls, so this allows the hal to pass through the command.
self.backlight = True
self.hal_backlight_on()

def backlight_off(self):
# Turns the backlight off.

# This isn't really an LCD command, but some modules have backlight
# controls, so this allows the hal to pass through the command.
self.backlight = False
self.hal_backlight_off()

def move_to(self, cursor_x, cursor_y):
# Moves the cursor position to the indicated position. The cursor
# position is zero based (i.e. cursor_x == 0 indicates first column).
self.cursor_x = cursor_x
self.cursor_y = cursor_y
addr = cursor_x & 0x3f
if cursor_y & 1:
addr += 0x40 # Lines 1 & 3 add 0x40
if cursor_y & 2: # Lines 2 & 3 add number of columns
addr += self.num_columns
self.hal_write_command(self.LCD_DDRAM | addr)

def putchar(self, char):
# Writes the indicated character to the LCD at the current cursor
# position, and advances the cursor by one position.
if char == '\n':
if self.implied_newline:
# self.implied_newline means we advanced due to a wraparound,
# so if we get a newline right after that we ignore it.
pass
else:
self.cursor_x = self.num_columns
else:
self.hal_write_data(ord(char))
self.cursor_x += 1
if self.cursor_x >= self.num_columns:
self.cursor_x = 0
self.cursor_y += 1
self.implied_newline = (char != '\n')
if self.cursor_y >= self.num_lines:
self.cursor_y = 0
self.move_to(self.cursor_x, self.cursor_y)

def putstr(self, string):
# Write the indicated string to the LCD at the current cursor
# position and advances the cursor position appropriately.
for char in string:
self.putchar(char)

def custom_char(self, location, charmap):
# Write a character to one of the 8 CGRAM locations, available
# as chr(0) through chr(7).
location &= 0x7
self.hal_write_command(self.LCD_CGRAM | (location << 3))
self.hal_sleep_us(40)
for i in range(8):
self.hal_write_data(charmap[i])
self.hal_sleep_us(40)
self.move_to(self.cursor_x, self.cursor_y)

def hal_backlight_on(self):
# Allows the hal layer to turn the backlight on.
# If desired, a derived HAL class will implement this function.
pass

def hal_backlight_off(self):
# Allows the hal layer to turn the backlight off.
# If desired, a derived HAL class will implement this function.
pass

def hal_write_command(self, cmd):
# Write a command to the LCD.
# It is expected that a derived HAL class will implement this function.
raise NotImplementedError

def hal_write_data(self, data):
# Write data to the LCD.
# It is expected that a derived HAL class will implement this function.
raise NotImplementedError

def hal_sleep_us(self, usecs):
# Sleep for some time (given in microseconds)
time.sleep_us(usecs)


Save this second bit of code, in a new command in Thonny as pico_i2c_lcd.py, this is the other piece of code to run the screen.


import time

class LcdApi:

# Implements the API for talking with HD44780 compatible character LCDs.
# This class only knows what commands to send to the LCD, and not how to get
# them to the LCD.
#
# It is expected that a derived class will implement the hal_xxx functions.
#
# The following constant names were lifted from the avrlib lcd.h header file,
# with bit numbers changed to bit masks.

# HD44780 LCD controller command set
LCD_CLR = 0x01 # DB0: clear display
LCD_HOME = 0x02 # DB1: return to home position

LCD_ENTRY_MODE = 0x04 # DB2: set entry mode
LCD_ENTRY_INC = 0x02 # DB1: increment
LCD_ENTRY_SHIFT = 0x01 # DB0: shift

LCD_ON_CTRL = 0x08 # DB3: turn lcd/cursor on
LCD_ON_DISPLAY = 0x04 # DB2: turn display on
LCD_ON_CURSOR = 0x02 # DB1: turn cursor on
LCD_ON_BLINK = 0x01 # DB0: blinking cursor

LCD_MOVE = 0x10 # DB4: move cursor/display
LCD_MOVE_DISP = 0x08 # DB3: move display (0-> move cursor)
LCD_MOVE_RIGHT = 0x04 # DB2: move right (0-> left)

LCD_FUNCTION = 0x20 # DB5: function set
LCD_FUNCTION_8BIT = 0x10 # DB4: set 8BIT mode (0->4BIT mode)
LCD_FUNCTION_2LINES = 0x08 # DB3: two lines (0->one line)
LCD_FUNCTION_10DOTS = 0x04 # DB2: 5x10 font (0->5x7 font)
LCD_FUNCTION_RESET = 0x30 # See "Initializing by Instruction" section

LCD_CGRAM = 0x40 # DB6: set CG RAM address
LCD_DDRAM = 0x80 # DB7: set DD RAM address

LCD_RS_CMD = 0
LCD_RS_DATA = 1

LCD_RW_WRITE = 0
LCD_RW_READ = 1

def __init__(self, num_lines, num_columns):
self.num_lines = num_lines
if self.num_lines > 4:
self.num_lines = 4
self.num_columns = num_columns
if self.num_columns > 40:
self.num_columns = 40
self.cursor_x = 0
self.cursor_y = 0
self.implied_newline = False
self.backlight = True
self.display_off()
self.backlight_on()
self.clear()
self.hal_write_command(self.LCD_ENTRY_MODE | self.LCD_ENTRY_INC)
self.hide_cursor()
self.display_on()

def clear(self):
# Clears the LCD display and moves the cursor to the top left corner
self.hal_write_command(self.LCD_CLR)
self.hal_write_command(self.LCD_HOME)
self.cursor_x = 0
self.cursor_y = 0

def show_cursor(self):
# Causes the cursor to be made visible
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR)

def hide_cursor(self):
# Causes the cursor to be hidden
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY)

def blink_cursor_on(self):
# Turns on the cursor, and makes it blink
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR | self.LCD_ON_BLINK)

def blink_cursor_off(self):
# Turns on the cursor, and makes it no blink (i.e. be solid)
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR)

def display_on(self):
# Turns on (i.e. unblanks) the LCD
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY)

def display_off(self):
# Turns off (i.e. blanks) the LCD
self.hal_write_command(self.LCD_ON_CTRL)

def backlight_on(self):
# Turns the backlight on.

# This isn't really an LCD command, but some modules have backlight
# controls, so this allows the hal to pass through the command.
self.backlight = True
self.hal_backlight_on()

def backlight_off(self):
# Turns the backlight off.

# This isn't really an LCD command, but some modules have backlight
# controls, so this allows the hal to pass through the command.
self.backlight = False
self.hal_backlight_off()

def move_to(self, cursor_x, cursor_y):
# Moves the cursor position to the indicated position. The cursor
# position is zero based (i.e. cursor_x == 0 indicates first column).
self.cursor_x = cursor_x
self.cursor_y = cursor_y
addr = cursor_x & 0x3f
if cursor_y & 1:
addr += 0x40 # Lines 1 & 3 add 0x40
if cursor_y & 2: # Lines 2 & 3 add number of columns
addr += self.num_columns
self.hal_write_command(self.LCD_DDRAM | addr)

def putchar(self, char):
# Writes the indicated character to the LCD at the current cursor
# position, and advances the cursor by one position.
if char == '\n':
if self.implied_newline:
# self.implied_newline means we advanced due to a wraparound,
# so if we get a newline right after that we ignore it.
pass
else:
self.cursor_x = self.num_columns
else:
self.hal_write_data(ord(char))
self.cursor_x += 1
if self.cursor_x >= self.num_columns:
self.cursor_x = 0
self.cursor_y += 1
self.implied_newline = (char != '\n')
if self.cursor_y >= self.num_lines:
self.cursor_y = 0
self.move_to(self.cursor_x, self.cursor_y)

def putstr(self, string):
# Write the indicated string to the LCD at the current cursor
# position and advances the cursor position appropriately.
for char in string:
self.putchar(char)

def custom_char(self, location, charmap):
# Write a character to one of the 8 CGRAM locations, available
# as chr(0) through chr(7).
location &= 0x7
self.hal_write_command(self.LCD_CGRAM | (location << 3))
self.hal_sleep_us(40)
for i in range(8):
self.hal_write_data(charmap[i])
self.hal_sleep_us(40)
self.move_to(self.cursor_x, self.cursor_y)

def hal_backlight_on(self):
# Allows the hal layer to turn the backlight on.
# If desired, a derived HAL class will implement this function.
pass

def hal_backlight_off(self):
# Allows the hal layer to turn the backlight off.
# If desired, a derived HAL class will implement this function.
pass

def hal_write_command(self, cmd):
# Write a command to the LCD.
# It is expected that a derived HAL class will implement this function.
raise NotImplementedError

def hal_write_data(self, data):
# Write data to the LCD.
# It is expected that a derived HAL class will implement this function.
raise NotImplementedError

def hal_sleep_us(self, usecs):
# Sleep for some time (given in microseconds)
time.sleep_us(usecs)

Main Code

Here is the code for the main system, this is what makes the game run. Open a new command in Thonny and save this code as main.py


from machine import Pin, I2C
from time import sleep, ticks_ms, ticks_diff
from pico_i2c_lcd import I2cLcd

# -------- LCD SETUP ----------
I2C_ADDR = 0x27 # Change if your LCD uses a different address
I2C_NUM_ROWS = 2
I2C_NUM_COLS = 16

i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
lcd = I2cLcd(i2c, I2C_ADDR, I2C_NUM_ROWS, I2C_NUM_COLS)

# -------- BUTTON SETUP ----------
button = Pin(14, Pin.IN, Pin.PULL_DOWN)

# -------- FUNCTIONS ----------
def show(msg, delay=2):
lcd.clear()
lcd.putstr(msg)
sleep(delay)

def wait_for_press():
"""Waits until button is pressed."""
while button.value() == 0:
pass
sleep(0.02) # debounce

def reaction_attempt(num):
# Show attempt number
show(f"Attempt {num}", 2)

lcd.clear()
sleep(2)

# Show GO and start timer
lcd.clear()
lcd.putstr("GO")
start = ticks_ms()

# Wait for button press
wait_for_press()
end = ticks_ms()

reaction_time = ticks_diff(end, start)

# Show reaction time
lcd.clear()
lcd.putstr("Time: " + str(reaction_time) + " ms")
sleep(2)

# -------- MAIN PROGRAM ----------

# Intro screens
show("Reaction Time Test", 3)
show("You will get 3 attempts", 3)
show("click button when 'GO' shows", 3)

# Three attempts
reaction_attempt(1)
reaction_attempt(2)
reaction_attempt(3)

# Ending screens
show("Reaction time complete", 3)
lcd.clear()
sleep(0.5)
show("Thank you for participating", 5)

lcd.clear()