FooGolf developers
Documentation › docs/web-build-feasibility-2026-09-19.md

FooGolf in the browser (Emscripten) - feasibility assessment

Date: 2026-09-19. Status: assessment complete, decisions taken, Stage 1 (HAL + build skeleton) started the same day. Numbers come from a read-only survey of foodoublebassesp32, esp32-device-core and foogolf-ota-cloud as of fw 0.8.590 / lib v0.2.31.

The ask

Run the device firmware - the LVGL UI, the games and drills, the Lua extensions and online play - in a browser as a WebAssembly build of the same C++ code, so browser players can join multiplayer rounds with hardware devices and with each other. Out of the web build by decision: ADC capture and every shot-generating path, OTA, BLE / Garmin R10, calibration, waveforms.

Verdict

Feasible, and not a rewrite. The UI and game layers are already portable in all but a handful of files; the platform surface is concentrated in three large files and a dozen network modules. A medium project in three stages: Stage 1 (single-player browser build with the real menus, drills, games, extensions, audio and persistence) is low-risk; Stage 2 (multiplayer through the existing cloud) needs small, already-modelled cloud changes plus one task rewrite; Stage 3 is polish plus the things a browser structurally cannot do, which stay out.

What is in our favour (measured)

Fact Why it matters
src/ is 151 k lines, but 73 k is src/assets (image data). UI + games are ~47 k lines. The real port surface is much smaller than the repo suggests.
LVGL 8.4 comes from lib_deps, not a fork. include/lv_conf.h has exactly two ESP lines (LV_MEM_POOL_ALLOC -> heap_caps_malloc, LV_TICK_CUSTOM -> millis()). LVGL has an official Emscripten port (lv_web_emscripten). The config is a two-line change.
The display flush is one function, my_disp_flush in src/UI/LvglPort.cpp (12.5 KB partial buffer). ScreenCast::onFlush already keeps a full 320x170 RGB565 shadow frame. The web flush writes the same shadow; JS blits it to a <canvas> per frame. Pixel-identical UI.
Input goes through the library's abstract Buttons::Source (six keys) and RemoteKeys (lock-free atomics, 100 ms / 600 ms rules) is already a virtual key provider built for presses arriving from another thread. Keyboard / on-page keypad -> RemoteKeys::press/hold/release, no new plumbing.
web/cast-viewer.html (1 505 lines) is already a 320x170 canvas + six-key keypad + Motif shield + Web Audio with autoplay unlock, transport isolated in connect() / keySend() / onBatch(). The browser chrome exists; swap the WebSocket source for the WASM framebuffer.
esp32-device-core/src/ui/ (~4 k lines) is pure LVGL except WifiScanPage.cpp, WifiPickerFlow.cpp (excluded anyway) and BootHighlightPage.cpp (two millis() calls). The shared UI toolkit ports as-is.
Firmware UI/game files touch only Preferences, millis, heap_caps_malloc, esp_random, delay. Arduino String appears in 18 files, 62 uses, almost all network code. One shim header covers the UI and games.
16 modules already compile natively ([env:native], test/native_stubs/, 15 tests, zig toolchain): physics, distance model, course generator, round codec, scorers. Proof the game core is platform-free.
Every cloud call is HTTP + JSON with ONE signing scheme (X-Device-Id / X-Timestamp / X-Signature = HMAC-SHA256). No WebSocket client; the only raw-socket cloud path is the hand-rolled long poll, which is a POST held <= 20 s. fetch covers all of it.
share.foogolf.com already serves HTTPS (ACM cert, allow-all on /sim/*, /device/*, /check-for-update, /cast/*). All three API Gateways are HTTPS-native. No new TLS work.
lambdas/sim_share does not care what kind of device is talking; a host with sim: "none" is a first-class state. A browser can host or join an online round with no protocol change.
Lua 5.4.7 is vendored pure C; LuaHost.cpp needs only malloc + one random seed; ChimeSynth is pure; AudioOut mixes in software with one I2S chokepoint (writeI2SChunk). Extensions, chimes and speech port cleanly.
PSRAM / DRAM budgets, CanvasPool, the TLS heap floor, ram_budget.py are all ESP-only concerns. The browser build ignores every memory constraint the firmware fights.

The hard parts

  1. The three god files. App.cpp (4 926 lines: 81 WiFi refs, 29 Preferences, 30 watchdog calls; App::tick is a while(true) that never returns and calls scanner.scan(), which blocks up to 6 s and ticks the UI from inside), UIController.cpp (6 154 lines, 55 WiFi refs, OTA install, NVS erase), Menu.cpp (4 995 lines, 52 WiFi refs, calibration / Dev / WiFi / R10 / camera menus). include/App.h (1 091 lines) holds hardware members (SpiDeviceADC, ADCAccessor, TFT_eSPI) that every game reaches for App::currentImpactRatio() and friends. The web build needs a FOOGOLF_WEB cut through these: no scan branch, no boot phase machine (highlights, OTA decision, audio-pack gate), the excluded menus compiled out. This is the largest single piece of work and must not disturb the firmware's scan-loop isolation.
  2. Threads. Eight FreeRTOS tasks (simshare, cast, analytics, lm, luapf, net, wifi, xtrContInit) plus main-loop blocking waits (joinBlocking, mintCodeBlocking, five delay(5); continue; hold loops). Only simshare and luapf matter for the web.
  3. Storage. 36 open-coded Preferences sites in one namespace, direct LittleFS calls in five modules (/lfs/players.bin, putt_*.bin, trend_*.bin, clips.bin, /lfs/extensions/). Shim: a Preferences class and a File class over Emscripten's IDBFS. Mechanical.
  4. Audio. Replace writeI2SChunk with a PCM push into a Web Audio ring (AudioWorklet). drain() from the loop stays. The audio pack ships with the web build as a preloaded asset (the web build is always current, so the signed download path is not needed).
  5. Shot injection is a product decision, not a porting problem. The narrowest door is Game::forward(SensorMessage) + Game::onShot(mps, face). The six existing remote-putt paths (StrokePlayGame::remotePutt/replayShot, MiniGolfGame::..., LuaGame::...) show the minimum shot is {mps, face, aoa, ratio}; the drills also read rotation rate and the two path/lie proportions. GSProIntegration::sendSynthesizedShot already fabricates a bare SensorMessage and calls forward().

Cloud changes needed for multiplayer (small, all modelled already)

What the browser build deliberately will not do

Feature Why Note
GSPro Direct / 8888, Springbok, direct from the page a browser cannot open TCP, not even to localhost See the GSPro decision below: a local bridge.
Analytics forwarding RFC1918 http from an https page = mixed content
LAN cast server, captive portal, WiFi picker, ShotEventServer, CSV import server a browser cannot listen The browser is its own screen.
Remote support cast, waveform uploads, telemetry pointless / excluded /device/poll still useful for the label + ctl.
OTA, BLE, calibration, waveform viewer, memory trace, Dev menu, Serial mode, USB HID excluded by decision

Decisions (user, 2026-09-19)

Staged path

  1. HAL + build skeleton. A wasm/ build in the firmware repo driven by a Python script calling em++ (the repo already builds its native tests that way), a FOOGOLF_WEB define, one shim directory (Arduino.h, Preferences, LittleFS/File, millis/delay, esp_random, heap_caps, FreeRTOS -> pthreads, WiFi.status() -> connected). test/native_stubs/ is the seed. Get esp32-device-core/ui + LVGL + the pure modules linking first.
  2. Cut the god files. FOOGOLF_WEB through App.h/App.cpp, UIController.cpp, Menu.cpp: a web tick that runs _ui.tick, the game tick, GuestMode::tick, serviceSimShareHost and skips scan / boot gate / OTA / BLE; excluded menus removed from the tables.
  3. Display, keys, audio, storage. Flush -> shadow -> canvas; keypad -> RemoteKeys; writeI2SChunk -> Web Audio; IDBFS. Reuse cast-viewer.html as the page.
  4. Putt panel feeding the trimmed beginShot.
  5. Networking. HTTPClient / WiFiClient shim over emscripten_fetch; SimShare's rawPost long poll becomes a fetch; cloud CORS + web registration deployed review-first.
  6. CI. The web build runs on every release beside the native tests so it cannot rot - and it is the browser simulator for extension authors that docs/lua/developer-guide.md promises.

Verification (when built)