Reaction time is how long it takes from seeing something to doing something. In this capstone you build a game that measures it to the millisecond. Type your name on the leaderboard page, press the button to start, wait for the LED, and hit the button as fast as you can. Press too early and you are caught cheating. The best five times stay on the leaderboard, so the whole class or family can compete.
What you'll learn
- How to measure short times with
millis() - How to make a game fair with a random wait and a false-start rule
- How to keep a sorted top-five list
- How a web page and a physical game can share data
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- 1 LED and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- Button module (45-in-1 Sensor Kit)
- Active buzzer module (45-in-1 Sensor Kit)
- A 2.4 GHz Wi-Fi network
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| Button module S | ESP32 GPIO 27 |
| Button module − | GND |
| Button module + | Not connected |
| ESP32 GPIO 26 | 220 Ω resistor, then the LED's long leg |
| LED short leg | GND |
| Active buzzer module S | ESP32 GPIO 25 |
| Active buzzer module + and − | 3V3 and GND |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
How to play
- Open the address from the Serial Monitor on a phone or computer, type your name and tap Save.
- Press the button to start. The buzzer beeps once.
- Wait. After 2 to 5 seconds the LED lights up.
- Press the button as fast as you can. Your time appears on the page.
- Press before the LED lights and the buzzer gives a long beep: false start!
The sketch
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only). Open the Serial Monitor at 115200 baud to see the address, then open it in a browser on a phone or computer on the same Wi-Fi.
#include <WiFi.h>
#include <WebServer.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int BUTTON_PIN = 27;
const int LED_PIN = 26;
const int BUZZER_PIN = 25;
WebServer server(80);
String player = "Player 1";
String lastResult = "Press the button to start";
String names[5];
int times[5] = {0, 0, 0, 0, 0}; // 0 = empty place
const char PAGE[] = R"rawliteral(
<!DOCTYPE html><html><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'><title>Reaction game</title>
<style>
body { font-family: sans-serif; padding: 16px; max-width: 480px; margin: auto; }
#last { font-size: 28px; font-weight: bold; margin: 16px 0; }
li { font-size: 20px; margin: 6px 0; }
input, button { font-size: 18px; padding: 6px 10px; }
</style></head><body>
<h1>Reaction game</h1>
<p><input id='name' maxlength='12' placeholder='Your name'> <button onclick='save()'>Save</button></p>
<p>Now playing: <b id='player'></b></p>
<div id='last'></div>
<h2>Top 5</h2>
<ol id='top'></ol>
<script>
function save() {
const n = document.getElementById('name').value.trim();
if (n) fetch('/name?n=' + encodeURIComponent(n)).then(update);
}
async function update() {
const d = await (await fetch('/data')).json();
document.getElementById('player').textContent = d.player;
document.getElementById('last').textContent = d.last;
const top = document.getElementById('top');
top.innerHTML = '';
d.top.forEach(function (row) {
const li = document.createElement('li');
li.textContent = row.name + ' - ' + row.ms + ' ms'; // textContent: names can't break the page
top.appendChild(li);
});
}
setInterval(update, 1000);
update();
</script></body></html>
)rawliteral";
bool pressed() {
return digitalRead(BUTTON_PIN) == LOW;
}
void beep(int ms) {
digitalWrite(BUZZER_PIN, HIGH);
delay(ms);
digitalWrite(BUZZER_PIN, LOW);
}
// Waits while serving the web page; returns true if the button was pressed during the wait
bool waitAndWatch(unsigned long ms) {
unsigned long start = millis();
while (millis() - start < ms) {
server.handleClient();
if (pressed()) return true;
}
return false;
}
void addToTop5(const String& name, int ms) {
for (int i = 0; i < 5; i++) {
if (times[i] == 0 || ms < times[i]) {
for (int j = 4; j > i; j--) { // move the slower ones down
times[j] = times[j - 1];
names[j] = names[j - 1];
}
times[i] = ms;
names[i] = name;
return;
}
}
}
String jsonText(String s) {
s.replace("\\", "\\\\");
s.replace("\"", "\\\"");
return "\"" + s + "\"";
}
void playRound() {
lastResult = player + ": wait for the light...";
beep(80);
while (pressed()) server.handleClient(); // wait until the start press is released
// Random wait of 2 to 5 seconds; pressing now is a false start
if (waitAndWatch(random(2000, 5000))) {
lastResult = player + ": false start!";
beep(600);
while (pressed()) server.handleClient();
return;
}
digitalWrite(LED_PIN, HIGH);
unsigned long lightOn = millis();
while (!pressed()) {
server.handleClient();
if (millis() - lightOn > 3000) { // too slow: give up
digitalWrite(LED_PIN, LOW);
lastResult = player + ": too slow!";
return;
}
}
int reaction = millis() - lightOn;
digitalWrite(LED_PIN, LOW);
lastResult = player + ": " + String(reaction) + " ms";
addToTop5(player, reaction);
Serial.println(lastResult);
while (pressed()) server.handleClient();
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
randomSeed(esp_random()); // the ESP32's hardware random numbers
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.print("Open http://");
Serial.println(WiFi.localIP());
server.on("/", []() { server.send(200, "text/html", PAGE); });
server.on("/name", []() {
String n = server.arg("n");
n.trim();
if (n.length() > 0 && n.length() <= 12) player = n;
server.send(200, "text/plain", "ok");
});
server.on("/data", []() {
String json = "{\"player\":" + jsonText(player) + ",\"last\":" + jsonText(lastResult) + ",\"top\":[";
bool first = true;
for (int i = 0; i < 5; i++) {
if (times[i] == 0) continue;
if (!first) json += ",";
json += "{\"name\":" + jsonText(names[i]) + ",\"ms\":" + String(times[i]) + "}";
first = false;
}
json += "]}";
server.send(200, "application/json", json);
});
server.begin();
}
void loop() {
server.handleClient();
if (pressed()) {
delay(30); // debounce
if (pressed()) playRound();
}
}
How it works
The game measures time with millis(), the number of milliseconds since the ESP32 started. It stores the time when the LED lights, waits for the button, and subtracts. The wait before the LED is random, from 2 to 5 seconds, so players can't learn the rhythm; esp_random() uses real electrical noise inside the ESP32 to seed the random numbers. A press during the wait is a false start.
The leaderboard is two arrays, one for names and one for times, kept in order from fastest to slowest. A new time is compared with each place from the top; at the first slower one, everything below moves down one place and the new time goes in. The slowest of the five falls off the end.
While the game waits, it keeps calling server.handleClient(), so the web page stays responsive even during a round. The page reads /data once per second. Names are sent with jsonText(), which escapes quotes, and the page shows them with textContent, so a name full of strange characters can't break the page.
Try this next
- Add a two-player mode with a second button: whoever presses first after the light wins.
- Save the leaderboard with the Preferences library so it survives a restart.
- Show the average of your last five rounds.
- Use the buzzer instead of the LED as the signal, and compare: are you faster at hearing or seeing?
Troubleshooting
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.
- The page doesn't open: the phone or computer must be on the same Wi-Fi as the ESP32, not on mobile data.
-
Every round is a false start: your button module may read LOW when idle. Change
== LOWto== HIGHinpressed(), or connect the module's + pin to 3V3. - Times are always 0 or 1 ms: the button is bouncing or stuck. Check the button wiring.
-
The buzzer is always on: some modules switch on with LOW. Swap HIGH and LOW in
beep().