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
- The three god files.
App.cpp(4 926 lines: 81 WiFi refs, 29 Preferences, 30 watchdog calls;App::tickis awhile(true)that never returns and callsscanner.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 forApp::currentImpactRatio()and friends. The web build needs aFOOGOLF_WEBcut 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. - Threads. Eight FreeRTOS tasks (
simshare,cast,analytics,lm,luapf,net,wifi,xtrContInit) plus main-loop blocking waits (joinBlocking,mintCodeBlocking, fivedelay(5); continue;hold loops). Onlysimshareandluapfmatter for the web. - Storage. 36 open-coded
Preferencessites in one namespace, directLittleFScalls in five modules (/lfs/players.bin,putt_*.bin,trend_*.bin,clips.bin,/lfs/extensions/). Shim: aPreferencesclass and aFileclass over Emscripten's IDBFS. Mechanical. - Audio. Replace
writeI2SChunkwith 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). - 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::sendSynthesizedShotalready fabricates a bareSensorMessageand callsforward().
Cloud changes needed for multiplayer (small, all modelled already)
- CORS on the OTA
HttpApi(template.yaml:170isCorsConfiguration: false). Copy the block fromdashboard/template.yaml:98-103withAllowHeaders: [content-type, x-device-id, x-timestamp, x-signature]and the web app's origin. Add the same origin to the extension store's CORS list soGET /e/<CODE>andPOST /e/checkwork from the page. - A web identity.
/register-deviceis open but requires a MAC and runs_purge_reprovisioned(deletes any row with the same MAC plus its shots and S3 waveforms). A browser must NEVER use it with a fabricated MAC. Add akind: "web"registration (no MAC, no purge), keep the UUID + secret in the browser, mark the row socheck_for_updatenever offers firmware and the Devices page can filter. The trust model equals hardware's: a device's secret sits in NVS. - Time. Signing refuses without SNTP; the browser clock is the shim.
- Nothing in
sim_shareneeds to change.
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)
- Putt source: typed or slider values. A panel in the page chrome with club speed, face
angle, attack angle, rotation rate, path/lie proportions and the player's impact ratio,
building a bare
SensorMessage(thesendSynthesizedShotpattern) into a trimmedbeginShot(no host frame, no uploader, no scanner). Recorded-shot replay, a pace mechanic and a scatter generator are NOT in the first version. - Roles: online play host AND guest through
/sim/*, with hardware devices and other browsers. Sim-sharing guest is not asked for in v1. - GSPro on the same PC as the browser: through a local bridge, never directly. A browser
cannot open TCP to
localhost:921either. The route is a small helper program run beside GSPro that exposesws://localhost:<port>and forwards newline-JSON to port 921 (browsers allow an https page to connect tows://localhost).GSProIntegration'sWiFiClientbecomes a WebSocket-client shim in the web build. Second stage, after online play works. - Threads: Emscripten pthreads with
-sPROXY_TO_PTHREAD. The firmware's blocking style survives nearly 1:1 (a synchronousemscripten_fetchis allowed off the browser's main thread,vTaskDelay->usleep, queues -> mutex + condvar) andRemoteKeys's atomics already fit a cross-thread key path. Cost: the hosting page carries COOP/COEP headers (one CloudFront response-headers policy) forSharedArrayBuffer, which also means the page cannot embed third-party iframes. Fallback if it bites: single-threaded Asyncify / JSPI with the task loops rewritten as fetch callbacks.
Staged path
- HAL + build skeleton. A
wasm/build in the firmware repo driven by a Python script callingem++(the repo already builds its native tests that way), aFOOGOLF_WEBdefine, 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. Getesp32-device-core/ui+ LVGL + the pure modules linking first. - Cut the god files.
FOOGOLF_WEBthroughApp.h/App.cpp,UIController.cpp,Menu.cpp: a web tick that runs_ui.tick, the game tick,GuestMode::tick,serviceSimShareHostand skips scan / boot gate / OTA / BLE; excluded menus removed from the tables. - Display, keys, audio, storage. Flush -> shadow -> canvas; keypad ->
RemoteKeys;writeI2SChunk-> Web Audio; IDBFS. Reusecast-viewer.htmlas the page. - Putt panel feeding the trimmed
beginShot. - Networking.
HTTPClient/WiFiClientshim overemscripten_fetch; SimShare'srawPostlong poll becomes a fetch; cloud CORS + web registration deployed review-first. - 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.mdpromises.
Verification (when built)
- A native SDL build of the same HAL for debugging, then the Emscripten build in a local static server with COOP/COEP headers.
- Walk every grid row; play each drill and game from the putt panel; install CFZW5 from the store.
- Two browser tabs + one hardware device in one Course play round and one Mini golf round.
- Reload the page: players, trends, settings and extensions persist.