Build a reaction game with a web leaderboard

How fast are you? An LED lights after a random wait, you hit the button, and the ESP32 measures your reaction time and keeps a top-five leaderboard on a web page.
  • Level:Advanced
  • Duration: 1 h 40 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Build a reaction game with a web leaderboard – EEMENTOR STEM™ lesson

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

Breadboard wiring diagram: Build a reaction game with a web leaderboard
Breadboard view. Check each connection against the wiring table below.
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

  1. Open the address from the Serial Monitor on a phone or computer, type your name and tap Save.
  2. Press the button to start. The buzzer beeps once.
  3. Wait. After 2 to 5 seconds the LED lights up.
  4. Press the button as fast as you can. Your time appears on the page.
  5. 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 == LOW to == HIGH in pressed(), 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().

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit