Architecture
August 11, 2026 · View on GitHub
Hotspot Arcade is three programs cooperating over two links:
Phones (browser) ESP32-S2 board Flipper Zero
----------------- ---------------- ------------------
web/ game client <ws> esp32/hotspot-arcade-fw flipper/hotspot-arcade
trivia.js AP + wildcard DNS host UI (scenes)
connect4.js catch-all HTTP (assets) UART v2 (ha_uart)
app.js (core) AsyncWebSocket /ws session/roster/rounds
game engine (referee) trivia pack -> QUESTION
^ ^
+------ UART 921600 -------+
The split: why the work lands where it does
The ESP32-S2 (240 MHz) is faster than the Flipper (64 MHz) and it is where the phone sockets terminate, so all real-time state lives on the ESP. The Flipper has the screen, the buttons, and the SD card, so it owns the session/meta layer. The UART only ever carries low-frequency, high-level messages, never per-frame state.
| Flipper owns (session/meta) | ESP owns (real-time) |
|---|---|
| Lobby roster + live scoreboard (mirrored) | WebSocket connections, socket<->player |
| Which game is active, round flow (start/reveal/next) | Per-move / per-question game state |
| Trivia question bank (SD) fed one round at a time | Move validation, buzz timing |
| Authoritative persistent scores, host display | Broadcasting state to phones |
| Streaming the web bundle to the ESP (on change) | Serving the bundle from LittleFS flash |
Scores are computed by the ESP (it is the referee), broadcast to phones for display,
and reported to the Flipper via SCORE/ROUND_RESULT so the host screen and
leaderboard stay in sync. Both stay consistent because every delta is reported.
ESP32 firmware (esp32/hotspot-arcade-fw/)
Single Arduino sketch plus header-only helpers (one translation unit):
hotspot-arcade-fw.ino— AP/DNS/HTTP bring-up, the WebSocket/wshandler, the framed UART RX/TX, and the engine "sink" implementations. Two FreeRTOS mutexes:serialMutexserializes whole UART frames (emitted from the loop and async tasks),engineMutex(recursive) guards engine state touched from both tasks.ha_assets.h— the file table. The Flipper streams gzipped files in; the ESP writes them to a LittleFS flash partition during the stream and the HTTP catch-all serves them from flash (withContent-Encoding: gzip), so the bundle survives reboots and costs no heap. The ESP advertises a CRC of the stored bundle in its PING beacon and the Flipper skips re-streaming when it matches (see docs/PROTOCOL.md).ha_games.h— the engine: player roster (with emoji avatars) plus all twenty games and their per-client JSON serialization. Per-game runtime state shares one union (only the active game's state is ever live; a game switch zeroes it and re-defaults only the incoming game), content packs stay resident outside the union, and Draw a Monster's large stroke store is heap/PSRAM-allocated only while it is the active game — that is how twenty games fit the S2's internal DRAM. The whole-group games (Trivia, Would You Rather, Word Scramble, Reaction Duel, Guess the Color, Spectrum, Kiss Marry Kill, Secrets, Fill the Blank, Werewolf, Spyfall, Draw a Monster) are phone-driven and self-organizing (ready-up -> countdown -> rounds -> podium); Connect Four / Tic-Tac-Toe / Dots & Boxes / Reversi share one generalized duel + challenge system (parameterized by kind), and Pong, Battleship, and Chess reuse the same challenge flow with their own match structs (Chess runs full server-side FIDE legality plus 5+0 blitz clocks); Drawing rotates a drawer and relays ink. Pong, Reaction Duel, and the chess clocks run ontick()(called from the.inoloop) alongside the trivia/party/draw round timers. Emoji reactions broadcast to everyone as aemojimessage. Trivia and the duels are event-driven; Pong is the real-time path.ha_json.h/ha_proto.h— tiny JSON reader/writer and the UART frame constants + CRC-8.
The web app (about 63 KB gzipped) lives in LittleFS flash, and RAM stays flat regardless of trivia pack size (questions are pushed one at a time, never stored in bulk).
Flipper app (flipper/hotspot-arcade/)
A ViewDispatcher + SceneManager app, same shape as flytrap:
hotspot_arcade.c— alloc/free, the UART-worker -> GUI custom-event bridge, and a 1s liveness tick (the ESP beaconsPING~every 2s; silence for 5s flags a lost board).ha_uart.c— the race-free UART transport (IRQ -> stream buffer -> worker -> GUI-thread parse), including the mandatoryexpansion_disable()dance before acquiring the GPIO USART. Runs at 921600.ha_proto.c— framed message encode.helpers/ha_session.c— the heart: the RX frame parser, the start handshake state machine (CLEAR_FILES -> stream bundle -> SET_AP -> START, driven by ESP acks), the roster (JOIN/LEAVE/SCORE), and trivia round orchestration (parse a pack question, build + sendQUESTION,REVEAL, next).helpers/ha_storage.c— config (FlipperFormat),manifest.jsonparsing, binary-safe file reads (pre-reserved buffers to avoid an OOM-inducing 2x realloc peak), trivia pack loading.scenes/— main_menu, lobby (dashboard + start flow), game_select, host_duel (event feed for the player-driven games), leaderboard, settings, ssid_input, flasher, textview (console). Every game is phone-driven, so the Flipper only selects the game and watches the feed — there is no per-game host screen.helpers/ha_esp_port.c+helpers/ha_flasher.c+scenes/..._flasher.c— an on-device ESP flasher over the GPIO UART (Espressifesp-serial-flasher, Apache-2.0, vendored inlib/esp-serial-flasher/trimmed to the ESP32-S2 and ESP32 WROOM stubs). It borrows the serial line viaha_uart_suspend/resume, polls for download mode, flashes the SD firmware bundle with MD5 verify on a worker thread, and reboots the ESP into the new firmware.
All app state is single-threaded (mutated only on the GUI thread after RX is drained), so there are no locks on the Flipper side.
Web client (web/)
Vanilla JS, no framework, built into a single gzipped index.html (about 18 KB). app.js
owns the WebSocket, identity (nickname + emoji avatar in localStorage), lobby, screen
router, emoji reactions, and the shared game-UI components (A.readyLobby / A.countdown
/ A.timebar / A.showLead leaderboard / A.podium). Each game module (trivia.js,
duel.js for the four board duels, draw.js, pong.js, wyr.js, scramble.js,
react.js) registers handlers for its message types and reuses those components. User-facing text is
localized through core/i18n.js: the host's language rides in the welcome message and
the client renders from a message catalog, with English the default and the fallback for
any untranslated string. Styled to the Flipper design
system (../web/DESIGN.md): dark, monochrome, one orange accent,
mono/uppercase, sharp borders. The captive page is a real-browser handoff because iOS/
Android captive mini-browsers do not run WebSockets reliably.
Wire protocols
Both links are specified in PROTOCOL.md: the framed UART v2 (with the raw-bulk escape used to stream files) and the WebSocket JSON. The protocol is the source of truth; all three programs are kept in sync with it.
Data flow: a trivia round
- Host picks a pack (SD) and Start Session. Flipper streams the bundle to the ESP,
which brings up the AP. Phones join,
hello->welcome/lobby. - Host selects Trivia. Flipper parses question N from the pack, sends
QUESTION. - ESP broadcasts
triviastate; phones tap; ESP validates + times eachanswer, reports liveEVENT {answers,total}to the Flipper. - Host taps Reveal. Flipper sends
REVEAL; ESP scores correct answers (speed bonus), reportsSCOREper player +ROUND_RESULT, broadcasts the reveal. - Host taps Next -> back to step 2, until the pack ends (
ROUND_END).