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

2026-09-26 (fw 0.8.623 + cloud): brought onto the first principles. Sections 1, 2 ("the host's next selection pulls all of them back", "Following vs detached") and the 0.8.440 / 0.8.441 decisions in section 7 (struck-through players, sit-outs) are HISTORY: docs/first-principles.md wins, and the "2026-09-26" section at the end says what the code does now.

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)

Standing rules (moved here from golfclaude CLAUDE.md, 2026-09-26)

These are the rules and user decisions that lived in the cross-repo CLAUDE.md until 2026-09-26; that file now carries a short summary and points here. This section is canonical for them - keep it current when the behaviour changes.

Online play and the online round

Canonical: docs/guest-mode-design.md (this repo, sections 5-11), docs/online-round-design.md (this repo, sections 5-15), docs/online-play-wire-protocol.html (hand-written message audit - regenerate when the protocol changes), docs/sim-sharing-design.md + foodoublebassesp32/docs/sim-sharing.md (these predate the first principles; where they differ, the principles win).

The model (free ranging / linked, joining, tethering, leaving a game, committed games, max players, a host or guest bouncing) is the first principles - none of it is restated here. Implementation decisions: - NVS opRole holds the linkage: Off (free ranging) / Host / Guest; NVS opTeth the tethering (a guest that bounces comes back as it went down); NVS lclMode the local active experience; NVS gmGen / gmRt the host's selection and bulk re-tether counters. The term is "host code"; the host code is shown ONLY on the Host details page (HostDetailsPage) - never on the cell, a Guests page or the sim page. The Online play cell opens the page DIRECTLY. Rows: Off -> Start hosting / New host code / Join a host / About; Host -> Stop hosting / Re-tether all guests / New host code / About; Guest -> Leave host / Resume tethering (while untethered) / About. New host code while hosting keeps the guests (moved_to redirect); while not hosting it mints a code and hosting stays stopped. - A guest's grid is drawn inside a 2 px yellow frame with a padlock + "Guest of " legend (closed = tethered). The guest cell reads "Guest of" over the host name. Devices are listed by name everywhere (the cloud names unnamed devices adjective + animal at check-in). - Every random player order alternates devices (OnlineRound::dealHonour, user rule). - Wire: a host's every Play / New / Resume publishes exp = {mode, gen, cfg, game, cm, rt} (game = a game as opposed to a drill or sim session, cm = committed, rt = the bulk re-tether counter); a TETHERED guest takes a follow only when it can take the screen (never during OTA, the WiFi picker, a modal, the keyboard mid-entry or a shot - App::guestCanTakeScreen); an untethered guest ignores gen until rt changes. Settings ride along live via Game::exportConfig/importConfig; a guest that changes a host-driven setting by hand untethers with a flash (first principles). Games are resumable across drills, drills are not. The cloud carries players / fol forward on every presence write and refuses a tethering join or re-tether (fol:true) with committed / full - the device then stays linked, untethered (Snapshot::tetherRefused). - An untethered guest has LEFT the host's game (first principles): the host's roster watch removes its seats like a departed device's (" has left"); there are no sit-outs and no strike-through any more (hostApplySitOuts / strikeMask deleted 0.8.623). Match play and skins re-derive from the remaining cards (one player left plays Par). - Host down (the cloud answers offline for a host that is still hosting): the guest untethers, stays linked with no timeout and returns to its local active experience; the host re-tethers every linked guest when it comes back up (retetherAll at GuestMode::begin). Stop hosting makes every guest free ranging. - The gates (GuestMode::mayStartGame, mayChangeActivePlayers, retetherAll, and the cloud's _tether_refusal): a host may not start a game its players plus its tethered guests' overfill; nobody changes their active players while their game (or, tethered, the host's) is committed; activating a player must fit in max players while the host plays a game. - Online round: roster = max players, fixed-palette seat colours, lock-step turns, the host scores every putt (guests' via /sim/input kind:putt), publishes OnlineRound::State first without a verdict (guests replay the roll from the same deterministic physics) then with it; guests overwrite their engine from it. Before the first putt the host re-syncs seats to the live rosters every second. The status page's current-player card is ONE 14 pt row like the queue rows (no big fonts in the HUD). - Forget WiFi networks first leaves the host / stops hosting and waits for the command to land. - The aim relay in Mini golf is SEQUENCED (absolute angle + seq, ad/as in the state), not timed; while tethered ANY device may turn the aim and every device shows it (user decision, for seniors of varying cognitive ability). - NetActivity means "a blocking call on the MAIN LOOP" and is never a general network mutex: the uploader's S3 PUT lights the Wi-Fi indicator through its own sink (ShotUploader::setBusySink), because raising NetActivity from the worker task tore down the guest's held long poll. Read Link gated: / Round lag: on Device status. Canonical: foodoublebassesp32/docs/online-link-stall-2026-09-19.md. - Diagnosability rule: both Env rings must say why a follow did NOT happen (OnlinePlay: not following yet - <why>, App::guestScreenBlocker(), a host log for the not-hosting case). The cloud logs sim host experience unconditionally - check it first when "the guest ignored me". - Sim sessions with guests (the first principles say who may hit and what flows): the host's and its tethered guests' putts / aim taps / club-selector shots reach the connector in FIFO order (keys cloud -> connector, shots via the host); sim modes never announce or use stimp; the club selector opens on Cancel; the replay mute is GONE - never re-add a device-side mute, the connector's FIFO key queue orders everything; the core-0 task never touches the network while a main-loop TLS call runs (boot gate + NetActivity busy hook) - keep any new core-0 network work behind the same gate. Inline #RRGGBB# recolour does not render on the device - do not rely on it for labels. Smoke: foogolf-ota-cloud/scripts/sim-smoke.py.

2026-09-26: the first principles (fw 0.8.623, cloud 2026-09-26)

docs/first-principles.md is the model; first-principles-gaps-2026-09-26.md lists what was wrong on 2026-09-26 and what closed it. In one place, what a device does now:

Bench (two units, pending): a guest's own Play while the host plays a drill (untethered, no follow on the host's next selection, follows on Re-tether all guests); the same mid-round (the guest's seats leave the host's round, the host's card shrinks, match play re-scores); Resume tethering refused after the first putt ("game under way"); a fifth player refused on the host and on a tethered guest; the host powered off mid-drill (guest back in its own experience within ~45 s, re-tethered when the host is back); Stop hosting (guests free ranging); a guest rebooted during a committed game (comes back linked, untethered, in its own experience).

User decisions (2026-09-26, after reading the round): a guest that bounced during a committed game is judged by whether the host's game is committed when it comes back, not when it went down; Leave host returns the guest to its local active experience; the Lua seat field following stays, always true. The cloud changeset for this round was executed by the user the same day.