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.mdwins, 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)
- 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.
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 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 ("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:
- Tethered / untethered is persisted (NVS
opTeth). A tethered guest follows every host selection (gen); an untethered guest follows nothing until the host's bulk re-tether (rt, NVSgmRt): automatic at host boot, or Host details > Re-tether all guests. The host refuses the bulk as a whole against a committed game or when its players plus every linked guest's would exceed four; the flash says which. - The local active experience (NVS
lclMode,GuestMode::localActiveMode) is what a tethered guest had before the host changed it (a sim session never is one - the default experience, Metrics, stands in). A guest returns to it, resumed, when the host goes down, stops hosting, refuses its tethering, or when it leaves the host. - The cloud gates tethering (
/sim/join,_tether_refusal): a new guest's join, and an untethered guest'sfol:true, are refused withcommitted(the host's game has a shot in it) orfull(host players + tethered guests' players + the joiner's > 4) while the host plays a GAME (exp.game). The device's task re-sends untethered at once andSnapshot::tetherRefusedtells GuestMode (a bounce comes back linked, untethered; a menu join shows the reason and does not join). Resume tethering asks and follows only once the cloud says yes (Snapshot::tethered). - An untethered guest has left the game:
hostRosterWatchin Stroke play, Mini golf and the Lua runtime treats a device whose roster entry is notfolexactly like a departed one - seats removed, "has left", the round carries on; hostSyncRosterPreShotandbuildOnlineRosterseat tethered guests only. Match play and skins are re-derived from the strokes every time they are shown, so they re-score by themselves; one player left plays Par. Sit-outs and the struck-through names are gone (the Lua seat fieldfollowingis always true). - Committed (
Game::committed(): a shot in and the round not over; Stroke play, Mini golf, an extension that is a game) rides inexp.cm(the host republishes on a change) and freezes the active players on every device in the game (mayChangeActivePlayers, the Players page's toggle and Make current); a host may not start a game its players plus its tethered guests' overfill (mayStartGame, the Play rows of Course play, Mini golf and the extensions). - Host down / stopped:
Snapshot::hostDown(the cloud'sofflinefor a still-hosting host) untethers the guest with a flash;hostStoppedmakes it free ranging; both start the local active experience once the screen is free (tryStartLocal, the same blockers as a follow). - Guests and sim sessions: Sim golf > GSPro > Play refuses on a guest; a tethered guest reaches the host's sim session as the sim guest page (mode 20) as before.
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.