In the quiz buzzer lesson two players had a button each. But what if the whole class wants to play? Every phone has a screen and a touch button, so we use those. The ESP32 runs a small web server on your Wi-Fi. Players open its page on their phones, type a name and get a big red BUZZ button. Whoever presses first wins the right to answer: their name appears on the LCD and every phone, the light comes on and the buzzer sounds. The quiz master presses the green button for a correct answer (one point, next question) or the red one for a wrong answer (that player is out for this question and the others can buzz again).
What you'll learn
- How the ESP32 serves a web page and answers requests from many phones
- How a page in the browser talks to the ESP32 with fetch() and JSON
- How to decide fairly who was first
- How to keep the game state (players, scores, who buzzed) in arrays
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- 1602 LCD display, 10 kΩ potentiometer and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- 2 push buttons (ESP32 IoT Starter Kit)
- Green LED and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- Passive buzzer (ESP32 IoT Starter Kit)
- Phones, tablets or laptops for the players, on the same Wi-Fi
- The printed parts from the 3D printing box below (podium), or the cardboard version described there
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| LCD VSS, RW and K | GND |
| LCD VDD | 5V |
| LCD V0 | Middle pin of the potentiometer (outer pins to 5V and GND) |
| LCD RS, E | ESP32 pins 19, 23 |
| LCD D4, D5, D6, D7 | ESP32 pins 18, 17, 16, 4 |
| LCD A (backlight +) | 220 Ω resistor, then 5V |
| "Correct" button | ESP32 pin 32 and GND |
| "Wrong" button | ESP32 pin 33 and GND |
| ESP32 pin 25 | 220 Ω resistor, then the green LED's long leg |
| LED short leg | GND |
| Passive buzzer +, − | ESP32 pin 26, GND |
The LCD is wired exactly as in the AI chat buddy. It runs on 5V, but only listens to the ESP32, so its 5V signals never reach the ESP32's pins.
The sketch
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only). WebServer, ESPmDNS and LiquidCrystal come with the ESP32 board package.
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <LiquidCrystal.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
LiquidCrystal lcd(19, 23, 18, 17, 16, 4); // RS, E, D4, D5, D6, D7
const int CORRECT_PIN = 32, WRONG_PIN = 33, LED_PIN = 25, BUZZER_PIN = 26;
const int MAX_PLAYERS = 8;
WebServer server(80);
String names[MAX_PLAYERS];
int scores[MAX_PLAYERS];
bool out[MAX_PLAYERS]; // answered wrong: can't buzz again this question
int players = 0;
int winner = -1; // who buzzed first, -1 = nobody yet
int question = 1;
bool lastCorrect = HIGH, lastWrong = HIGH;
// The page every player sees. It asks the ESP32 for the game state every 0.4 s.
const char PAGE[] PROGMEM = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Quiz buzzer</title>
<style>body{font-family:Arial,sans-serif;text-align:center;background:#1B3A7B;color:#fff;margin:0;padding:20px}
input,button{font-size:22px;padding:12px 18px;border-radius:12px;border:0}
#buzz{width:240px;height:240px;border-radius:50%;background:#DE544A;color:#fff;font-size:44px;font-weight:bold;
margin:24px auto;display:block;box-shadow:0 10px 0 #9E2F27}
#buzz:active{transform:translateY(6px);box-shadow:0 4px 0 #9E2F27}
#buzz:disabled{background:#6B7280;box-shadow:0 10px 0 #374151}
#msg{font-size:22px;min-height:30px}table{margin:16px auto;font-size:20px}td{padding:4px 12px}</style></head><body>
<div id="join"><h1>Quiz show</h1><p>Your name</p><input id="name" maxlength="10"><p><button onclick="join()">Join</button></p></div>
<div id="game" style="display:none"><h2 id="me"></h2><button id="buzz" onclick="buzz()">BUZZ</button>
<div id="msg"></div><table id="scores"></table></div>
<script>
var id = localStorage.getItem('quizId');
function join() {
var n = document.getElementById('name').value.trim();
if (!n) return;
fetch('/join?name=' + encodeURIComponent(n)).then(function (r) { return r.text(); }).then(function (t) {
if (t === 'full') { alert('The game is full'); return; }
id = t; localStorage.setItem('quizId', id); start();
});
}
function buzz() { fetch('/buzz?id=' + id); }
function start() {
document.getElementById('join').style.display = 'none';
document.getElementById('game').style.display = 'block';
poll(); setInterval(poll, 400);
}
function poll() {
fetch('/state?id=' + id).then(function (r) { return r.json(); }).then(function (s) {
if (s.me < 0) { localStorage.removeItem('quizId'); location.reload(); return; }
document.getElementById('me').textContent = s.name + ': ' + s.score + ' points';
document.getElementById('buzz').disabled = s.winner !== '' || s.out;
var m = 'Question ' + s.q + ': buzz when you know it!';
if (s.out) m = 'Wrong answer. Wait for the next question.';
if (s.winner !== '') m = s.winner === s.name ? 'You were first! Answer now.' : s.winner + ' was first';
document.getElementById('msg').textContent = m;
var h = '';
for (var i = 0; i < s.table.length; i++) h += '<tr><td>' + s.table[i][0] + '</td><td>' + s.table[i][1] + '</td></tr>';
document.getElementById('scores').innerHTML = h;
});
}
if (id) start();
</script></body></html>)rawliteral";
String clean(String s) { // letters, digits and spaces only, at most 10
String o;
for (unsigned int i = 0; i < s.length() && o.length() < 10; i++) {
char c = s[i];
if (isalnum(c) || c == ' ') o += c;
}
o.trim();
return o.length() ? o : String("Player");
}
int playerId() { // the id in the request, or -1
if (!server.hasArg("id") || server.arg("id").length() == 0) return -1;
int id = server.arg("id").toInt();
return (id >= 0 && id < players) ? id : -1;
}
void lcdLine(int row, String text) {
while (text.length() < 16) text += ' ';
lcd.setCursor(0, row);
lcd.print(text.substring(0, 16));
}
void showLcd() {
if (players == 0) {
lcdLine(0, "Join: quiz.local");
lcdLine(1, WiFi.localIP().toString());
return;
}
lcdLine(0, winner >= 0 ? "Q" + String(question) + " " + names[winner] + "!" : "Q" + String(question) + " Buzz now!");
int best = 0; // the leader and their score
for (int i = 1; i < players; i++) if (scores[i] > scores[best]) best = i;
lcdLine(1, "Top: " + names[best] + " " + String(scores[best]));
}
void nextQuestion() {
question++;
winner = -1;
for (int i = 0; i < players; i++) out[i] = false;
digitalWrite(LED_PIN, LOW);
showLcd();
}
void handleJoin() {
String n = clean(server.arg("name"));
for (int i = 0; i < players; i++) { // same name again (e.g. a reloaded page): same player
if (names[i] == n) { server.send(200, "text/plain", String(i)); return; }
}
if (players >= MAX_PLAYERS) { server.send(200, "text/plain", "full"); return; }
names[players] = n;
scores[players] = 0;
out[players] = false;
server.send(200, "text/plain", String(players));
players++;
Serial.println(n + " joined");
showLcd();
}
void handleBuzz() {
int id = playerId();
if (id >= 0 && winner < 0 && !out[id]) { // the first valid buzz wins
winner = id;
digitalWrite(LED_PIN, HIGH);
tone(BUZZER_PIN, 880, 400);
Serial.println(names[id] + " buzzed first");
showLcd();
}
server.send(200, "text/plain", "ok");
}
void handleState() {
int id = playerId();
String j = "{\"me\":" + String(id);
if (id >= 0) j += ",\"name\":\"" + names[id] + "\",\"score\":" + String(scores[id]) + ",\"out\":" + (out[id] ? "true" : "false");
j += ",\"winner\":\"" + (winner >= 0 ? names[winner] : String("")) + "\",\"q\":" + String(question) + ",\"table\":[";
bool listed[MAX_PLAYERS] = {false};
for (int k = 0; k < players; k++) { // highest score first
int b = -1;
for (int i = 0; i < players; i++) if (!listed[i] && (b < 0 || scores[i] > scores[b])) b = i;
listed[b] = true;
if (k) j += ",";
j += "[\"" + names[b] + "\"," + String(scores[b]) + "]";
}
j += "]}";
server.send(200, "application/json", j);
}
bool pressed(int pin, bool& last) { // true once per press
bool now = digitalRead(pin);
bool p = last == HIGH && now == LOW;
last = now;
if (p) delay(30);
return p;
}
void setup() {
Serial.begin(115200);
pinMode(CORRECT_PIN, INPUT_PULLUP);
pinMode(WRONG_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
lcd.begin(16, 2);
lcdLine(0, "Connecting...");
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
MDNS.begin("quiz"); // players can open http://quiz.local
server.on("/", []() { server.send_P(200, "text/html", PAGE); });
server.on("/join", handleJoin);
server.on("/buzz", handleBuzz);
server.on("/state", handleState);
server.begin();
Serial.print("Players open http://quiz.local or http://");
Serial.println(WiFi.localIP());
showLcd();
}
void loop() {
server.handleClient();
if (pressed(CORRECT_PIN, lastCorrect) && winner >= 0) {
scores[winner]++;
tone(BUZZER_PIN, 1319, 150);
nextQuestion();
}
if (pressed(WRONG_PIN, lastWrong)) {
if (winner >= 0) { // that player is out, the others can buzz again
out[winner] = true;
winner = -1;
digitalWrite(LED_PIN, LOW);
tone(BUZZER_PIN, 200, 400);
showLcd();
} else {
nextQuestion(); // nobody buzzed: skip to the next question
}
}
// Hold both buttons for 3 seconds to start a new game
if (digitalRead(CORRECT_PIN) == LOW && digitalRead(WRONG_PIN) == LOW) {
unsigned long held = millis();
while (digitalRead(CORRECT_PIN) == LOW && digitalRead(WRONG_PIN) == LOW) {
if (millis() - held > 3000) {
players = 0;
question = 0;
nextQuestion();
lcdLine(0, "New game!");
delay(1500);
showLcd();
break;
}
delay(10);
}
}
}
Play it
- Upload the sketch. The LCD shows Join: quiz.local and the ESP32's address, for example
192.168.1.57. - Every player connects their phone to the same Wi-Fi and opens
http://quiz.local. If that doesn't work on a phone, type the number from the LCD instead. - Each player types a name and taps Join. Their phone now shows the big BUZZ button.
- Read out a question. The first player to buzz gets the light, the sound and their name on every screen.
- Press the green button if the answer is right: one point, and everyone can buzz on the next question. Press red if it is wrong: that player is out for this question and the others can buzz. Red with nobody buzzed skips the question. Hold both buttons for 3 seconds for a new game.
How it works
The ESP32 is a tiny web server. When a phone asks for /, it sends the player page, which is stored in the sketch as one long text. Everything else is fetch() requests from that page: /join adds a player and returns their number, /buzz says "I pressed", and /state asks for the latest situation. The ESP32 answers /state with JSON, which the page uses to update the name, the button, the message and the score table. Each phone asks every 0.4 seconds, so all screens stay in step.
Who was first? The ESP32 handles one request at a time. The first /buzz it receives while winner is still -1 wins, and every later one is simply ignored. On a home network the delays are a few hundredths of a second, so the fastest finger nearly always wins, but a phone with a weak Wi-Fi signal can lose by a hair.
The page remembers its player number in localStorage, so reloading the page doesn't create a second player. If the ESP32 restarts and forgets everyone, /state answers "me":-1 and the page goes back to the join screen. Names are cleaned to letters, digits and spaces, so nobody can break the page with strange characters.
Try this next
- Add a 10-second answer timer: if the player who buzzed doesn't answer in time, they are out automatically.
- Give a point to everyone except the wrong player when an answer is wrong, for a more forgiving game.
- Add a quiz master page at
/hostwith "correct" and "wrong" buttons, so you can run the game from your own phone. - Show the top three on the LCD by scrolling the second line.
Troubleshooting
-
quiz.local doesn't open: some phones and older Android versions don't understand
.localnames. Type the number shown on the LCD instead, withhttp://in front. - The page opens but the button does nothing: the phone may have switched to mobile data. Check that it is on the same Wi-Fi as the ESP32.
- A player appears twice: they joined with a different name from a second browser. Hold both buttons for 3 seconds to start a new game.
- The LCD shows only blocks: turn the contrast potentiometer slowly from one end to the other.
- 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.
- A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.