Breadboard reaction timer game with OLED display, push button, buzzer and ESP32-C3

Beginner

Build a Reaction Timer Game

DisplaysGamesInputs

Wait for the signal, then hit the button as fast as you can -- a small display and a stopwatch built entirely out of your own code.

7 real parts included

Total for this kit: 1,800.00 ฿

Doesn't include any order discount you may get at checkout.

What you'll need

Project Goal

This project builds a simple reaction-time game: wait for a signal, then hit the button as fast as possible, with the result shown on a small display. Underneath the game is a stopwatch built entirely out of your own code -- no dedicated timer hardware involved.

What the Controller Does

The controller runs the entire game: it waits a random delay, sounds the buzzer as the 'go' signal, records the exact moment that happens, watches for the button press, records that moment too, and calculates the difference between the two timestamps as the reaction time.

What the Display Does

The OLED display's only job is showing the current game state -- a countdown or 'wait' indicator, then the final reaction time once the round ends. It has no logic of its own; the controller decides everything it shows and triggers a redraw at each state change.

What the Buzzer and Button Do

The buzzer provides the 'go' signal the player is reacting to, and the push button is how the player actually responds. Between them they define the two timestamps the whole game is built around -- when 'go' happened, and when the player reacted to it.

The Stopwatch Concept

The actual game logic is just a stopwatch with two events. First, after a random delay (long enough, and varied enough, that a player can't reliably anticipate it), the controller sounds the buzzer and immediately records that timestamp. Second, when the button is pressed, the controller records a second timestamp. The reaction time shown on the display is simply the difference between the two -- there's no dedicated timer chip doing this work, it's entirely a matter of the controller reading its own clock at the right two moments.

Software and Library Concepts

This project relies more on program logic than any single library: a state machine (waiting, ready, go, showing result) governs what the controller does at each stage, while the OLED library only needs simple text-drawing calls to show the countdown state and the final number. Get the timing logic working with output printed over serial first, before wiring up the display, to confirm the stopwatch itself is accurate before worrying about how it looks.

Testing and Troubleshooting

  • Timer always reads zero or a nonsense value -- check that your code is actually capturing both timestamps at the right moments, rather than displaying a variable that was never updated.
  • Button doesn't register a press reliably -- this is a classic mechanical-button debounce issue; check the wiring first, and consider adding a brief software debounce delay if presses are still inconsistent.
  • Display never updates -- confirm it's wired correctly and that your code is actually calling a screen-refresh function on every state change, not just when the program starts.
  • Player can predict the 'go' signal -- widen the random delay range; a narrow or short delay window makes the timing more guessable than genuinely reactive.

Optional Future Upgrade: Two-Player Reaction Race

Once the single-player version is solid, adding a second button and comparing two players' reaction times on the same round is a natural next step -- the same stopwatch logic just needs to track two timestamps per round instead of one.

Wiring

  • OLED VCC → ESP32-C3 SuperMini 3.3V
  • OLED GND → ESP32-C3 SuperMini GND
  • OLED SDA → GPIO8 (`SDA_PIN`) — ESP32-C3 default I2C SDA
  • OLED SCL → GPIO9 (`SCL_PIN`) — ESP32-C3 default I2C SCL
  • Active Buzzer signal/+ → GPIO4 (`BUZZER_PIN`)
  • Active Buzzer GND → ESP32-C3 SuperMini GND
  • Push Button leg 1 → GPIO3 (`BUTTON_PIN`)
  • Push Button leg 2 → ESP32-C3 SuperMini GND

The button uses `INPUT_PULLUP` so it reads LOW when pressed — no external resistor needed. The sketch debounces the button in software and requires the player to fully release the button before a new round can start, which naturally prevents holding it down to cheat.

Wiring Diagram

VCC → 3.3V GND SDA → GPIO8 SCL → GPIO9 signal/+ → GPIO4 GND leg 1 → GPIO3 leg 2 → GND ESP32-C3 SuperMini OLED Active Buzzer Push Button

Starter Code

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SDA_PIN 8      // ESP32-C3 default I2C SDA - adjust if your board differs
#define SCL_PIN 9      // ESP32-C3 default I2C SCL - adjust if your board differs
#define BUTTON_PIN 3   // Tactile button, other leg to GND - any digital input GPIO
#define BUZZER_PIN 4   // Active buzzer signal - any digital output GPIO

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_ADDRESS 0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

enum GameState { IDLE, GET_READY, ARMED, GO, SHOW_RESULT };
GameState state = IDLE;

unsigned long stateEnteredAt = 0;
unsigned long randomDelay = 0;
unsigned long goTime = 0;

bool rawPressed() {
  return digitalRead(BUTTON_PIN) == LOW; // INPUT_PULLUP: LOW = pressed
}

// Returns true once, only on the release -> press transition (debounced).
bool pressedEdge() {
  static bool lastStable = false;
  static unsigned long lastChange = 0;
  bool reading = rawPressed();
  if (reading != lastStable && millis() - lastChange > 30) {
    lastStable = reading;
    lastChange = millis();
    return lastStable; // true only on the press edge
  }
  return false;
}

void showMessage(const char* line1, const char* line2) {
  display.clearDisplay();
  display.setTextSize(2);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 10);
  display.println(line1);
  display.setTextSize(1);
  display.setCursor(0, 44);
  display.println(line2);
  display.display();
}

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(BUZZER_PIN, LOW);

  Wire.begin(SDA_PIN, SCL_PIN);
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
    Serial.println("SSD1306 init failed - check wiring/address");
    while (true) delay(1000);
  }

  randomSeed(analogRead(0)); // floating ADC pin as an entropy source
  showMessage("Reaction Timer", "Press to start");
}

void loop() {
  bool edge = pressedEdge();

  switch (state) {
    case IDLE:
      if (edge) {
        showMessage("Get ready...", "");
        state = GET_READY;
        stateEnteredAt = millis();
      }
      break;

    case GET_READY:
      // brief pause so the player lets go of the button before arming
      if (millis() - stateEnteredAt > 500) {
        randomDelay = random(1500, 5000); // 1.5-5s random wait before GO
        stateEnteredAt = millis();
        state = ARMED;
      }
      break;

    case ARMED:
      if (rawPressed()) {
        showMessage("Too early!", "Press to retry");
        digitalWrite(BUZZER_PIN, HIGH);
        delay(200);
        digitalWrite(BUZZER_PIN, LOW);
        state = SHOW_RESULT;
      } else if (millis() - stateEnteredAt >= randomDelay) {
        showMessage("GO!", "");
        digitalWrite(BUZZER_PIN, HIGH);
        goTime = millis();
        state = GO;
      }
      break;

    case GO:
      if (millis() - goTime > 150) {
        digitalWrite(BUZZER_PIN, LOW); // stop the short GO beep after 150ms
      }
      if (rawPressed()) {
        unsigned long reactionMs = millis() - goTime;
        char line1[16];
        snprintf(line1, sizeof(line1), "%lu ms", reactionMs);
        showMessage(line1, "Press to retry");
        Serial.printf("Reaction time: %lu ms\n", reactionMs);
        state = SHOW_RESULT;
      }
      break;

    case SHOW_RESULT:
      if (edge) {
        showMessage("Get ready...", "");
        state = GET_READY;
        stateEnteredAt = millis();
      }
      break;
  }
}

Not sure about a step? Ask us