A Real Drum for a Browser Rhythm Game (ESP32 + Web Serial)
August 11, 2026·8 min read
There is a small game on this site called Rhythm Hands. It is a learning toy for my kid: notes scroll across a staff, a yellow playhead sweeps over them, and you tap in time. Blue notes are the left hand (spacebar), red notes are the right (enter), and there are touch pads for a tablet. It teaches quarter notes and ti-ti eighths without ever showing a child a settings screen.
Tapping a spacebar is fine. Hitting an actual drum is better. So this post gives the game a physical input: a piezo sensor glued to a drum surface, an ESP32 reading the knock, and the hit landing in the browser as if you'd pressed the key. No app rewrite, no cloud, no server. Field report, mistakes left in, because the mistakes are the useful part.
The Parts
- An ESP-WROOM-32 DevKit V1 (the cheap dual-core board with the CP2102 USB bridge). I also had a XIAO ESP32C3 on the bench, but the C3 has no GPIO34/35, and I'd already wired the sensor to 34, so the WROOM won.
- A HiLetgo piezoelectric vibration sensor module — a brass piezo disk on a little board, three pins:
S(signal),+,-. Passive-ish: a knock produces a voltage spike onS. - A USB cable, and a laptop running Chrome.
That is the entire bill of materials. The interesting decisions are all in software.
Why Web Serial, Not WiFi
The ESP32 can talk WiFi, so the obvious-looking design is: firmware POSTs hit events to a little local server, or opens a WebSocket to the browser. I didn't do that, and I'd argue you shouldn't either for this.
A drum hit is a latency-critical, false-trigger-sensitive event. Every hop you add — router, DHCP lease, a server process to babysit, mDNS name resolution, a socket that silently drops after the laptop sleeps — is a hop that adds jitter or a 3am "it just stopped working." For a thing a child whacks, "plug the USB in and it works" beats "make sure the ESP32 joined the 2.4GHz SSID and the server is running."
Web Serial (navigator.serial, Chrome/Edge) reads the USB serial port straight from the page. Sub-millisecond transport, no network, no server, and the debounce logic lives on the microcontroller where it belongs. The one cost is a user gesture — the browser makes you click a button and pick the port — which is a feature, not a bug, for anything touching hardware.
So the contract is dead simple. The firmware prints a line per hit:
L:1873and the page reads L and fires a blue hit. That's the whole protocol.
Wiring
HiLetgo piezo module ESP32 DevKit V1
S (signal) ---------> GPIO34 (ADC1, input-only)
- (GND) ---------> GND
+ (VCC) ---------> 3V3GPIO34 is one of the ESP32's input-only pins (GPIO34–39). They have no internal pull resistors and no output drivers, which is exactly what you want for an analog sensor — nothing fighting the piezo. Do not use an ADC2 pin (0, 2, 4, 12–15, 25–27) for this; ADC2 is unavailable whenever WiFi is active and behaves strangely even when it isn't.
One honest note I learned the hard way below: on this module, + to 3V3 matters. Skip it and the signal pin sits dead flat.
The Firmware
The whole job is: read the analog pin, decide a knock happened, and don't let one whack count as five. A struck piezo rings — one hit is a decaying burst of oscillation, not a single clean spike — so there are two pieces of debounce:
- A retrigger lockout: after an accepted hit, ignore the pin for 80ms so the ring counts once.
- A short peak scan: once we cross threshold, follow the signal for ~12ms to report its true peak. That number is only for tuning; the game ignores it.
struct DrumPad {
const char *id; // token the game reads: "L" (blue) or "R" (red)
uint8_t pin; // ADC-capable GPIO the sensor's signal is on
int threshold; // raw 12-bit value (0-4095) a hit must exceed
unsigned long lastHitAt; // millis() of last accepted hit (debounce)
};
// Only the connected pad is listed so an unconnected input-only pin
// can't pick up noise and fire phantom hits.
DrumPad pads[] = {
{ "L", 34, 350, 0 },
// { "R", 35, 350, 0 }, // uncomment once a red piezo is on GPIO35
};
const int padCount = sizeof(pads) / sizeof(pads[0]);
const unsigned long retriggerMs = 80; // min gap between hits on one pad
const unsigned long peakScanMs = 12; // how long to track the peak
void setup() {
Serial.begin(115200);
analogReadResolution(12); // 0-4095
for (int i = 0; i < padCount; i++) {
pinMode(pads[i].pin, INPUT);
analogSetPinAttenuation(pads[i].pin, ADC_11db); // full ~0-3.3V range
}
}
void loop() {
const unsigned long now = millis();
for (int i = 0; i < padCount; i++) {
DrumPad &pad = pads[i];
const int value = analogRead(pad.pin);
// below threshold, or still inside the post-hit lockout -> ignore
if (value <= pad.threshold || now - pad.lastHitAt < retriggerMs) continue;
// follow the spike briefly to capture its true peak
int peak = value;
const unsigned long started = millis();
while (millis() - started < peakScanMs) {
const int sample = analogRead(pad.pin);
if (sample > peak) peak = sample;
delayMicroseconds(300);
}
if (peak > pad.threshold) {
Serial.print(pad.id);
Serial.print(':');
Serial.println(peak); // e.g. "L:1873\n"
pad.lastHitAt = millis();
}
}
delay(1);
}Notice the second pad is commented out. I'm running one sensor for now, and an unconnected input-only GPIO floats — leave GPIO35 in the scan list with nothing on it and it'll happily invent red hits from electrical noise. List only what's wired. When the second disk goes on, uncomment one line and reflash.
I flashed it with the arduino-cli that ships inside Arduino IDE 2.x, so there was nothing new to install:
CLI="/c/Users/you/AppData/Local/Programs/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe"
"$CLI" compile --fqbn esp32:esp32:esp32 rhythm-hands-esp32-drum-pads
"$CLI" upload -p COM6 --fqbn esp32:esp32:esp32 rhythm-hands-esp32-drum-padsThe Receiver, in the Browser
This is the part I like: the game already speaks keypress and pointer events, so I didn't touch the game logic at all. Web Serial just calls the same press("L") the spacebar does.
Opening the port is a button click, at 115200 baud to match the sketch:
const connectSerial = useCallback(async () => {
if (typeof navigator === "undefined" || !("serial" in navigator)) {
setSerial({ status: "unsupported", message: "Use Chrome or Edge for ESP32 USB" })
return
}
const port = await navigator.serial.requestPort() // the user gesture
await port.open({ baudRate: 115200 })
serialPortRef.current = port
serialStopRef.current = false
setSerial({ status: "connected", message: "ESP32 live" })
readSerial(port)
}, [readSerial])The read loop decodes bytes and splits on newlines, buffering any partial line for next time — serial gives you a byte stream, not tidy messages, so you have to reassemble lines yourself:
const readSerial = useCallback(async (port) => {
const decoder = new TextDecoder()
while (port.readable && !serialStopRef.current) {
const reader = port.readable.getReader()
try {
while (!serialStopRef.current) {
const { value, done } = await reader.read()
if (done) break
serialBufferRef.current += decoder.decode(value, { stream: true })
const lines = serialBufferRef.current.split(/\r?\n/)
serialBufferRef.current = lines.pop() || "" // keep the partial line
lines.forEach(handleSerialLine)
}
} finally {
reader.releaseLock()
}
}
}, [handleSerialLine])And the parser is deliberately forgiving. It takes the first token off the line and maps a small vocabulary to a hand, so L, L:1873, LEFT, BLUE, 1 all mean the same thing. That way I can change the firmware's mind about wording later without touching the browser:
const handleSerialLine = useCallback((rawLine) => {
const line = rawLine.trim().toUpperCase()
if (!line) return
const [token] = line.split(/[\s,:;=]+/).filter(Boolean)
const hand =
token === "L" || token === "LEFT" || token === "BLUE" || token === "B" || token === "1" ? "L"
: token === "R" || token === "RIGHT" || token === "RED" || token === "2" ? "R"
: null
if (hand) press(hand) // exactly what the spacebar calls
}, [press])press("L") is the game's existing input path — it flashes the pad, finds the nearest unplayed note within the timing window, grades it perfect/great/ok, updates the combo. The drum is now indistinguishable from the keyboard as far as the game is concerned. That's the goal: the transport is a detail, the game doesn't know or care.
Two Bugs That Ate The Afternoon
It did not work on the first try. It never does. Both failures were the honest kind — the ones where the tool is telling you the truth and you're misreading it.
1. "Wrong boot mode detected (0x13)"
First upload:
A fatal error occurred: Failed to connect to ESP32: Wrong boot mode
detected (0x13)! The chip needs to be in download mode.These cheap DevKit clones often have a flaky auto-reset circuit (the little cap-and-transistor network that lets esptool drop the chip into the bootloader over DTR/RTS). When it doesn't work, you do it by hand. The trick that finally stuck: hold the BOOT button down through the entire Connecting...... phase, not the tap-EN-and-release dance. Holding IO0 low the whole time forces download mode even while esptool pulses EN. Once you see Writing at 0x... with a percentage, let go. Every reflash since has been the same little ritual.
2. The sensor that read a perfect, dead zero
This one was embarrassing and worth it. After flashing, I listened on the port and tapped. Nothing. Zero lines. I lowered the threshold — still nothing. I started doubting the wiring.
So I flashed a throwaway diagnostic sketch that just streams the raw ADC 20 times a second, and watched the number:
void loop() {
int peak = 0; unsigned long t = millis();
while (millis() - t < 50) { int s = analogRead(34); if (s > peak) peak = s; }
Serial.println(peak);
}Baseline: 0. Tapping: 0. A flat zero — not drifting noise, an actual grounded-looking zero. That is not "no signal reaching the pin," that's "the pin is being held at 0V." I went hunting for a short... and then realized the actual bug was two-layered:
- I hadn't connected
+to 3V3. This particular module needs power; without it the signal output just sits at rail-bottom. - And on the run where I thought I was proving it dead, I wasn't actually tapping during the capture window. A passive piezo idles at 0. A flat zero from an untouched sensor is the correct, expected reading. I had built a test that couldn't tell "broken" apart from "you didn't hit it."
Powered it, tapped for real, and the numbers told the whole story at once:
signal: 554
signal: 4095
signal: 733
signal: 2816
...
PEAK reading over 20s: 4095
non-zero(>20) samples: 303Idle floor of 0, firm hits railing the ADC at 4095, light taps landing in the few-hundreds. That's a gorgeous signal — and it also handed me the threshold for free. My initial guess of 550 was needlessly deaf; with a noise floor of literally zero there's enormous headroom, so I dropped it to 350 to catch lighter taps and never looked back. Reflashed the real sketch, listened again, and got clean L:871, L:1982, L:4095 lines pouring out with every hit.
The lesson I keep re-learning: when a sensor reads nothing, build the test that shows you the raw value before you start rewiring. Half the time the sensor is fine and your test is lying to you.
Where It Landed
Load the game in Chrome, click CONNECT ESP32, pick the port, and whack the drum. It fires the blue pad exactly like the spacebar — same timing window, same combo counter, same little green PERFECT.
It's blue-only for the moment, which covers the early one-hand levels. The two-handed levels want a second disk on GPIO35 and one uncommented line, and because the browser's parser already understands R, that's a firmware-only change. The nicer future version drops the hands entirely — one drum that just means "I hit now," letting the game pick whichever note is nearest. But that's a different post. This one just needed to make a real drum play a browser game, and it does.
Enjoyed this post? Give it a clap!
Comments