Justinas Petkauskas

← back to projects

Hybrid Quizbowl Buzzers

ESP-IDF, ESP-NOW, KiCAD, Fusion · 2026

GitHub

This project uses three ESP32 based boards communicating via a unidirectional ESP-NOW link for robust, low latency Quizbowl lockout arbitration. A custom autopairing algorithm eliminates hardcoded MAC addresses and improves usability. The receiver also runs a SoftAP web server which hosts a scoring terminal for the reader, digitizing and simplifying tedious quizbowl scoring and adding analysis capability. Synchronized buzzer timing system coordinates transmitter nodes based on receiver time, arbitrating winner based on incoming timestamps so that near-simultaneous buzzes aren’t dropped, reaching 50/50 performance on true-simultaneous buzzes, 5/95 on 25 μs deltas, and 100% correct arbitration for deltas >=50 μs. (See test results below.)

I built these buzzers to replace my high school team’s aging wired set. The hybrid approach—wireless team stations with wired individual clickers—is more convenient than fully wired systems and scales more efficiently than fully wireless approaches.

Buzzer receiver, transmitter, and one clicker.
Reader console.

Development

This project started sometime in the last two years. Every Friday morning, my high school quiz bowl team would meet for casual play and practice. Unfortunately, our limited time was often consumed by the cumbersome setup of our wired buzzer set, a functional but inconvenient 90s relic. This meant that our “buzzes” often devolved into slapping the table and arguing about who “buzzed” first on close prompts.

Around the same time, I was building my ESP32 based MP3 player which introduced me to the robust capabilities afforded by the ESP32 series. I decided that as a quick follow up to use up my remaining S3’s, I would quickly build a custom buzzer system that I could give our team as a replacement for the old set.

While the original plan was to implement a fully wireless setup where all eight players buzzed on wireless nodes, that approach would distribute the issue’s core complexity—robust wireless communication—to nine total nodes, while requiring power overhead at each node. Instead, I had a different idea. Because most teams sit close together during practice and play, the real inconvenience isn’t the player-to-player wiring, but rather the transmitter-to-receiver wiring. This drove the ultimate architectural choice of having one centralized receiver with two transmitting nodes which host four “dumb” clickers each. The clicker itself is just a cherry MX switch wired to a 3.5 mm cable packaged in a 3D printed handle. The actual transmitting and accompanying overhead lives in the transmitter box. This makes the system orders of magnitude less complicated while only losing marginal convenience.

Of course, what I thought would be a quick project soon escalated to become one of my largest, second only to the MP3 itself. Technically speaking, the architecture should have been straightforward. Three two-layer boards with a few passives and maybe 100 lines of firmware was all that was required to get the breadboarded prototype doing something buzzer-like.

While my primary pitfall building the MP3 player was poor design choices, the reason this project took so long was because of ballooning scope—and that isn’t entirely a bad thing. The original parameters were for a unidirectional hard-coded ESP-NOW link between two transmitters and one receiver written in Arduino IDE firmware. But once I reached each milestone, I decided I could add a bit more just to make the system more functional—and that’s how the project I anticipated shipping in January was only done in June.

A lot of that scope expansion was enabled by agentic coding. In total, I went on two Claude Code campaigns while building the buzzers. For me, deciding to open Claude Code in my VSCode window was a tortured decision that took more consideration than it was worth. Put simply, my gut wants me to be able to take full credit for every line of code. But the tradeoff is real—my first agentic campaign implemented in a week what likely would’ve taken me an entire summer to learn and ultimately made the buzzers a better product for their end users. I settled at a happy medium: I wrote all the logic that I saw as essential for the core original scope of the project, and outsourced some of the extras. Ultimately, the firmware’s architecture and autopairing remained original, while the webserver and arbitration synchronization were built agentically.

The end result is a product that beats all current commercial products that I am aware of on cost and features. (Most cost more than $200, while this set can definitely be built for less than $100 and is the only lockout system with a natively-hosted reader console.) If you are interested in building or buying one of these, please reach out.

Technical Highlights

Many of my design choices were driven by the initial irrational drive to minimize complexity as much as possible. I wanted to outsource as much overhead as possible. This led to some interesting design choices—principally among them being the 9V battery. My struggles integrating LiPo’s while building the MP3 player made me want to avoid LiPo’s on a product that I was going to actually ship to non-technical users who wouldn’t want to hit RST if the MCU bricked because of low battery voltage. Instead of building around the advantages of a LiPo, I decided to use USB-C rechargeable 9V batteries. This was actually a choice I am pretty happy about. From the user’s perspective, this just adds a small step between each use, while circumventing protection IC’s, fuel gauges, dozens of passives, and extra complexity.

Autopairing

The autopairing algorithm works in three steps.

  1. When the receiver powers on, it pulses a standard ESP-NOW packet with its own MAC address and special handshake values.

    pairing_complete = xSemaphoreCreateBinary(); // semaphore for espnow callback
    
    esp_now_register_recv_cb(pairing_recv_callback);
    
    memcpy(peer.peer_addr, DEFAULT_MAC_VALUE, 6);
    esp_now_add_peer(&peer);
    esp_now_send(DEFAULT_MAC_VALUE, (uint8_t *)&handshake, sizeof(handshake));
    
    while(xSemaphoreTake(pairing_complete, pdMS_TO_TICKS(2500)) != pdTRUE) // blocks here until receiver pairing beacon is received
    {
      esp_now_send(DEFAULT_MAC_VALUE, (uint8_t *)&handshake, sizeof(handshake));
    }
  2. When each transmitter receives the handshake, it confirms the handshake and copies the receiver’s MAC into memory.

    received_sem = xSemaphoreCreateBinary(); // semaphore for espnow callback
    
    esp_now_register_recv_cb(on_recv);
    xSemaphoreTake(received_sem, portMAX_DELAY); // blocks here until receiver pairing beacon is received
    
    // Defining receiver MAC
    memcpy(peer.peer_addr, receiver_mac, 6);
    esp_now_add_peer(&peer);
  3. One player from each team buzzes, sending a confirmation packet back to the receiver and enrolling each transmitter—with its unique MAC—as team 0 and team 1.

    void pairing_recv_callback(const esp_now_recv_info_t *info, const uint8_t *data, int len)
    {
      static uint8_t caller_index = 0;
    
      uint8_t incoming_mac[6];
      memcpy(incoming_mac, info->src_addr, 6);
    
      if ((caller_index != 0) && (memcmp(incoming_mac, transmitter_mac_addresses[caller_index - 1], 6) != 0))
      {
        memcpy(transmitter_mac_addresses[caller_index], incoming_mac, 6);
    
        caller_index++;
        transmitter_b_paired = true;
      }
      else if (caller_index == 0)
      {
        memcpy(transmitter_mac_addresses[0], incoming_mac, 6);
    
        caller_index++;
        transmitter_a_paired = true;
      }
    
      if (transmitter_a_paired && transmitter_b_paired)
      {
        // pairing complete sequence
        xSemaphoreGive(pairing_complete);
      }
    }

Webserver

The webserver is probably the coolest part about this whole project for me. Essentially, the SoftAP hosted webserver allows the reader to read packets, score the match, export detailed results, and update firmware all from a web browser. It is built on the ESP32’s well-documented local webserving hosting abilities, and runs on the receiver’s ESP without significantly decreasing battery life. Check out a dummy demo of the interface on my GitHub.

Synchronized arbitration

After doing a final test of the system’s lockout fairness, I wasn’t super happy with the arbitration profile. Essentially, radio jitter and wireless overhead meant my first-to-the-receiver approach was accurate for obvious win conditions—cycles where one team buzzes well before the other—but failed to arbitrate close calls consistently. When two players buzzed near-simultaneously—a condition very common in competitive quizbowl matches—arbitration would be influenced by non-deterministic radio delays. Close buzzes could not be adjudicated fairly.

Average Winner vs. Delay scatter plot
In this scenario, team one receives a synthetic delay of n ms created with a PMOS testing jig. Average winner across twenty trials is ideally team zero for the case where delay > 0, representing a decisive victory. However, the average winner decreases linearly from the 50/50 base zero-delay case, reaching decisive 0/100 levels with only around 5 ms (5000 μs) of delay.
PMOS testing jig
simultaneous jig injection
100 μs delay injection
Zero-delay vs. 100 μs (.1 ms) injected delay from test jig
injected delay vs actual delay
Injected delay has very little correlation to observed packet separation, suggesting constant 5 ms ESP-NOW consecutive-packet processing overhead.

The change involves arbitrating the buzzes based on deterministic timing. The synchronization algorithm works in three steps:

  1. On each CLEAR cycle, triggered from webserver or physical button, the receiver broadcasts its absolute timing in a packet to both nodes. Both transmitters then copy the timestamp, with some amount of near-equal latency introduced.

    static void sync_sender_task(void *arg)
    {
      while (1)
      {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        current_epoch++;
        packet sync_pkt = {
            .transmitter_id  = PACKET_SYNC_ID,
            .player_id       = PACKET_SYNC_ID,
            .transmitter_mac = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF},
            .timestamp_us    = 0xFFFFFFFF,
            .epoch           = current_epoch,
        };
        esp_now_send(DEFAULT_MAC_VALUE, (uint8_t *)&sync_pkt, sizeof(sync_pkt));
      }
    }
  2. On a buzz, the transmitter captures its time relative to the receiver’s broadcast timestamp.

    packet to_send;
    
    to_send.player_id      = pin_id;
    to_send.transmitter_id = ctx->transmitter_id;
    to_send.timestamp_us   = (uint32_t)(esp_timer_get_time() - sync_base_us);
    to_send.epoch          = local_epoch;
    xQueueSendFromISR(ctx->q, &to_send, NULL);
    
  3. Once the receiver receives a packet it waits 20 ms before locking out to receive a close-call packet. If none are received, it locks out as usual. But if a second packet is received within the close-call window, timestamps are compared directly.

    packet first;
    if (xQueueReceive(q, &first, portMAX_DELAY) != pdTRUE) continue;
    
    if (latch_state) continue; /* discard packets that arrived while latched */
    
    /* Collect a competing packet within the arbitration window. */
    packet winner = first;
    packet competitor;
    bool contested = false;
    if (xQueueReceive(q, &competitor, pdMS_TO_TICKS(20)) == pdTRUE)
    {
      contested = true;
      if (competitor.timestamp_us < winner.timestamp_us)
      {
        winner = competitor;
      }
    }
    
    latch_state = true;

The results demonstrate that this wireless buzzing approach can perform at competition-fairness levels. Arbitrating buzzes based on receiver synchronized timestamps achieves a 100x improvement in judgement fairness (minimum 100% winrate delay goes from 5 ms to 50 μs) in spite of a 10% asymmetric first-receive skew.

Delay (μs) Average Winner Average Difference Average Receiver
050.0012.7060.00
255.0023.0570.00
500.0052.3075.00
750.0082.7570.00
1000.00101.9070.00
timestamp approach results
Average winner and average receiver are scaled such that receiver 1 is 100 and receiver 0 is 0. Winner is 50/50 for the simultaneous case, before collapsing to nearly 0/100 for the delay >0 case. The 50 μs (0.00005 s) absolute certainty threshold exceeds meaningful human reaction time/fairness criteria. Observed packet difference scales linearly with respect to injected difference, while first packet remains largely random with slight skew, corrected for with synchronization.

This project has been plenty of fun to build. This is the first project I am shipping to non-technical users, meaning that I had to prioritize usability, robustness, and simplicity along the way as well. That also means that nearly anyone can use a Hybrid Buzzer for their quizbowl team. If you are interested in using one of these systems or have any questions or comments, please reach out!