Week 15: Interface and Application Programming¶
Week 15 Assignment:
-
Group Assignment
- Compare as many tool options as possible
-
Individual Assignment:
- 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.
- The device: three XIAO ESP32C3 controllers, each with 5 buttons (input) and OLED + speaker + LED (output).
- The application: a Node.js server + 2 browser pages + 1 firmware program that together turn the controllers into a multiplayer quiz game.
- 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:
- The players - interact through the physical controllers (buzzing, picking answers)
- The host - runs the game from a browser dashboard (or laptop keyboard)
- 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();
}

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:
-
Landing screen — pulsing “READY!” button

-
How to Play instructions — three numbered rules

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

-
Countdown — big 3 → 2 → 1 → GO! number

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

-
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¶
-
Start the server (Mac terminal)
cd "/Users/emilynnoor/Desktop/AWL/AWL"node server.js -
Open the host display open
http://localhost:8080/ -
(Optional) Open the editor in another tab to set up the quiz open http://localhost:8080/admin.html
-
Power on the three controllers — each one’s OLED shows “AWL READY?” Press the Hand button once on each controller to “join” the game
-
Click READY! → LET’S PLAY! → Next Question → ▶ Start Countdown The buzz race is on.
Trial Game¶
This is the trial game :)
Files¶
The Files:
- server.js - Node.js game server (HTTP + WebSocket + state machine)
- awl_console_V1.ino - ESP32 firmware — same source for all 3 controllers
- index.html - Host display: intro → instructions → game → celebration
- admin.html - Quiz editor + API client
- playwithyou.svg - Brand mark loaded by the display
- awl.svg - AWL Logo
- questions.json - Quiz content (editable via the admin page)