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)
- Controls > Guest mode is a top-level Controls cell with an LED. Rows: Join a host… (keyboard: the host's invite code) / Leave host, New invite code (only while not a guest), Devices (code, role, roster), About.
- Attach = the device-wide invite code. The code that sim sharing minted per device is now the device's code full stop; "New invite code" moved from the integration menus to Guest mode. A device becomes a host simply by having guests on its code; there is no host-side toggle.
- What pulls guests: starting or resuming a drill, a game, a sim integration or Basic view (it is a drill) — every Play / New / Resume row. Opening a menu, settings pages or the cast page does not.
- What is interrupted on a guest: any experience and any menu, mid-round included (the round stays resumable under HOME-never-resets). Never: an OTA decision or install, the WiFi picker/portal/scan, a modal, the interference page, the keyboard mid-entry, a shot in flight — those wait and the follow lands as soon as the screen is free.
- The host's settings ride along, live. Stimp, shots per drill and the drill's own settings (Lag putting min/max/target, the round settings, Close-in's tee distance, Face angle's hole distance, Make distance hole style, Arc strength look) are published with the experience; a following guest applies them, and a change the host makes inside the drill (Up/Down on the Lag putting distance) moves every following guest at once.
- Following vs detached. A guest that starts an experience itself (Play / New — not Resume) is detached: it keeps guest mode but the host's setting changes no longer reach it, until the host's next selection pulls everyone back in.
- LED colours on the Guest mode cell: hot pink = this device is hosting guests; green = following the host; amber = detached; grey = guest mode on but the host is unreachable; red = off.
- Devices are listed by name everywhere (sim page roster, status lines): the dashboard label. The cloud names every unnamed device with a whimsical adjective + animal ("Ferocious ferret") at its next check-in — new units get one seconds after provisioning; existing blank ones on their next boot. Dashboard renames still work.
- Sim sharing is rewired onto this. The Sim row's "Sim sharing" cell and the join flow are gone; a guest whose host enters GSPro Direct or port 8888 lands on the sim guest page (internally still gameMode 20) automatically, and leaves it when the host moves on. Sessions stay capped at 4 devices.
- Guest mode persists across a reboot: the guest rejoins and follows the host's current experience immediately. A host's generation counter is persisted too, so a host reboot does not re-pull guests.
- The guest's background long poll running in menus and drills alike is accepted (same core-0 plain-HTTP loop as sim sharing, gated off the network during main-loop TLS).
3. Protocol
The session row keyed by the device's code (foogolf-sim-sessions) gains
exp = {mode, gen, cfg}:
POST /sim/hostactionexperience(also accepted onstart) stores it and bumps the state tagst;mode= gameMode (0 = Basic view),gen= the host's selection counter (NVSgmGen),cfg= a JSON object of short name → number.- Every response's state carries
expandsim(derived: mode 2 → gspro, 4 → springbok, else none), so a guest's/sim/joinheartbeat long poll returns within ~1 s of a host selection or setting change. - Player names: the cloud substitutes the device row's label for the body name (host and guests) whenever the device has one.
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
GuestMode::localSelection(mode, resume)is called from every menu selection (saveGameModeToNvs,Action_ResumeGame): host →gen++, publish; guest → Play/New detaches, Resume does not.- Host tick (500 ms): rebuild the cfg (
stimp,shots+ the active game'sGame::exportConfig) and republish when it differs from the last publish. - Guest tick: on a snapshot change, a new
gen→ pending follow →Menu_StartExperience(app, mode)onceApp::guestCanTakeScreen()(mode 2/4 → 20); a same-gen cfg change while following and in the host's mode →Game::importConfig(returns false to be retried when the page is not up or a shot is animating). Game::exportConfig / importConfig(Game.hExpConfig, ≤ 12 name → int32 pairs) implemented by Lag putting (csMin csMax manual target), Stroke play/Match/Close-in (mpHoles mpDistU putMin putMax ciMin ciMax ciDist), Face angle (foHoleFt), Make distance (mdHoleYel), Arc strength (arcLook). Setters persist to NVS on the guest, so the host's settings become the guest's — accepted.- The sims no longer start/stop the host link;
SimSharingGuestno longer starts/stops the guest link — both belong to GuestMode for the session.
4. Open
- Bench verification of the whole flow (two or more units) is pending, and the cloud changeset from 2026-09-08 must be executed (auto mode cannot apply CloudFormation changes).
- "New invite code" while hosting a drill session: guests are carried over (default) or removed, as before; the wording now points at the Devices page.
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:
- Guest crash loop fixed (best diagnosis, not yet bench-confirmed). A
guest following into the game it was already in (e.g. a rebooted guest
restoring Stroke play, then the host's Stroke play arriving) re-ran
setGameon that game while its own page was the live LVGL screen —deactivate()deletes that page, which a menu row never does with the page up.Menu_StartExperiencenow stops the current experience the way HOME does, parks LVGL on a blank screen, then switches, and drops the parking screen once the new experience has loaded its own. If the loop persists, the Device status "Last crash" line is the next clue. - Rows: the grid gains an Online row (WiFi, Screen casting, Online
play) ahead of Controls; without WiFi the two cells after WiFi carry
MenuItem::disabledand are drawn in the style chosen at Settings > Visual settings > Disabled cells: Grey / No-entry sign (red ring + slash) / Dimmed / Struck through (GridMenuPage::setDisabledStyle, NVSdisStyle; exploration — pick one later). "Players" is Local players. - Online play cell: "Online play / Hosting (NZYT)".
- Join a host goes straight to the keyboard, prefilled with the last
host code and with OK focused (
KeyboardPage::show(..., focusOk)), so the same host again is a single OK. The success page is just "Joined"; the follow into the host's experience is held ~2.5 s so it can be read. Leave host / Stop hosting just re-render the Online play menu (no page). - Always an active experience; a new device's is Stroke play (NVS
gameModedefault 11). - Games are resumable, drills are not.
StrokePlayGame:: setResumeOnActivatelets a round survive another experience in between: the Stroke/Match submenus lead with Resume + New game whenever a round is open (GameMenuSpec::resumable), the per-variant Resume re-selects the game without a reset, and a host's Resume is published as cfgresume:1so following guests resume their own open round (a host in a game → Lag putting → Resume brings every guest back to its game with scores intact). Close-in (15) is a drill and never resumes. - ContentPage heading shadow now paints over the body on every page
(the strip is moved to the foreground in
show()), which is what the missing "decoration line" on the scrolling intro page was.
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
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)
- Latency (0.8.436). Setting changes on the host and a guest's detach
took 4–5 s to show. Three causes, all fixed: the ADC scan loop did not
yield for cloud state (now
App::scanShouldYield()includesGuestMode::wantsTick()and aSimShare::snapshotVersion()change); the host published a changed cfg only at its 500 ms poll (now at once,s_hostCfgDirty); the guest'sfolflag rode the next 20 s heartbeat (now the held heartbeat aborts and resends,s_beatDirty). - Lag putting target (0.8.437). A following guest started the drill
on its own random distance.
importConfigapplied the host's target only when the host'smanualflag was set; the host's target is now the target regardless, and one that arrives before the drill page exists is parked in_remoteTargetFtand consumed bygenerateTarget()at activation. - End of round on a guest (0.8.438). The guest ran its own card countdown, restarted a local round, spoke the opener and re-applied the host's still-finished round — a card → opener → card loop. A guest now holds the card ("Waiting for the host to restart"); the host's new round id ends it.
- Boot order (0.8.439). After both devices rebooted (host first),
the host's roster showed only its local players.
App::beginrestored the game (activating Stroke play) beforeGuestMode::beginhad read the role from NVS, sorole()was Off and the host's round activated offline — never published, never accepting joins — while the guest read the cloud's stale pre-reboot round (hence it "saw everyone").GuestMode::loadRole()now runs before the restore;begin()still starts the cloud loop afterwards. Any future boot-time code that asksGuestMode::role()must sit afterloadRole(). - Not-following shown everywhere (0.8.440, user decision 2026-09-09).
When a guest wanders off (its own Play/New or a hand-changed setting),
its players are drawn struck through on every device in the session:
the Stroke play status page (current player card and turn queue), the
results page, the scorecard names and the host's Guests page (beside
the link-off icon).
StrokePlayGame::strikeMask()builds a bit per seat — own seats fromGuestMode::following(), the others by looking the seat's device up in the cloud roster (fol) — andonlineTickredraws the status page when the mask changes (checked every 500 ms). The sim page's single players label is not struck (one label, no per-name decoration). - An unfollowing guest affects nobody (0.8.441, user decision
2026-09-09). Bench case: a guest in the host's Stroke play round went
through its own menus and started Stroke play — it detached (struck
through on the host) yet still saw the whole roster and its putts still
landed in the host's round. Now: a guest's own Play/New activates a
private round with its local players only (
activatesets_onlineonly for a host, a following guest, or a follow being applied); a guest detached mid-round by a hand-changed setting goes private on the next idle tick (goPrivate: remote seats removed, own scores kept; a spectator with no seat deals a fresh private round). On the host the detached device's seats stay in the round, struck through, and sit each hole out:hostApplySitOuts(at every hole start and on the 1 s roster watch) marks them holed with no strokes, so the lock-step turn moves on; the card shows a dash andtoParCompletedskips stroke-less completed holes. From the first hole after the guest follows again (Resume following re-applies the host's round) its seats play normally. Match play is still local-only (the USNDP putting test was removed in 0.8.452).
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 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.
- Online play rows: Off → Become a host / Join a host; Host → Host
details / Guests; About always. Both Become a host and Host details
open the same page (
HostDetailsPage,UIController::showHostDetailsPage). The menu's Stop hosting row and the Same-code / New-code list are gone. - The page: the sim page's blue selector (one label per row on a white
highlight bar) with Start hosting / Stop hosting / New host code; a
row that does not apply (Start while hosting, Stop while not) is drawn
dimmed (0x8FA8D8) and the cursor skips it. Under it the host code in 32 pt
gold (0xFFD700) — "assigned when hosting starts" or "assigning..." while
empty. The right-hand side is an account-group-outline icon built from
LVGL border-only circles and rounded rects clipped by their container (no
image asset or converter exists in the repo; the front figure is filled
black so it occludes the two behind), and beneath it "N guests" (or "Not
hosting").
UIController::tickre-renders in place when the SimShare snapshot version, the own code or the role changes, so the count and a freshly minted code land live without moving the cursor. - Actions: Start hosting →
GuestMode::startHosting(false); Stop →stopHosting(); New host code →startHosting(true)— while hosting the cloud keeps the guests and redirects them (moved_to); while not hosting it starts hosting on the fresh code (the user's spec listed the row as available in both states; this is the reading taken — revisit if a code-only mint without hosting is wanted). BACK/HOME leave vialv_async_call; the page is guarded inrefreshMenusIfOpenand released with the other transient screens. - Removed displays:
GuestMode::cellLabelhost branch is "Online play / Hosting"; the Guests page has no code line;GuestMode::guestsText(dead) deleted; the sim page's join row + account-plus icon are gone; the join hints read "Ask the host for the code on their Host details page". - Also 0.8.480: "Players" (not "Local players") for the Controls cell +
list title; a player deleted on this device mid-round leaves the round
at once in every mode (
StrokePlayGame::dropDeadLocalSeatsfromtick()); the Replay speed setting is gone.
11.1 Round 2 (fw 0.8.481 + esp32-device-core v0.2.9, user decisions 2026-09-10)
- New host code while stopped stays stopped.
SimShare::mintCodeBlockingPOSTs/sim/host action:regenerate keep_guests:false(the old row is deleted — nobody is on it while stopped) and thenaction:stop, because the cloud's regenerate opens an active session row. Blocking on the main loop with the task WDT paused, likejoinBlocking.GuestMode:: mintNewHostCodestores + persists the code; the page shows it at once. A failure leaves the old code and flashes "Could not get a new code". While hosting, New host code still regenerates in place with the guests carried (startHosting(true)). - Icon 100×82 (was 118×96); the count line under it reads "Hosting: N guests" / "Hosting: 1 guest", right-aligned; "Not hosting" when off.
- Keyboard Cancel key (library
KeyboardPage, v0.2.9): its own row at the top left, level with the entry text; the title and entry moved right of it (entry tail 22 chars). ESC is "move left" on the keyboard, so Join a host, WiFi passwords and player names had no exit but HOME. Cancel =onDone(nullptr)+ the back callback; focus opens on the first character row (Cancel is one UP away);focusOkstill opens on OK.