What you will learn
- Organize the game using explicitly defined states.
- Preserve the beginning of a LOW interval qualified after 30 ms.
- Distinguish false starts, valid responses, and timeouts.
- Exclude OLED and Serial transfers from the timed part of each trial.
- Interpret the score and the limitations of a simple reaction timer.
Before you start
Complete courses 03, 04, and 11: a debounced button, millis() timing, and OLED display operation.
Reference setup
- Board in Arduino IDE
- ESP32 Dev Module
- Arduino-ESP32
- 3.3.12
- Serial Monitor
- 115200 baud
This is a reference profile, not an identification of the pictured board. Adapt the GPIO mapping and verify the circuit before using ESP32-C3/S2/S3 or a differently labelled board.
What we will build
Build a reaction game: press the button to prepare a trial, release it, and wait for the LED to light. Then press again. The OLED displays milliseconds and the best result since startup. An early press or missing response ends the trial without a new record.
This teaching game has button-sampling and timer limitations. It is not a calibrated instrument for assessing ability or health.
Learning objectives
- Organize an interaction as program states.
- Distinguish an input transition from a confirmed press.
- Preserve an event timestamp while debouncing continues.
- Handle early and late responses.
- Keep slow transfers outside the time-sensitive part of a game.
Prerequisites
Complete courses 03, 04, and 11: buttons, millis(), and OLED operation. Use a classic ESP32/ESP32-WROOM-32, rather than an arbitrary board from another ESP32 family. Check each component separately before combining them.
Equipment
| Component | Quantity | Specification and notes |
|---|---|---|
| Development board with a classic ESP32-WROOM-32 module | 1 | Match the reference GPIO labels; check physical header positions on the actual board. |
| USB data and power cable | 1 | Use the connector fitted to your board; the pictured kit lists Micro-USB. |
| Solderless breadboard | 1 | The kit lists 830 tie points. Check whether the power rails are split. |
| 0.96-inch OLED — reference I2C SSD1306 128×64 | 1 | VCC/GND/SDA/SCL, 3.3 V supply, onboard pull-ups to 3V3. The image does not confirm the controller, interface or resolution. |
| Momentary pushbutton | 1 | Use two terminals connected only when pressed; two legs on the same side may already be joined. |
| Red LED | 1 | Anode A and cathode K; identify polarity on the actual part. |
| 220 Ω resistor | 1 | One current-limiting resistor per LED branch; use the kit’s 220 Ω resistors. |
| Jumper wires | 9 | Use male-to-male or female-to-male leads to suit the board headers. Approximate quantity; depends on the physical layout. |
No transistor, relay, or buzzer is needed. The reference display is a four-pin I2C SSD1306 OLED, 128 × 64, compatible with 3.3 V power and logic. The photograph does not identify the controller or lead order. SH1106 and SPI variants need a different driver or connection. Adafruit — OLED library
Circuit and assembly order
Wiring diagram
All external components use 3.3 V. The reference display is a 128×64 I2C SSD1306 with suitable pull-ups; verify its controller and address. No additional transistor, diode or relay is required.
| From | To | Connection |
|---|---|---|
ESP32.3V3 | OLED1.VCC | Reference 128×64 I2C SSD1306 powered at 3.3 V. |
ESP32.GND | OLED1.GND | Common ground. |
ESP32.GPIO21 | OLED1.SDA | I2C data. |
ESP32.GPIO22 | OLED1.SCL | I2C clock. |
ESP32.GPIO27 | SW1.CONTACT_A | Button input configured with INPUT_PULLUP. |
SW1.CONTACT_B | ESP32.GND | Other terminal of the selected contact pair, connected when pressed. |
ESP32.GPIO25 | R1.1 | Digital output to a series 220 Ω resistor. |
R1.2 | D1.A | Resistor to the LED anode. |
D1.K | ESP32.GND | LED cathode to ground. |
- Disconnect USB. Connect OLED VCC to 3V3, GND to GND, SDA to GPIO21, and SCL to GPIO22.
- Connect GPIO25 through R1, 220 Ω, to the LED anode, and its cathode to GND.
- Choose button contacts that connect when pressed: one goes to GPIO27, the other to GND. Two legs already connected inside the button are not the correct pair.
- Check the common ground and ensure OLED pull-ups connect to 3.3 V. Then connect USB.
INPUT_PULLUP keeps the released input HIGH; pressing connects it to GND and produces LOW. GPIO labels describe signals, not physical header positions. Espressif — GPIO API
Arduino preparation
Use Arduino IDE 2.x, esp32 by Espressif Systems 3.3.12, ESP32 Dev Module, and 115200 baud. Install these libraries:
| Library | Version |
|---|---|
| Adafruit SSD1306 | 2.5.17 |
| Adafruit GFX Library | 1.12.6 |
| Adafruit BusIO | 1.17.4 |
Wire is included with the ESP32 core. Extract the Arduino ZIP, open reaction_game.ino from its matching folder, select the port, then run Verify and Upload.
| Library and release | Version | Dependencies |
|---|---|---|
| Adafruit SSD1306 | 2.5.17 | Adafruit GFX Library 1.12.6 |
| Adafruit GFX Library | 1.12.6 | Adafruit BusIO 1.17.4 |
| Adafruit BusIO | 1.17.4 | None |
Wire is included in the ESP32 core; do not install it separately through Library Manager.
Complete program
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
constexpr uint8_t LED_PIN = 25;
constexpr uint8_t BUTTON_PIN = 27;
constexpr uint8_t SDA_PIN = 21;
constexpr uint8_t SCL_PIN = 22;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint32_t DEBOUNCE_MS = 30;
constexpr uint32_t REACTION_WINDOW_MS = 3000;
Adafruit_SSD1306 display(128, 64, &Wire, -1, 100000UL, 100000UL);
// Each event is accepted once after 30 ms of stable input.
// If held during reset, a stable release is required before the first press.
struct DebouncedButton {
bool rawHigh = true;
bool stableHigh = true;
bool armed = true;
bool pressed = false;
bool released = false;
uint32_t rawChangedAt = 0;
uint32_t pressStartedAt = 0;
void begin(uint32_t now) {
rawHigh = stableHigh = digitalRead(BUTTON_PIN) == HIGH;
armed = rawHigh;
rawChangedAt = now;
pressed = released = false;
}
void update(uint32_t now) {
pressed = released = false;
const bool readingHigh = digitalRead(BUTTON_PIN) == HIGH;
if (readingHigh != rawHigh) {
rawHigh = readingHigh;
rawChangedAt = now;
}
if (rawHigh != stableHigh && static_cast<uint32_t>(now - rawChangedAt) >= DEBOUNCE_MS) {
stableHigh = rawHigh;
if (stableHigh) {
armed = true;
released = true;
} else if (armed) {
pressed = true;
pressStartedAt = rawChangedAt;
}
}
}
};
DebouncedButton button;
enum GameState { IDLE, RELEASE_TO_START, WAITING, GO, RESULT, FALSE_START, TIMEOUT };
GameState state = IDLE;
bool ready = false;
bool hasBest = false;
uint32_t stateSince = 0;
uint32_t waitDuration = 0;
uint32_t goAt = 0;
uint32_t reactionMs = 0;
uint32_t bestMs = 0;
void finishTrial(GameState outcome, uint32_t score);
void scanI2CBus() {
Serial.println("I2C scan: ACK identifies an address, not the display controller.");
uint8_t found = 0;
for (uint8_t address = 1; address < 127; ++address) {
Wire.beginTransmission(address);
if (Wire.endTransmission() == 0) {
Serial.print("ACK at 0x"); Serial.println(address, HEX);
++found;
}
}
if (found == 0) Serial.println("No I2C device acknowledged.");
}
bool initializeDisplay() {
if (!Wire.begin(SDA_PIN, SCL_PIN)) {
Serial.println("OLED ERROR: I2C initialization failed; reset to retry.");
return false;
}
Wire.setClock(100000);
Wire.setTimeOut(50);
scanI2CBus();
Wire.beginTransmission(OLED_ADDRESS);
if (Wire.endTransmission() != 0) {
Serial.println("OLED ERROR: configured address did not ACK; check wiring/address and reset.");
return false;
}
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS, false, false)) {
Serial.println("OLED ERROR: buffer initialization failed; reset to retry.");
return false;
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setTextWrap(false);
return true;
}
void renderScreen() {
display.clearDisplay();
display.setCursor(0, 0); display.print("Reaction game");
display.setCursor(0, 14);
if (state == IDLE) display.print("Press to start");
else if (state == RELEASE_TO_START) display.print("Release the button");
else if (state == WAITING || state == GO) {
display.print("Watch the LED");
display.setCursor(0, 26); display.print("Press when lit");
} else if (state == RESULT) {
display.print("Reaction: "); display.print(reactionMs); display.print(" ms");
} else if (state == FALSE_START) display.print("FALSE START");
else display.print("TIMEOUT");
display.setCursor(0, 40);
if (hasBest) { display.print("Best: "); display.print(bestMs); display.print(" ms"); }
else display.print("Best: --");
if (state == RESULT || state == FALSE_START || state == TIMEOUT) {
display.setCursor(0, 54); display.print("Press for next round");
}
display.display();
}
void startWaiting() {
randomSeed(micros());
waitDuration = static_cast<uint32_t>(random(2000L, 5001L));
digitalWrite(LED_PIN, LOW);
state = WAITING;
renderScreen(); // Complete this transfer before starting the timing window.
stateSince = millis();
}
void finishTrial(GameState outcome, uint32_t score) {
digitalWrite(LED_PIN, LOW);
state = outcome;
stateSince = millis();
if (state == RESULT) {
reactionMs = score;
if (!hasBest || score < bestMs) { bestMs = score; hasBest = true; }
Serial.print("Reaction_ms="); Serial.print(reactionMs);
Serial.print(" best_ms="); Serial.println(bestMs);
} else Serial.println(state == FALSE_START ? "FALSE START" : "TIMEOUT");
renderScreen();
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
pinMode(BUTTON_PIN, INPUT_PULLUP);
button.begin(millis());
ready = initializeDisplay();
if (!ready) return;
stateSince = millis();
renderScreen();
}
void loop() {
if (!ready) return;
const uint32_t now = millis();
button.update(now);
if (state == IDLE || state == RESULT || state == FALSE_START || state == TIMEOUT) {
if (button.pressed) {
state = RELEASE_TO_START;
stateSince = now;
renderScreen();
}
return;
}
if (state == RELEASE_TO_START) {
if (button.released) startWaiting();
return;
}
if (state == WAITING) {
if (button.pressed) { finishTrial(FALSE_START, 0); return; }
if (static_cast<uint32_t>(now - stateSince) >= waitDuration) {
digitalWrite(LED_PIN, HIGH);
goAt = millis();
state = GO;
}
return;
}
// During WAITING and GO, no display transfer or Serial output is performed.
// pressStartedAt is the first observed LOW of the final stable debounce interval.
if (button.pressed) {
const int32_t signedReaction = static_cast<int32_t>(button.pressStartedAt - goAt);
if (signedReaction < 0) finishTrial(FALSE_START, 0);
else if (static_cast<uint32_t>(signedReaction) <= REACTION_WINDOW_MS) {
finishTrial(RESULT, static_cast<uint32_t>(signedReaction));
} else finishTrial(TIMEOUT, 0);
return;
}
// A press beginning at exactly 3000 ms still receives its 30 ms qualification time.
if (static_cast<uint32_t>(now - goAt) >= REACTION_WINDOW_MS + DEBOUNCE_MS) {
finishTrial(TIMEOUT, 0);
}
}
Startup uses I2C at 100 kHz, scans addresses, and checks the OLED at OLED_ADDRESS = 0x3C. Set 0x3D only if the actual address is confirmed. An ACK does not identify an SSD1306 controller. If display preparation fails, the game does not begin and the LED remains off.
States and trial rules
| State | Behavior |
|---|---|
IDLE | A new press requests a trial. |
RELEASE_TO_START | Wait for a confirmed button release. |
WAITING | Keep the LED off during the selected delay. |
GO | Light the LED and evaluate response timing. |
RESULT | Display a valid result and the best result. |
FALSE_START | A confirmed press began before the cue. |
TIMEOUT | No valid response arrived within the window. |
A new press after a trial returns to release waiting. Holding the button is not another press. If it is held during startup, release it and press again.
Waiting begins after release
startWaiting() prepares the instruction to watch the LED before starting the wait clock. A micros() value at release seeds the generator; the selected wait is 2000–5000 ms, including both endpoints. This makes timing harder to anticipate, without claiming perfect or cryptographic randomness.
The OLED instruction stays unchanged during waiting and reaction. The LED is the cue. After the wait clock starts, OLED transfers and Serial reports pause until the trial ends. finishTrial() then switches off the LED, updates a valid record when appropriate, and calls renderScreen().
Preserving the press time
The button must remain stable for 30 ms before a change is accepted. The debouncer also retains the beginning of the LOW interval that eventually meets this condition, in button.pressStartedAt. If contacts bounce, the timestamp moves to the next LOW interval; it is not claimed to capture the first physical contact. Arduino — button debounce
When the LED lights, the program records goAt. If LOW begins 240 ms later and is confirmed another 30 ms afterwards, the score is approximately 240 ms, not 270 ms. Qualification decides whether to accept the press; the saved timestamp determines its score.
Early presses and the deadline
A press beginning before the cue remains a false start even when its 30 ms qualification ends after the LED lights. The program compares the LOW-start timestamp with goAt, instead of checking only the state at confirmation. A pulse shorter than the qualification time is not an accepted press.
REACTION_WINDOW_MS is 3000 ms. Another 30 ms is allowed to qualify a press beginning no later than the 3000 ms boundary. A score above 3000 ms is rejected; qualification time does not extend the valid reaction window. A confirmed event is processed before the timeout decision. Time differences also handle millis() wrapping past its maximum.
First run and experiment
Complete five normal trials, one deliberate early press, and one unanswered trial. Enter actual results in your log rather than prefilled “typical” times. Apparent improvement may reflect practice or anticipation; one best attempt does not describe all your responses.
| Trial | Action | Expected behavior | Your observation |
|---|---|---|---|
| 1–5 | Press only after the LED lights | Valid score; update the record when smaller | Complete |
| 6 | Press during waiting | FALSE_START; preserve the record | Complete |
| 7 | Do not press after the cue | TIMEOUT; preserve the record | Complete |
| 8 | Hold the button after a result | No automatic repeat | Complete |
The bestMs record belongs only to the current power/reset session. Resetting clears it; no persistent storage is used.
Troubleshooting
| Problem | Check |
|---|---|
| The game keeps waiting for release | Check the contact pair, GPIO27, and a short to ground. |
| LED never lights | Check GPIO25, R1, polarity, and successful OLED setup. |
| The screen seems frozen during a trial | This is intentional; watch the LED rather than changing text. |
| False start appears after the LED lights | The press may have begun before the cue and qualified later. |
| A short tap is ignored | Acceptance requires a stable LOW interval of 30 ms. |
| OLED does not respond | Check address, SDA/SCL, 3.3 V supply, and controller. |
Independent challenge
Reduce the permitted response to 2000 ms while keeping 30 ms qualification. Explain results for LOW edges at −10, +240, +1995, and +2001 ms relative to the LED cue. Assume each edge remains stable long enough. These are constructed timing examples.
Worked answers and limitations
Set REACTION_WINDOW_MS = 2000, retain DEBOUNCE_MS = 30, and calculate the final waiting boundary from their sum. −10 ms is a false start even when confirmed at +20 ms. +240 ms scores 240 ms. +1995 ms qualifies at +2025 ms and is accepted. +2001 ms is outside the window and produces no valid score.
Why save the raw timestamp? It avoids adding the full debounce interval to the result. Nevertheless, the loop polls at a finite rate, millis() has finite resolution, and contacts bounce. Displaying milliseconds is an approximate result of this implementation, not proof of one-millisecond accuracy.
Downloads
Keep an observation log with the program version. Record actual compilation, physical-board checks, and your trial results separately.
