Skip to content

Week 15: Interface and Application Programming

Week 15 Assignment:
  1. Group Assignment

    1. Compare as many tool options as possible
  2. Individual Assignment:

    1. write an application that interfaces a user with aninput &/or output device that you made

GROUP ASSIGNMENT

Chai Huo Week 15 - Group Assignment

INDIVIDUAL ASSIGNMENT

For this week’s assignment, I did interface and application programming for my final project, AWL. Three game controllers I designed and fabricated (5 buttons (input); OLED, speaker, LED (output)) talk to Node.js server on my laptop, which serves a browser-based host display + question editor. Players buzz in with a physical button, the system runs the lockout race, the host display shows what’s happening on a projector, and an admin page lets me edit questions through the browser.

  1. The device: three XIAO ESP32C3 controllers, each with 5 buttons (input) and OLED + speaker + LED (output).
  2. The application: a Node.js server + 2 browser pages + 1 firmware program that together turn the controllers into a multiplayer quiz game.
  3. The interfaces: every button press on a controller produces immediate local feedback (sound, screen, LED) and triggers a network message that updates the central display the audience sees — and the host can shape the game from a third interface (a webpage of buttons or a keyboard) without ever touching the physical controllers.

The Device

Each controller has:

  • Inputs: 5 push buttons - Raise Hand, A, B, C, D
  • Outputs: SSD1309 OLED (128x64, I2C), I2S audio amp + speaker, status LED (inside)
  • Brain: Seeed XIAO ESP32C3 (WiFi-capable)

What the Application does

The application turns three independent controllers into one shared quiz game. There are three kinds of user, each with their own interface to the device:

  1. The players - interact through the physical controllers (buzzing, picking answers)
  2. The host - runs the game from a browser dashboard (or laptop keyboard)
  3. The quiz author - adds and edits questions through a separate browser page

All three talk to the same Node.js server running on my laptop over a single WebSocket on port 8080.

The protocol: JSON

The three layers (controllers, server, webpages) all speak the same compact JSON protocol. Designed to be small enough for the ESP32 to parse the with simple subtring matching.

Messages from a controller to the server:

{ "team": "red", "event": "hello" }
{ "team": "red", "event": "hand",   "t": 12345 }
{ "team": "red", "event": "answer", "choice": "B", "t": 12347 }

Messages from the server (broadcast to everyone — controllers AND the host display browser):

{ "type": "state",     "phase": "QUESTION_OPEN",
  "lockedTeam": null,
  "scores": { "red": 1, "yellow": 0, "blue": 2 },
  "question": { "index": 0, "total": 11, "text": "...", "choices": {...} } }
{ "type": "lockout",   "team": "red" }
{ "type": "answered",  "team": "red", "choice": "B" }
{ "type": "result",    "team": "red", "correct": true, "choice": "B", "correctChoice": "B" }
{ "type": "countdown", "value": 3 }
{ "type": "gameover",  "winner": "red", "scores": {...} }
{ "type": "reset" }

The server broadcasts on every state change. Both the controllers and the host display webpage are subscribers to the same WebSocket, so the display stays in sync with the controllers’ view of the world automatically — no separate communication layer needed.

Layer 1: The Controller Firmware

Tech

Arduino C++, libraries: U8g2 (OLED), WiFi, ESPmDNS, ArduinoWebsockets (by Gil Maimon), ESP-IDF driver/i2s.h (audio)

The firmware is one source file. To flash three controllers, I change one line (#define TEAM_ID “red” / “yellow” / “blue”) and re-upload. No per-device branches.

This code is generated by Claude: (File name: awl_console_V1.ino)

// AWL Console  Networked v5
// XIAO ESP32C3 quiz console: WiFi + WebSocket + OLED + I2S audio + 5 buttons
//
// Per-team build: change TEAM_ID below to match the physical controller color.
//   Red    = P1
//   Yellow = P2
//   Blue   = P3
//
// Boot flow:
//   1. Boot screen "AWL READY?"   player presses HAND to join
//   2. Then "TEAM: <color> / Ready" until a question opens
//   3. Press HAND during QUESTION_OPEN to buzz, then A/B/C/D to answer
//   4. Answer letter stays on OLED until result is shown (trophy / crying face)
//
// Library needed (install via Library Manager):
//   ArduinoWebsockets by Gil Maimon

#include <Wire.h>
#include <U8g2lib.h>
#include <driver/i2s.h>
#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoWebsockets.h>

using namespace websockets;

// ── PER-DEVICE CONFIG ────────────────────────────────
// Change ONLY this line when flashing each controller:
#define TEAM_ID   "blue"        // "red" | "yellow" | "blue"

// ── NETWORK CONFIG ───────────────────────────────────
const char* WIFI_SSID = "X.factory2.4G";
const char* WIFI_PASS = "make0314";
// The Mac's IP changes from day to day. Instead of hardcoding it, we use mDNS:
// at runtime we ask the network for the IP of this hostname.
const char* WS_HOST   = "Emilys-MacBook-Pro";   // Mac's mDNS name (without .local)
const int   WS_PORT   = 8080;
String      ws_url    = "";                      // built after mDNS resolves

// ── Pins ─────────────────────────────────────────────
#define LED_PIN          D3
#define I2C_SDA          D4
#define I2C_SCL          D5
#define I2S_DOUT         D0
#define I2S_BCLK         D1
#define I2S_LRC          D2
#define BTN_A            D6
#define BTN_B            D7
#define BTN_RAISE_HAND   D8
#define BTN_C            D9
#define BTN_D            D10

// ── OLED ─────────────────────────────────────────────
U8G2_SSD1309_128X64_NONAME0_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);

// ── WebSocket client ─────────────────────────────────
WebsocketsClient wsClient;
bool wsConnected = false;
unsigned long lastReconnectAttempt = 0;
const unsigned long RECONNECT_INTERVAL_MS = 3000;

// ── Game state (local to this controller) ───────────
bool ready = false;          // set to true after player presses HAND on boot
char lastChoice = 0;         // 'A'/'B'/'C'/'D' if we sent an answer, else 0

// ── Audio ─────────────────────────────────────────────
const int SAMPLE_RATE = 22050;
const int AMPLITUDE   = 4000;

void setupI2S() {
  i2s_config_t cfg = {
    .mode                 = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate          = SAMPLE_RATE,
    .bits_per_sample      = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format       = I2S_CHANNEL_FMT_RIGHT_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags     = 0,
    .dma_buf_count        = 8,
    .dma_buf_len          = 64,
    .use_apll             = false
  };
  i2s_pin_config_t pins = {
    .bck_io_num   = I2S_BCLK,
    .ws_io_num    = I2S_LRC,
    .data_out_num = I2S_DOUT,
    .data_in_num  = I2S_PIN_NO_CHANGE
  };
  i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pins);
  i2s_zero_dma_buffer(I2S_NUM_0);
}

void playTone(uint32_t freq_hz, uint32_t duration_ms) {
  uint32_t totalSamples = (SAMPLE_RATE * duration_ms) / 1000;
  if (freq_hz == 0) {
    int16_t silence[2] = {0, 0};
    for (uint32_t i = 0; i < totalSamples; i++) {
      size_t bw;
      i2s_write(I2S_NUM_0, silence, sizeof(silence), &bw, portMAX_DELAY);
    }
    return;
  }
  uint32_t halfPeriod = SAMPLE_RATE / (freq_hz * 2);
  if (halfPeriod < 1) halfPeriod = 1;
  bool     high       = true;
  uint32_t counter    = 0;
  for (uint32_t i = 0; i < totalSamples; i++) {
    int16_t s        = high ? AMPLITUDE : -AMPLITUDE;
    int16_t stereo[2] = {s, s};
    size_t bw;
    i2s_write(I2S_NUM_0, stereo, sizeof(stereo), &bw, portMAX_DELAY);
    if (++counter >= halfPeriod) { high = !high; counter = 0; }
  }
}

void flushAudio() {
  int16_t silence[2] = {0, 0};
  for (int i = 0; i < 512; i++) {
    size_t bw;
    i2s_write(I2S_NUM_0, silence, sizeof(silence), &bw, portMAX_DELAY);
  }
  i2s_zero_dma_buffer(I2S_NUM_0);
}

// ── Sound effects ─────────────────────────────────────
void soundRaiseHand() { playTone(1800, 80); playTone(0, 30); playTone(2400, 120); flushAudio(); }
void soundA()         { playTone(1500, 100); flushAudio(); }
void soundB()         { playTone(1800, 100); flushAudio(); }
void soundC()         { playTone(2100, 100); flushAudio(); }
void soundD()         { playTone(2400, 100); flushAudio(); }
void soundCorrect()   { playTone(1500, 80); playTone(2000, 80); playTone(2500, 150); flushAudio(); }
void soundWrong()     { playTone(800, 100); playTone(600, 200); flushAudio(); }
void soundStartup()   { playTone(1200, 60); playTone(0, 30); playTone(1600, 60); playTone(0, 30); playTone(2000, 100); flushAudio(); }
void soundConnected() { playTone(2000, 60); playTone(2400, 60); playTone(2800, 100); flushAudio(); }
void soundJoin()      { playTone(1000, 80); playTone(1500, 80); playTone(2000, 80); playTone(2500, 150); flushAudio(); }

// ── Display ───────────────────────────────────────────
void showOnOLED(const char* text) {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_logisoso32_tr);
  u8g2.drawStr((128 - u8g2.getStrWidth(text)) / 2, 48, text);
  u8g2.sendBuffer();
}

void showStatus(const char* line1, const char* line2) {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_helvB12_tr);   // Helvetica Bold (closest built-in to Karla)
  u8g2.drawStr((128 - u8g2.getStrWidth(line1)) / 2, 25, line1);
  u8g2.drawStr((128 - u8g2.getStrWidth(line2)) / 2, 50, line2);
  u8g2.sendBuffer();
}

// Uppercase helper  returns the TEAM_ID like "RED" instead of "red"
String teamUpper() {
  String s = String(TEAM_ID);
  s.toUpperCase();
  return s;
}

// "AWL READY?" boot screen  shown until player presses HAND to join
void showReadyScreen() {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_logisoso24_tr);
  const char* big = "AWL";
  u8g2.drawStr((128 - u8g2.getStrWidth(big)) / 2, 32, big);
  u8g2.setFont(u8g2_font_helvB12_tr);
  const char* sub = "READY?";
  u8g2.drawStr((128 - u8g2.getStrWidth(sub)) / 2, 56, sub);
  u8g2.sendBuffer();
}

void showIdle() {
  // If player hasn't joined yet, show the READY? screen instead
  if (!ready) { showReadyScreen(); return; }

  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_helvB12_tr);
  String header = String("TEAM: ") + teamUpper();   //  "TEAM: RED"
  u8g2.drawStr((128 - u8g2.getStrWidth(header.c_str())) / 2, 25, header.c_str());
  const char* sub = wsConnected ? "READY" : "CONNECTING...";
  u8g2.drawStr((128 - u8g2.getStrWidth(sub)) / 2, 50, sub);
  u8g2.sendBuffer();
}

// ── Happy face (per-round CORRECT answer) ────────────
void showHappyFace() {
  u8g2.clearBuffer();

  // "YEAY!" label at top
  u8g2.setFont(u8g2_font_helvB18_tr);
  const char* label = "YEAY!";
  u8g2.drawStr((128 - u8g2.getStrWidth(label)) / 2, 18, label);

  int cx = 64;
  int cy = 44;
  int r  = 16;

  // Face circle (double line for emphasis)
  u8g2.drawCircle(cx, cy, r);
  u8g2.drawCircle(cx, cy, r - 1);

  // Eyes  small filled dots
  u8g2.drawDisc(cx - 6, cy - 4, 2);
  u8g2.drawDisc(cx + 6, cy - 4, 2);

  // Big smile  lower half of a small circle, opens upward
  u8g2.drawCircle(cx, cy + 2, 8, U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT);
  u8g2.drawCircle(cx, cy + 2, 7, U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT);

  u8g2.sendBuffer();
}

// ── #1 + Trophy (END OF GAME winner) ─────────────────
void showWinnerFinal() {
  u8g2.clearBuffer();

  // "#1 WINNER!" label at top
  u8g2.setFont(u8g2_font_helvB12_tr);
  const char* label = "#1 WINNER!";
  u8g2.drawStr((128 - u8g2.getStrWidth(label)) / 2, 13, label);

  int cx = 64;

  // Cup body (rectangle frame so we can put "1" inside)
  u8g2.drawFrame(cx - 14, 18, 28, 20);
  // "1" inside the cup
  u8g2.setFont(u8g2_font_helvB14_tr);
  const char* one = "1";
  u8g2.drawStr(cx - u8g2.getStrWidth(one) / 2, 34, one);

  // Cup bottom edge
  u8g2.drawBox(cx - 16, 38, 32, 3);
  // Side handles
  u8g2.drawCircle(cx - 18, 25, 5);
  u8g2.drawCircle(cx + 18, 25, 5);
  // Stem
  u8g2.drawBox(cx - 3, 41, 6, 7);
  // Two-tier base
  u8g2.drawBox(cx - 10, 48, 20, 4);
  u8g2.drawBox(cx - 14, 52, 28, 4);

  u8g2.sendBuffer();
}

// ── Crying face graphic (for loser) ──────────────────
void showCryingFace() {
  u8g2.clearBuffer();

  // "TOO BAD" label at top
  u8g2.setFont(u8g2_font_helvB12_tr);
  const char* label = "TOO BAD";
  u8g2.drawStr((128 - u8g2.getStrWidth(label)) / 2, 12, label);

  int cx = 64;
  int cy = 42;
  int r  = 18;

  // Face circle outline
  u8g2.drawCircle(cx, cy, r);
  u8g2.drawCircle(cx, cy, r - 1);  // double line for emphasis

  // Eyes - X marks (closed crying eyes)
  u8g2.drawLine(cx - 9, cy - 6, cx - 5, cy - 2);
  u8g2.drawLine(cx - 9, cy - 2, cx - 5, cy - 6);
  u8g2.drawLine(cx + 5, cy - 6, cx + 9, cy - 2);
  u8g2.drawLine(cx + 5, cy - 2, cx + 9, cy - 6);

  // Frown (upper half of small circle, so the curve opens downward)
  u8g2.drawCircle(cx, cy + 12, 6, U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT);

  // Tear drops
  u8g2.drawDisc(cx - 9, cy + 4, 2);
  u8g2.drawDisc(cx + 9, cy + 4, 2);
  // Tear streams
  u8g2.drawLine(cx - 9, cy + 2, cx - 9, cy + 7);
  u8g2.drawLine(cx + 9, cy + 2, cx + 9, cy + 7);

  u8g2.sendBuffer();
}

// ── WebSocket send ────────────────────────────────────
void sendEvent(const char* event, const char* choice = nullptr) {
  if (!wsConnected) {
    Serial.println("[ws] not connected, skipping send");
    return;
  }
  String msg = String("{\"team\":\"") + TEAM_ID +
               "\",\"event\":\"" + event + "\"";
  if (choice) {
    msg += String(",\"choice\":\"") + choice + "\"";
  }
  msg += String(",\"t\":") + millis() + "}";
  wsClient.send(msg);
  Serial.print("[ws->] "); Serial.println(msg);
}

// ── WebSocket message handler ────────────────────────
void onMessage(WebsocketsMessage message) {
  String data = message.data();
  Serial.print("[ws<-] "); Serial.println(data);

  if (data.indexOf("\"type\":\"lockout\"") >= 0) {
    if (data.indexOf(String("\"team\":\"") + TEAM_ID + "\"") >= 0) {
      // We won the buzz
      digitalWrite(LED_PIN, HIGH);
      showOnOLED("GO!");
    } else {
      showStatus("Buzzed:", "other team");
    }
  }
  else if (data.indexOf("\"type\":\"result\"") >= 0) {
    bool wasUs = data.indexOf(String("\"team\":\"") + TEAM_ID + "\"") >= 0;
    bool correct = data.indexOf("\"correct\":true") >= 0;
    if (wasUs) {
      if (correct) { showHappyFace();  soundCorrect(); }   // "YEAY!" + smiley
      else         { showCryingFace(); soundWrong();   }   // tears
      delay(2500);
    } else {
      showStatus("OTHER TEAM", correct ? "SCORED" : "MISSED");
      delay(800);
    }
    digitalWrite(LED_PIN, LOW);
    lastChoice = 0;
    showIdle();
  }
  else if (data.indexOf("\"type\":\"gameover\"") >= 0) {
    // Server tells us who won the whole game
    bool weWon = data.indexOf(String("\"winner\":\"") + TEAM_ID + "\"") >= 0;
    if (weWon) {
      showWinnerFinal();
      soundCorrect();
      delay(150);
      soundCorrect();   // double celebration jingle
    } else {
      showCryingFace();
      soundWrong();
    }
    // Stay on this screen until host presses 'r' (reset) or device is power-cycled.
  }
  else if (data.indexOf("\"type\":\"reset\"") >= 0) {
    digitalWrite(LED_PIN, LOW);
    lastChoice = 0;
    showIdle();
  }
}

void onEvent(WebsocketsEvent event, String data) {
  if (event == WebsocketsEvent::ConnectionOpened) {
    wsConnected = true;
    Serial.println("[ws] connected");
    soundConnected();
    showIdle();
    sendEvent("hello");
  } else if (event == WebsocketsEvent::ConnectionClosed) {
    wsConnected = false;
    Serial.println("[ws] disconnected");
    showIdle();
  }
}

// Resolve the Mac's hostname (Emilys-MacBook-Pro.local) to an IP via mDNS.
// Returns true if we got an IP and built ws_url.
bool resolveServer() {
  Serial.print("[mdns] resolving "); Serial.print(WS_HOST); Serial.println(".local ...");
  showStatus("Finding", "server...");
  IPAddress ip = MDNS.queryHost(WS_HOST, 3000);   // 3-second timeout
  if (ip == IPAddress(0, 0, 0, 0)) {
    Serial.println("[mdns] resolve FAILED");
    return false;
  }
  ws_url = "ws://" + ip.toString() + ":" + String(WS_PORT);
  Serial.print("[mdns] resolved -> "); Serial.println(ws_url);
  return true;
}

void connectWebSocket() {
  // Make sure we have an IP for the server. If not, resolve via mDNS.
  if (ws_url.length() == 0) {
    if (!resolveServer()) return;   // try again next reconnect tick
  }
  Serial.print("[ws] connecting to "); Serial.println(ws_url);
  showStatus("Connecting", "to server...");
  wsClient.onMessage(onMessage);
  wsClient.onEvent(onEvent);
  bool ok = wsClient.connect(ws_url);
  if (!ok) {
    Serial.println("[ws] connect failed - clearing URL to re-resolve next time");
    ws_url = "";   // force re-resolution (Mac's IP may have changed)
  }
}

// ── Edge detection ────────────────────────────────────
bool lastA = HIGH, lastB = HIGH, lastH = HIGH, lastC = HIGH, lastD = HIGH;

bool justPressed(bool curr, bool& last) {
  bool fired = (last == HIGH && curr == LOW);
  last = curr;
  return fired;
}

// ── Button reaction (local feedback + network send) ──
// persist=true means the OLED label STAYS until a result/reset message clears it.
void buttonReact(const char* label, void (*soundFn)(),
                 const char* eventName, const char* choice = nullptr,
                 bool persist = false) {
  digitalWrite(LED_PIN, HIGH);
  showOnOLED(label);
  Serial.print("Button: "); Serial.println(label);
  soundFn();
  sendEvent(eventName, choice);
  if (!persist) {
    digitalWrite(LED_PIN, LOW);
    showIdle();
  }
  // If persist=true: leave LED on + label showing until the server replies.
}

// ── Handle a HAND press (context-dependent) ──────────
void handleHandPress() {
  if (!ready) {
    // First press = "join". Don't send a buzz to the server, just go to Ready state.
    ready = true;
    Serial.println("[ready] player joined");
    showStatus("WELCOME", String(String("TEAM: ") + teamUpper()).c_str());
    soundJoin();
    delay(800);
    showIdle();
  } else {
    // Real buzz
    buttonReact("HAND", soundRaiseHand, "hand");
  }
}

// ── Setup ─────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("\nAWL Console v5 — TEAM: " TEAM_ID);

  pinMode(LED_PIN,        OUTPUT);
  digitalWrite(LED_PIN,   LOW);
  pinMode(BTN_A,          INPUT_PULLUP);
  pinMode(BTN_B,          INPUT_PULLUP);
  pinMode(BTN_RAISE_HAND, INPUT_PULLUP);
  pinMode(BTN_C,          INPUT_PULLUP);
  pinMode(BTN_D,          INPUT_PULLUP);

  Wire.begin(I2C_SDA, I2C_SCL);
  u8g2.begin();
  setupI2S();

  showStatus("AWL", "Booting...");
  soundStartup();

  // WiFi
  Serial.print("[wifi] connecting to "); Serial.println(WIFI_SSID);
  showStatus("WiFi", "connecting...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("[wifi] OK, ip="); Serial.println(WiFi.localIP());
    showStatus("WiFi OK", WiFi.localIP().toString().c_str());
    delay(800);
  } else {
    Serial.println("[wifi] FAILED (will keep retrying)");
    showStatus("WiFi", "FAILED");
  }

  // Start mDNS so we can resolve "Emilys-MacBook-Pro.local" to an IP.
  // The name we pass here is what THIS device would advertise  not what we're querying.
  if (!MDNS.begin("awl-client")) {
    Serial.println("[mdns] start FAILED");
  } else {
    Serial.println("[mdns] started");
  }

  connectWebSocket();
  // After WS connect, onEvent() will fire and call showIdle()  which now
  // shows "AWL READY?" because ready==false. Player must press HAND to join.
  showIdle();
}

// ── Loop ──────────────────────────────────────────────
void loop() {
  // Maintain WiFi
  if (WiFi.status() != WL_CONNECTED) {
    wsConnected = false;
    showStatus("WiFi", "reconnecting");
    WiFi.reconnect();
    delay(500);
    return;
  }

  // Maintain WebSocket
  if (wsConnected) {
    wsClient.poll();
  } else if (millis() - lastReconnectAttempt > RECONNECT_INTERVAL_MS) {
    lastReconnectAttempt = millis();
    connectWebSocket();
  }

  // Buttons
  bool a = digitalRead(BTN_A);
  bool b = digitalRead(BTN_B);
  bool h = digitalRead(BTN_RAISE_HAND);
  bool c = digitalRead(BTN_C);
  bool d = digitalRead(BTN_D);

  if (justPressed(h, lastH)) handleHandPress();

  // Answer buttons only respond after player has joined (ready==true)
  if (ready) {
    if (justPressed(a, lastA)) { lastChoice = 'A'; buttonReact("A", soundA, "answer", "A", true); }
    if (justPressed(b, lastB)) { lastChoice = 'B'; buttonReact("B", soundB, "answer", "B", true); }
    if (justPressed(c, lastC)) { lastChoice = 'C'; buttonReact("C", soundC, "answer", "C", true); }
    if (justPressed(d, lastD)) { lastChoice = 'D'; buttonReact("D", soundD, "answer", "D", true); }
  } else {
    // Still update the edge-detect history so we don't fire on join transition
    lastA = a; lastB = b; lastC = c; lastD = d;
  }

  delay(20);
}
Boot Flow

void setup() {
  // [hardware init: pins, OLED, I2S, sounds]
  showStatus("AWL", "Booting...");
  soundStartup();

  // Connect to WiFi
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) delay(250);

  // Start mDNS so we can resolve the laptop by hostname
  MDNS.begin("awl-client");
  connectWebSocket();
  showIdle();   // shows "AWL READY?" until player presses Hand
}
mDNS

I used mDNS so that the firmware doesn’t need re-flashing when my mac’s IP changes.

DHCP changes my Mac’s IP daily. Hardcoding it would mean re-flashing all 3 controllers every morning. mDNS solves this, the firmware asks the network for the IP at runtime.

bool resolveServer() {
  IPAddress ip = MDNS.queryHost("Emilys-MacBook-Pro", 3000);  // 3-sec timeout
  if (ip == IPAddress(0, 0, 0, 0)) return false;
  ws_url = "ws://" + ip.toString() + ":" + String(WS_PORT);
  return true;
}

If the WebSocket disconnects (for example if my Mac’s IP changed mid-session), ws_url is cleared and we re-resolve on the next reconnect attempt. Flash once, runs anywhere on the same LAN.

Custom OLED graphics

When the server tells this team they got the answer correct, the firmware draws a happy smiley with U8g2 primitives (no bitmap files):

void showHappyFace() {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_helvB18_tr);
  u8g2.drawStr((128 - u8g2.getStrWidth("YEAY!")) / 2, 18, "YEAY!");
  int cx = 64, cy = 44, r = 16;
  u8g2.drawCircle(cx, cy, r);
  u8g2.drawCircle(cx, cy, r - 1);                    // double-line emphasis
  u8g2.drawDisc(cx - 6, cy - 4, 2);                  // left eye
  u8g2.drawDisc(cx + 6, cy - 4, 2);                  // right eye
  u8g2.drawCircle(cx, cy + 2, 8,                     // big curved smile
    U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT);
  u8g2.sendBuffer();
}
Other graphics:

Layer 2: The Server (Node.js)

Tech

Node.js with built-in http, fs, path, readline + the ws library for WebSocket.

The server is the brain. It holds all the game state and rules.

// AWL Game Server v2
// ---------------------------------------------------------------
// 3-team quiz with lockout buzzer + host keyboard control.
//
// Phases:
//   IDLE              - nothing happening, between rounds
//   QUESTION_PREVIEW  - question is shown to host & audience, but buzzes are NOT accepted yet
//   COUNTDOWN         - host clicked "Begin"; counting 3..2..1..GO before buzzes open
//   QUESTION_OPEN     - countdown finished, teams may now buzz
//   LOCKED            - a team won the buzz, waiting for their A/B/C/D
//   RESULT            - answer marked, showing result
//
// Team messages IN:
//   { team, event:"hello" }
//   { team, event:"hand", t }
//   { team, event:"answer", choice:"A|B|C|D", t }
//
// Server messages OUT (broadcast to all):
//   { type:"hello", message }
//   { type:"state", phase, lockedTeam, scores }
//   { type:"lockout", team }                 // a team won the buzz
//   { type:"answered", team, choice }        // team submitted an answer
//   { type:"result", team, correct, choice } // host marked it
//   { type:"reset" }                         // back to idle / new round
//
// Host commands (type one letter + Enter in the Pi terminal):
//   o  open new question (teams may now buzz)
//   c  current locked team got it CORRECT  (+1)
//   w  current locked team got it WRONG    (0)
//   r  reset to IDLE
//   s  print scores
//   z  zero out scores (new game)
//   q  quit server
//
// HTTP: also serves the host display page from ./public on the same port.
//   Open http://<pi-ip>:8080/  in a browser to see the live scoreboard.
// ---------------------------------------------------------------

const http      = require('http');
const fs        = require('fs');
const path      = require('path');
const WebSocket = require('ws');
const readline  = require('readline');

const PORT           = 8080;
const PUBLIC_DIR     = path.join(__dirname, 'public');
const QUESTIONS_FILE = path.join(__dirname, 'questions.json');
const TEAMS          = ['red', 'yellow', 'blue'];
const WIN_SCORE      = 3;     // first team to reach this many points wins the game

// ── Quiz content ────────────────────────────────────────────────
let quiz = { title: 'Quiz', questions: [] };
let qIndex = -1;     // -1 = no question loaded yet; index into playOrder (not quiz.questions)
let playOrder = [];  // shuffled list of indices into quiz.questions

// Fisher-Yates shuffle of [0..n-1]
function shufflePlayOrder() {
  playOrder = quiz.questions.map((_, i) => i);
  for (let i = playOrder.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [playOrder[i], playOrder[j]] = [playOrder[j], playOrder[i]];
  }
  console.log(`[shuffle] play order: [${playOrder.map(i => i + 1).join(', ')}]`);
}

function loadQuestions() {
  try {
    quiz = JSON.parse(fs.readFileSync(QUESTIONS_FILE, 'utf8'));
    console.log(`[quiz] loaded ${quiz.questions.length} questions ("${quiz.title}")`);
  } catch (e) {
    console.log(`[quiz] could not load questions.json: ${e.message}`);
    quiz = { title: 'No quiz loaded', questions: [] };
  }
  shufflePlayOrder();
}
loadQuestions();

function currentQuestion() {
  if (qIndex < 0 || qIndex >= playOrder.length) return null;
  const q = quiz.questions[playOrder[qIndex]];
  if (!q) return null;
  return {
    index: qIndex,
    total: playOrder.length,
    text: q.q,
    choices: q.choices,
  };
}

// ── Game state ──────────────────────────────────────────────────
let phase       = 'IDLE';
let lockedTeam  = null;
let lastAnswer  = null;
const scores    = { red: 0, yellow: 0, blue: 0 };
const sockets   = {};        // team -> WebSocket
const attemptedTeams = new Set();   // teams that already tried (and missed) the current question
let countdownTimers = [];           // setTimeout IDs for in-progress countdown

function cancelCountdown() {
  countdownTimers.forEach(t => clearTimeout(t));
  countdownTimers = [];
}

function startFreshRound() {
  // Called when host advances to a new question OR fully resets.
  cancelCountdown();
  attemptedTeams.clear();
  lockedTeam = null;
  lastAnswer = null;
}

// ── HTTP server (serves the host display) ───────────────────────
const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.js':   'text/javascript; charset=utf-8',
  '.css':  'text/css; charset=utf-8',
  '.json': 'application/json',
  '.png':  'image/png',
  '.jpg':  'image/jpeg',
  '.svg':  'image/svg+xml',
  '.ico':  'image/x-icon',
};

const server = http.createServer((req, res) => {
  // ── API: questions ─────────────────────────────────
  if (req.url === '/api/questions' && req.method === 'GET') {
    res.writeHead(200, {'Content-Type': 'application/json'});
    res.end(JSON.stringify(quiz));
    return;
  }

  if (req.url === '/api/questions' && req.method === 'PUT') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const incoming = JSON.parse(body);
        // basic shape check
        if (typeof incoming.title !== 'string' || !Array.isArray(incoming.questions)) {
          res.writeHead(400, {'Content-Type':'application/json'});
          res.end(JSON.stringify({ok: false, error: 'expected {title, questions:[]}'}));
          return;
        }
        // sanitise each question
        const cleaned = incoming.questions.map(q => ({
          q: String(q.q || '').trim(),
          choices: {
            A: String(q.choices?.A || '').trim(),
            B: String(q.choices?.B || '').trim(),
            C: String(q.choices?.C || '').trim(),
            D: String(q.choices?.D || '').trim(),
          },
          correct: ['A','B','C','D'].includes(q.correct) ? q.correct : 'A',
        }));
        quiz = { title: incoming.title.trim() || 'Quiz', questions: cleaned };
        fs.writeFileSync(QUESTIONS_FILE, JSON.stringify(quiz, null, 2));
        shufflePlayOrder();   // questions changed  reshuffle the play order
        if (qIndex >= playOrder.length) qIndex = Math.max(0, playOrder.length - 1);
        console.log(`[admin] saved ${quiz.questions.length} questions ("${quiz.title}")`);
        broadcastState();
        res.writeHead(200, {'Content-Type':'application/json'});
        res.end(JSON.stringify({ok: true, count: quiz.questions.length}));
      } catch (e) {
        res.writeHead(400, {'Content-Type':'application/json'});
        res.end(JSON.stringify({ok: false, error: e.message}));
      }
    });
    return;
  }

  // ── Static files ───────────────────────────────────
  const reqPath = (req.url === '/' ? '/index.html' : req.url).split('?')[0];
  const filePath = path.normalize(path.join(PUBLIC_DIR, reqPath));
  // prevent path traversal outside public/
  if (!filePath.startsWith(PUBLIC_DIR)) {
    res.writeHead(403); res.end('Forbidden'); return;
  }
  fs.readFile(filePath, (err, data) => {
    if (err) { res.writeHead(404, {'Content-Type':'text/plain'}); res.end('Not found'); return; }
    const ext = path.extname(filePath).toLowerCase();
    res.writeHead(200, {'Content-Type': MIME[ext] || 'application/octet-stream'});
    res.end(data);
  });
});

// ── WebSocket server (shares the same HTTP port) ────────────────
const wss = new WebSocket.Server({ server });
server.listen(PORT, () => {
  console.log(`AWL server listening on port ${PORT}`);
  console.log(`  WebSocket:    ws://<pi-ip>:${PORT}`);
  console.log(`  Host display: http://<pi-ip>:${PORT}/`);
  console.log('Host commands: [n]ext Q | [p]rev | [o] preview | [b]egin countdown | [c]orrect | [w]rong | [g]ame over | [r]eset | [s]cores | [z]ero | [l]oad Qs | [q]uit');
});

function broadcast(obj) {
  const msg = JSON.stringify(obj);
  wss.clients.forEach((c) => {
    if (c.readyState === WebSocket.OPEN) c.send(msg);
  });
}

function broadcastState() {
  broadcast({
    type: 'state',
    phase,
    lockedTeam,
    scores,
    quizTitle: quiz.title,
    question: currentQuestion(),
  });
}

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  ws.teamId = null;
  console.log(`[+] connect from ${ip}`);
  ws.send(JSON.stringify({ type: 'hello', message: 'Welcome to AWL' }));
  broadcastState();

  ws.on('message', (data) => {
    let msg;
    try { msg = JSON.parse(data.toString()); }
    catch { console.log(`[!] bad JSON: ${data}`); return; }

    // Host commands from the web UI buttons
    if (msg.type === 'host' && msg.cmd) {
      console.log(`[<-host UI] ${msg.cmd}`);
      handleHostCmd(msg.cmd);
      return;
    }

    const { team, event } = msg;
    console.log(`[<-${team || '?'}] ${JSON.stringify(msg)}`);

    if (!TEAMS.includes(team)) {
      console.log(`[!] unknown team: ${team}`);
      return;
    }

    // First message from a team: register socket
    if (!ws.teamId) {
      ws.teamId = team;
      // If a previous socket existed for this team, close it
      const prev = sockets[team];
      if (prev && prev !== ws && prev.readyState === WebSocket.OPEN) {
        console.log(`[reg] team ${team} reconnected, dropping old socket`);
        try { prev.close(); } catch (e) {}
      }
      sockets[team] = ws;
      console.log(`[reg] team ${team} <- ${ip}`);
      broadcastState();
    }

    if (event === 'hello') return;

    if (event === 'hand') {
      if (phase !== 'QUESTION_OPEN') {
        console.log(`[skip] hand from ${team} while phase=${phase}`);
        return;
      }
      if (attemptedTeams.has(team)) {
        console.log(`[skip] hand from ${team}  already attempted this question`);
        return;
      }
      lockedTeam = team;
      phase = 'LOCKED';
      console.log(`[lock] >>> ${team} won the buzz <<<`);
      broadcast({ type: 'lockout', team });
      broadcastState();
      return;
    }

    if (event === 'answer') {
      if (phase !== 'LOCKED' || team !== lockedTeam) {
        console.log(`[skip] answer from ${team} (phase=${phase}, locked=${lockedTeam})`);
        return;
      }
      lastAnswer = msg.choice;
      const q = quiz.questions[playOrder[qIndex]];
      const correctChoice = q?.correct;
      const isCorrect = (correctChoice && correctChoice === msg.choice);

      console.log(`[answer] ${team} picked ${msg.choice} -> ${isCorrect ? 'CORRECT (+1)' : 'WRONG'}`);
      broadcast({ type: 'answered', team, choice: msg.choice });

      if (isCorrect) {
        // CORRECT: award point, end the round
        scores[team] += 1;
        const reachedWinScore = scores[team] >= WIN_SCORE;
        setTimeout(() => {
          broadcast({
            type: 'result',
            team, correct: true,
            choice: msg.choice,
            correctChoice,
          });
          phase = 'RESULT';
          broadcastState();

          // If this team just hit the winning score, auto-fire GAME OVER
          // after a brief celebration for the correct answer itself.
          if (reachedWinScore) {
            setTimeout(() => {
              console.log(`[winner] ${team} reached ${scores[team]} pts (target ${WIN_SCORE})  GAME OVER!`);
              broadcast({ type: 'gameover', winner: team, scores });
              phase = 'IDLE';
              lockedTeam = null;
              lastAnswer = null;
              broadcastState();
            }, 2000);
          }
        }, 1200);
      } else {
        // WRONG: lock this team out of the rest of the round
        attemptedTeams.add(team);
        const remaining = TEAMS.filter(t => !attemptedTeams.has(t));
        const roundContinues = remaining.length > 0;
        console.log(`[wrong] ${team} attempted. remaining: [${remaining.join(',')}]`);

        setTimeout(() => {
          // Broadcast the result. Only reveal the correct answer if the round is OVER.
          broadcast({
            type: 'result',
            team, correct: false,
            choice: msg.choice,
            correctChoice: roundContinues ? null : correctChoice,
            roundContinues,
            attempted: Array.from(attemptedTeams),
            remaining,
          });

          if (roundContinues) {
            // After the result animation plays out on controllers, re-open for remaining teams
            setTimeout(() => {
              phase = 'QUESTION_OPEN';
              lockedTeam = null;
              lastAnswer = null;
              console.log(`[reopen] question open again for: [${remaining.join(',')}]`);
              broadcastState();
            }, 2500);
          } else {
            // Everyone tried. End the round; correct answer was revealed above.
            phase = 'RESULT';
            broadcastState();
          }
        }, 1200);
      }

      return;
    }
  });

  ws.on('close', () => {
    if (ws.teamId && sockets[ws.teamId] === ws) {
      console.log(`[-] team ${ws.teamId} disconnected`);
      delete sockets[ws.teamId];
      broadcastState();
    } else {
      console.log(`[-] disconnect from ${ip}`);
    }
  });

  ws.on('error', (err) => {
    console.log(`[!] socket error: ${err.message}`);
  });
});

// ── Host control (shared by stdin keyboard AND web UI buttons) ───
function handleHostCmd(rawCmd) {
  const cmd = (rawCmd || '').toString().trim().toLowerCase();
  switch (cmd) {
    case 'o':
      if (qIndex < 0) { console.log('[host] no question loaded — press [n] to load first question'); break; }
      startFreshRound();
      phase = 'QUESTION_PREVIEW';
      console.log(`[host] PREVIEW Q${qIndex+1}/${playOrder.length}  press [b] to begin countdown`);
      broadcast({ type: 'reset' });
      broadcastState();
      break;

    case 'n':
      if (playOrder.length === 0) { console.log('[host] no questions loaded'); break; }
      qIndex = Math.min(qIndex + 1, playOrder.length - 1);
      startFreshRound();
      phase = 'QUESTION_PREVIEW';
      console.log(`[host] NEXT  Q${qIndex+1}/${playOrder.length}: ${quiz.questions[playOrder[qIndex]].q}`);
      broadcast({ type: 'reset' });
      broadcastState();
      break;

    case 'p':
      if (playOrder.length === 0) { console.log('[host] no questions loaded'); break; }
      qIndex = Math.max(qIndex - 1, 0);
      startFreshRound();
      phase = 'QUESTION_PREVIEW';
      console.log(`[host] PREV  Q${qIndex+1}/${playOrder.length}: ${quiz.questions[playOrder[qIndex]].q}`);
      broadcast({ type: 'reset' });
      broadcastState();
      break;

    case 'b': {
      // BEGIN  start 3-2-1-GO countdown, then transition to QUESTION_OPEN
      if (phase !== 'QUESTION_PREVIEW') {
        console.log(`[host] cannot start countdown from phase=${phase}`);
        break;
      }
      cancelCountdown();
      phase = 'COUNTDOWN';
      console.log('[host] COUNTDOWN starting');
      broadcast({ type: 'countdown', value: 3 });
      broadcastState();
      countdownTimers.push(setTimeout(() => broadcast({ type: 'countdown', value: 2 }), 1000));
      countdownTimers.push(setTimeout(() => broadcast({ type: 'countdown', value: 1 }), 2000));
      countdownTimers.push(setTimeout(() => {
        broadcast({ type: 'countdown', value: 0 });   // 0 = "GO!"
        phase = 'QUESTION_OPEN';
        console.log('[host] QUESTION OPEN — teams may buzz');
        broadcastState();
      }, 3000));
      break;
    }

    case 'c':
      if (phase !== 'LOCKED' || !lockedTeam) {
        console.log('[host] no team is locked in');
        break;
      }
      scores[lockedTeam] += 1;
      console.log(`[host] CORRECT for ${lockedTeam}  (score: ${scores[lockedTeam]})`);
      broadcast({
        type: 'result',
        team: lockedTeam,
        correct: true,
        choice: lastAnswer,
        correctChoice: quiz.questions[qIndex]?.correct,
      });
      phase = 'RESULT';
      broadcastState();
      break;

    case 'w':
      if (phase !== 'LOCKED' || !lockedTeam) {
        console.log('[host] no team is locked in');
        break;
      }
      console.log(`[host] WRONG for ${lockedTeam}`);
      broadcast({
        type: 'result',
        team: lockedTeam,
        correct: false,
        choice: lastAnswer,
        correctChoice: quiz.questions[qIndex]?.correct,
      });
      phase = 'RESULT';
      broadcastState();
      break;

    case 'l':
      loadQuestions();
      console.log('[host] reloaded questions.json');
      broadcastState();
      break;

    case 'g': {
      // GAME OVER  figure out the winning team(s) and broadcast
      let maxScore = -1;
      let winner = null;
      for (const t of TEAMS) {
        if (scores[t] > maxScore) {
          maxScore = scores[t];
          winner = t;
        }
      }
      console.log(`[host] GAME OVER  winner: ${winner} (${maxScore} pts)`);
      broadcast({ type: 'gameover', winner: winner, scores: scores });
      phase = 'IDLE';
      lockedTeam = null;
      lastAnswer = null;
      broadcastState();
      break;
    }

    case 'r':
      startFreshRound();
      phase = 'IDLE';
      console.log('[host] RESET to IDLE');
      broadcast({ type: 'reset' });
      broadcastState();
      break;

    case 's':
      console.log('[host] scores:', scores, ' phase:', phase, ' locked:', lockedTeam);
      break;

    case 'z':
      for (const t of TEAMS) scores[t] = 0;
      qIndex = -1;
      startFreshRound();
      shufflePlayOrder();   // new game = freshly shuffled question order
      phase = 'IDLE';
      console.log('[host] NEW GAME — scores zeroed, questions reshuffled');
      broadcast({ type: 'reset' });
      broadcastState();
      break;

    case 'q':
      console.log('[host] quitting');
      process.exit(0);
      break;

    case '':
      break;

    default:
      console.log('Commands: n (next Q) | p (prev) | o (preview) | b (begin countdown) | c (correct) | w (wrong) | g (GAME OVER) | r (reset) | s (scores) | z (zero) | l (reload Qs) | q (quit)');
  }
}

// Stdin (keyboard) input -> handleHostCmd
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.on('line', (line) => handleHostCmd(line));
Game State Machine — 6 phases
let phase = 'IDLE';
//   IDLE              - nothing happening, between rounds
//   QUESTION_PREVIEW  - question is shown, but buzzes NOT yet accepted
//   COUNTDOWN         - 3  2  1  GO! before buzzes open
//   QUESTION_OPEN     - countdown finished, teams may now buzz
//   LOCKED            - a team won the buzz, waiting for A/B/C/D
//   RESULT            - answer marked, showing result
Lockout Buzzer

Node’s event loop is single-threaded, so two hand events arriving microseconds apart get processed one at a time. The second one finds phase !== 'QUESTION_OPEN' and bails out — no race condition, no explicit locking needed.

if (event === 'hand') {
  if (phase !== 'QUESTION_OPEN') {
    console.log(`[skip] hand from ${team} while phase=${phase}`);
    return;
  }
  if (attemptedTeams.has(team)) {
    console.log(`[skip] hand from ${team}  already attempted`);
    return;
  }
  lockedTeam = team;
  phase = 'LOCKED';
  broadcast({ type: 'lockout', team });
  broadcastState();
}
Auto-mark Answers

The server reads each question’s correct field from questions.json and judges automatically. The 1.2-second delay between “answered” and “result” lets the picked pill glow blue on the display before flipping green/red — the audience sees what was answered before whether it was right.

const q = quiz.questions[playOrder[qIndex]];
const correctChoice = q?.correct;
const isCorrect = (correctChoice && correctChoice === msg.choice);

broadcast({ type: 'answered', team, choice: msg.choice });

if (isCorrect) {
  scores[team] += 1;
  const reachedWinScore = scores[team] >= WIN_SCORE;   // first to 3 = wins
  setTimeout(() => {
    broadcast({ type: 'result', team, correct: true, choice: msg.choice, correctChoice });
    phase = 'RESULT';
    broadcastState();

    if (reachedWinScore) {
      setTimeout(() => {
        broadcast({ type: 'gameover', winner: team, scores });
        phase = 'IDLE';
      }, 2000);
    }
  }, 1200);
}
Rebound Buzzing
attemptedTeams.add(team);  // lock this team out for the rest of the round
const remaining = TEAMS.filter(t => !attemptedTeams.has(t));
const roundContinues = remaining.length > 0;

if (roundContinues) {
  setTimeout(() => {
    phase = 'QUESTION_OPEN';
    broadcastState();   // others can buzz now
  }, 2500);
} else {
  // everyone tried  reveal the correct answer, end the round
}
Random Question Order
function shufflePlayOrder() {           // Fisher-Yates
  playOrder = quiz.questions.map((_, i) => i);
  for (let i = playOrder.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [playOrder[i], playOrder[j]] = [playOrder[j], playOrder[i]];
  }
}

Reshuffled on server start, on Zero Scores (new game), and after the admin saves new questions.

Layer 3: The Host Display (browser)

Tech

plain HTML, CSS, and vanilla JavaScript — no frameworks, no build step. One self-contained public/index.html file served by the Node server.

Displays

The display walks the audience through 5 stages:

  1. Landing screen — pulsing “READY!” button

  2. How to Play instructions — three numbered rules

  3. Question + Start Countdown button — question card with the 4 coral pills below

  4. Countdown — big 3 → 2 → 1 → GO! number

  5. Buzz race — team locks in, answers, pill goes green/red

  6. Game over celebration — full-screen overlay with bouncing winner name + trophy + confetti

Live Updates via WebSocket

The page opens its own WebSocket to the same ws://...:8080 the controllers use. Whenever the server broadcasts a message, the page updates the DOM:

ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'state') {
    setScores(msg.scores);
    renderQuestion(msg.question);
    // ... handle phase transitions
  } else if (msg.type === 'answered') {
    // glow the pill the team picked
    document.querySelector(`.choice[data-letter="${msg.choice}"]`)
            .classList.add('picked');
  } else if (msg.type === 'result') {
    // flip pill green / red, optionally reveal correct answer
  } else if (msg.type === 'gameover') {
    showCelebration(msg.winner);
  }
};
Host Control Bar

A row of buttons at the bottom of the display lets the host run the game without a terminal. Each button shows its keyboard shortcut, and pressing the key in the browser triggers the same code path.

<button class="host-btn primary"  onclick="sendCmd('n')">Next Question <span class="key">N</span></button>
<button class="host-btn correct"  onclick="sendCmd('b')"> Begin <span class="key">B</span></button>
<button class="host-btn correct"  onclick="sendCmd('c')"> Correct <span class="key">C</span></button>
<button class="host-btn wrong"    onclick="sendCmd('w')"> Wrong <span class="key">W</span></button>
<button class="host-btn gameover" onclick="sendCmd('g')">Game Over <span class="key">G</span></button>
function sendCmd(cmd) {
  ws.send(JSON.stringify({ type: 'host', cmd: cmd }));
}

document.addEventListener('keydown', (e) => {
  const k = e.key.toLowerCase();
  if ('ncwgrzpsolb'.includes(k)) {
    sendCmd(k);
    e.preventDefault();
  }
});

Layer 4: The Quiz Editor (browser)

Tech

plain HTML/CSS/JS again, talking to two HTTP endpoints on the server.

This allows me prepare the quiz before the game without ever opening a text editor or restarting the server. Loads questions.json at page open, lets me edit inline, saves with one click, and the live display refreshes immediately with the new questions.

How to run it

  1. Start the server (Mac terminal) cd "/Users/emilynnoor/Desktop/AWL/AWL" node server.js

  2. Open the host display open http://localhost:8080/

  3. (Optional) Open the editor in another tab to set up the quiz open http://localhost:8080/admin.html

  4. Power on the three controllers — each one’s OLED shows “AWL READY?” Press the Hand button once on each controller to “join” the game

  5. Click READY! → LET’S PLAY! → Next Question → ▶ Start Countdown The buzz race is on.

Trial Game

This is the trial game :)

Files