FooGolf developers
Documentation › docs/guest-mode-design.md

0.8.493 (user decision 2026-09-11): the Online play > Guests page described below was removed - Host details (0.8.480) shows the hosting state and live guest count, which was all the user wanted from it. The following/struck-through state still shows on the round pages. Record: foodoublebassesp32/docs/removals-2026-09-11.md.

Online play ("guest mode") — one device drives a group (fw 0.8.424 → 0.8.425, cloud 2026-09-08)

0.8.425 (user decisions, same day): the feature is called "Online play" and the roles are explicit. Controls > Online play: role Off (red LED) offers Become a host (same host code as last time, or a new random one) and Join a host; a Host (hot pink; cell reads "Online play / Host (guests use NTY23)") gets Stop hosting + Guests (list); a Guest (green/amber/grey; cell reads "(guest of NAME)") gets Leave host (NAME); About always. Nothing hosts by itself any more — hosting is a conscious choice (NVS opRole), so a sim host must Become a host before guests can join; "invite code" is now "host code" everywhere and the New invite code row is gone (Become a host > New host code replaces it). Section 2 below describes the 0.8.424 shape where it differs.

Canonical design for the "guest mode" structural feature. Firmware: foodoublebassesp32/src/CLOUD/GuestMode.{h,cpp} on top of src/CLOUD/SimShare.{h,cpp}; cloud: foogolf-ota-cloud/lambdas/sim_share/app.py (+ check_for_update for device names). Sim sharing's own design (sim-sharing-design.md) still describes the sim traffic; this document is the layer above it.

1. Goal (user, 2026-09-08)

A group of people with one person showing them the device: that person's unit (the host) makes every decision — which drill, game, Basic view or golf sim is underway — and every attached unit (a guest) starts the same thing. Guests are then independent (they can finish the drill or start something else themselves), but the host's next selection pulls all of them back in, whatever they are doing.

2. Decisions (user, 2026-09-08)

3. Protocol

The session row keyed by the device's code (foogolf-sim-sessions) gains exp = {mode, gen, cfg}:

Host loop cadence

Every device with guest mode off hosts from boot: /sim/host start once, then /sim/poll. With nobody on the session it is a wait-0 poll every 20 s (keeps the host "online" for a joiner — the cloud's threshold is 45 s — and notices a join within 20 s); with guests it is the 20 s long poll as before. An experience publish aborts a long poll in flight and posts at once. Idle cost ≈ 180 tiny Lambda calls/hour/device.

Firmware

4. Open

5. Round 3 (fw 0.8.426, esp32-device-core v0.2.5, 2026-09-08)

User decisions after the first two-unit bench session:

6. Following status (fw 0.8.435 + cloud 2026-09-08)

User decisions: the host drags its guests into a drill; inside it every device's putts are private, but a setting change the host makes (Lag putting's Up/Down distance, stimp, the round settings, ...) still reaches every following guest. A guest that changes such a setting itself by hand is no longer following: it gets a flash ("No longer following — Online play > Resume following"), an amber LED, and the host's Guests page shows it with a link-off icon (link = following, green). Online play > Resume following (shown only while detached) re-applies the host's current experience and settings at once. The host's next selection still pulls every guest back, as before.

Mechanics: GuestMode::noteLocalSettingChange() is called at the top of every setter the host cfg carries (App stimp / shots per drill, Lag putting min/max, the stroke-engine round settings, Face angle hole distance, Make distance hole style, Arc strength look) and by the drills' Up/Down nudges; it is a no-op unless this device is a following guest and never fires while a host value is being applied (applyingRemote(), set around every cfg import). The guest's heartbeat carries fol, the cloud keeps it per guest and lists it in players, and the host's Guests page (now a custom LVGL page, rebuilt live on roster changes) draws the icon. UIController::showFlash is the app-wide flash (the previous screen comes back after the hold).

7. Bench round 2026-09-08/09 (fw 0.8.436 – 0.8.439)

8. Guest frame on the main menu (fw 0.8.444, esp32-device-core v0.2.6, user decision 2026-09-09)

A guest should see at a glance why the menu is not its own to drive. While GuestMode::role() is Guest, the grid main menu is drawn inside a 2 px yellow (0xFFD600) rounded outline hugging the screen edge — every cell and row label slides inside it — with a fieldset-style legend breaking the top edge at the left: a 14 px padlock then "Guest of " (the host code until the name is known), both yellow, montserrat 14. The padlock is MDI lock-outline while the guest follows the host and lock-open- variant-outline once it has detached (its own Play/New, or a setting changed by hand); the same two glyphs replace the chain-link icons on the host's Online play > Guests page (green closed / amber open, strike- through unchanged). Live: the frame is part of the grid build (UIController::applyGuestFrame before every GridMenuPage::show), and refreshMenusIfOpen already fires on follow / detach, on the host name arriving and on the host stopping (the frame disappears with the role). The indicators on lv_layer_top (capture block + Wi-Fi glyph, x 282..318, y 2..18) are never touched: the frame's top-right corner is painted out in the page colour (setFrameKeepOut(280, 0, 40, 20)), so the line reads as passing behind them. Library: GridMenuPage::setFrame(legend, rgb, icon) + setFrameKeepOut (generic; any product can label a controlled menu). Firmware: src/UI/LockIcons.{h,cpp} (14×14 alpha bitmaps).

0.8.446 (esp32-device-core v0.2.7, user request 2026-09-09): the frame is inset 6 px from the screen edge (it hugged the edge in 0.8.444); the legend strip stays pinned to the top left at y 0, so the top line runs through the legend's vertical middle, fieldset style, hidden behind the strip's page-colour background. The keep-out patch under the indicators is unchanged.

9. Close-in putting tee deck (fw 0.8.450, bench 2026-09-09)

Close-in putting is deliberately not an online round (each device putts its own ten one-putt holes at its own pace), so a following guest only shared the host's min/max range and rolled its own random tees — host 12 ft, guest 11 ft on hole 1. Now the round's ten tees are a deck rolled once at round start (buildCiDeck) and exported in the cfg as ciD0..ciD2 (four 8-bit tees per int). A following guest adopts the deck (_ciDeckRemote: its startHole never re-rolls) and, if its current hole has no putt yet, retargets it in place (ciRetargetHole: tee, remaining, make pattern, label, scene, settle speech — the same routine the UP/DOWN override uses). Hole N is therefore the same distance on every device whatever each one's pace; the host's UP/DOWN override (ciDist) still takes precedence while it is on. ExpConfig::kMax went from 12 to 16 for the three extra keys (the JSON stays well under SimShare::kCfgLen = 200).

10. Synced / Unsynced, and the 0.8.451 batch (user decisions 2026-09-09)

Words. A guest locked onto its host is synced (closed padlock); one that has started something itself or changed a setting by hand is unsynced (open padlock). The menu-frame legend carries the state ("Guest of NAME (synced)"), the Online play row is Resume syncing, the flashes read "Unsynced from NAME / Online play > Resume syncing" and "Synced with NAME", the About page's section is "Syncing". Code names (following, GuestMode::resumeFollowing) are unchanged.

Flash everywhere. UIController::showFlash draws a full-screen panel on lv_layer_top for its 2.5 s instead of loading a screen: a guest's own Play used to load the experience's page over the flash screen a moment later, so the message was only seen where the start was deferred (Make distance). Now every unsync path shows it.

Forget WiFi networks first leaves the host (guest) or stops hosting (host) and waits up to 4 s for SimShare::roleSettled() — the role command posted and the task idle — before dropping the link, so the host sees the guest go at once (not at the cloud's 40 s presence timeout) and a host's guests are freed by host_stopped as with Stop hosting.

Also in 0.8.451: Screen casting opens the LAN cast page directly (the transport page and the Normal / Inverted page are gone; Remote support cast lives under Settings > Advanced), the cast page's address is left-justified clear of the indicators, the Players page/cell is "Local players", and the Stroke/Match HUD's hole box moved left of the colour dot with an empty slot on every other row so the dots stay aligned.

11. Host details page — the host code shows nowhere else (fw 0.8.480, user decisions 2026-09-10)

Problem (user): the host code was on the Online play cell ("Hosting (NTY23)"), the Guests page and the sim page's " to join" row. During a streamed demo anyone quick could join; with a well-known professional playing, thousands might. Decision: the code is displayed only on a page the user opens deliberately.

11.1 Round 2 (fw 0.8.481 + esp32-device-core v0.2.9, user decisions 2026-09-10)