FooGolf developers
Documentation › docs/online-round-design.md

Online round — one Stroke play round across several devices (2026-09-08)

Layer above Online play (guest-mode-design.md): when a host starts Stroke play, every attached device plays the same round — one roster (players from the host and all guests, max 4), one turn order, one scorecard, one roll of the ball everywhere. Firmware: foodoublebassesp32/src/GAME/ OnlineRound.{h,cpp} (the shared round state + JSON), StrokePlayGame (host / guest / spectator branches), src/CLOUD/SimShare (transport); cloud: lambdas/sim_share/app.py.

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

2. Round state (host → cloud → guests)

One JSON object, published by the host with /sim/host action:"round" whenever it changes, carried in every response's state (round) and part of the st tag, so a guest's heartbeat long poll wakes on it (~1 s):

{ "id": 17,                 // bumps on every New game (host gen)
  "ph": "lobby"|"play"|"over",
  "hole": 3, "turn": 1,     // current hole (1-based), roster index up
  "ro": [ { "n":"Andrew","d":"Cameron","c":0,"h":4 }, ... ],   // roster: name, device label, colour idx, hcp
  "ho": [12, 7, 33, ...],   // dealt tee distance per hole (ft), numHoles long
  "sc": [[2,3,0,...], ...], // strokes per player per hole (0 = not played)
  "shot": { "seq":41, "p":1, "hole":3, "mps":1.9, "face":-0.4, "aoa":1.2, "ratio":1.65,
            "holed":false, "x":0.3, "y":11.2, "rem":1.1 },     // last scored putt + verdict
  "note": { "seq":5, "t":"Grimace has left" } }                // flash notice

Also in the document, added as the games that use it arrived:

Roster membership: a player belongs to the device whose label is d. Players come from each device's active local players: guests send theirs with every /sim/join (players:[{n,h}]), the host with every /sim/poll; the cloud keeps them on the session row and lists them under each device in players.

3. Putt relay

Guest device (its player's turn): captures → animates locally → /sim/input kind:putt with p, hole, mps, face, aoa, ratio → host drains it from its poll → validates (that player's turn, that hole) → scores with its own physics → republishes round with sc, turn/hole advanced and shot (+ verdict). The other devices replay shot when its seq is new (the owning device skips its own). A host player's putt is the same minus the input hop.

4. Roles inside StrokePlayGame

5. Roster before the first putt (fw 0.8.447, bench 2026-09-09)

A player activated on any device's Local players page while the shared round is open but nobody has putted yet joins the round. The host's 1 s roster watch (StrokePlayGame::hostSyncRosterPreShot, replacing the old "joins" loop that only noticed a whole new device) builds the expected seat list — this device's active players, then every guest's players in device order, capped at 4 — and, when it differs from the seats, rebuilds them on the hole already dealt (same tee distance, blank cards, honour recomputed) and publishes the state; guests overwrite theirs from it as usual. Resume still does no local re-key while online (the shared round is the round). A guest's activation reaches the host within ~1 s because SimShare::setLocalPlayers now aborts the held heartbeat when the list changes (the same beatDirty path as a following change; before, the new list waited for the ≤ 20 s cadence). Once a putt is in, the roster is frozen except for leaves. Bench tool in the same release: Settings > Advanced > Reinstall audio pack deletes /lfs/clips.bin (breadcrumb cleared) and restarts, so the boot gate downloads the pack again with the 0.8.446 progress bar.

12. Mini golf on the same model (fw 0.8.498, 2026-09-13)

Mini golf (gameMode 23) shares its round exactly like Stroke play while the device hosts Online play - the user's decision that it is a first-class game, not a drill each guest plays alone. Differences from the stroke round, all inside MiniGolfGame (the engines are separate): the state carries g: 23, the course seed, every seat's ball position bx/by (ft, course coordinates) and the par per hole in ho; the shot record carries ang, the launch angle the aim line gave the putt (radians, aim + face angle), because the sensors give no direction and the aim is set on the owning device; the putt input (/sim/input kind:putt) carries ang as well and the Lambda passes it through (optional, clamped to +-7 rad). Guests replay a shot from the pre-verdict state's positions with that angle and the record's speed / ratio through the same deterministic physics, so every device's ball rests where the host's did. Canonical: foodoublebassesp32/docs/mini-golf-game.md (Online round section).

Aim from any device (0.8.499, made live in 0.8.501). User decision 2026-09-13: while the host and its guests are tethered, ANY device may turn the aim line and every device shows the current aim - yes, that lets any player take over; for the intended audience (seniors with varying cognitive abilities) that is the better trade. A guest's arrow taps go to the host as quarter-degree aim inputs, the host applies them and publishes the state (aim = the seat on turn's line). 0.8.501 fixed the two reasons a change did not show up promptly: a host's own change only published when the ADC scan loop returned by itself (up to the 6 s no-swing timeout) - now MiniGolfGame::wantsTick() yields the loop once the 300 ms debounce is up, and again for a settle republish 1.5 s after the last change; and a guest's inbound input is now a scanShouldYield() term (SimShare::inputPending()), so the host's engine sees a guest's aim or putt within a main-loop pass rather than at the next scan return. A guest ignores the host's echoed aim for 700 ms after its own last tap (aimLocalHold), so a hand on the arrows never sees the line snap back to a stale echo mid-sweep; the settle republish converges it afterwards.

Which device rolls first (question 2026-09-13). On the device screens the hitter always leads by construction: beginRoll queues the putt for the cloud and starts its own roll in the same call, so the host cannot receive the record before the hitter's zoom-in (350 ms) has begun; the host replays ~0.3-0.6 s later (POST + poll wake + main-loop pass) and the other guests after the host's publish (+ the 250 ms onlineTick cadence). What CAN show another device's roll first is the cast viewer: its playout buffer starts at 0.45 s and grows to 2.5 s on a stalling WiFi, so a device watched through the cast page appears later than one watched directly.

14. Sequenced absolute aim (fw 0.8.506 + cloud 2026-09-14)

The relative-step aim relay of section 12 (guest sends +-quarter degrees, host adds them, guest holds its own line for a fixed 2.5 s against the echo) misbehaved on the bench: taps "ignored" by the host and the guest's line snapping back to the host's later. Any lost or merged step left the two apart for good, and an in-flight state could land between the end of the hold and the host's settle republish. Now, at the user's suggestion, aim events are sequenced: a guest sends the resulting ABSOLUTE aim plus its own sequence number (/sim/input kind:aim gains aim + aseq, the Lambda passes them through), the host sets that value and stamps its state with the sender (ad) and seq (as), and a guest skips an echo of its own older aim, takes the same seq as the ack, and keeps its own line against other devices' only while an aim of its own is unacknowledged (5 s bound). Record: foodoublebassesp32/docs/mini-golf-game.md 0.8.506.

13. The turn that jumped without a putt (cloud fix 2026-09-14)

Bench 2026-09-14, Mini golf, host + guest: the host's blue player was on turn, the guest turned the aim with the arrows, the line kept fighting the host's, and the turn suddenly went to the red player with no putt hit. Cause was in the cloud, not the game: /sim/input (and the busy-path touch) refreshed the guest's presence by writing a bare {name, seen} entry over guests.<device>, which dropped the guest's players list (and its fol flag) until the guest's next heartbeat re-sent them. The host's next poll therefore listed the guest with no players, the 1 s roster watch (section 5, hostSyncRosterPreShot - Mini golf has the same one) saw the seats differ from the rosters, rebuilt the round on the dealt hole with a fresh random honour (the device-alternating draw) and reset every aim; the heartbeat then restored the players and it happened again. Every aim step from a guest before the first putt could trigger it, which is why it looked like an echo fight. The Lambda now builds the presence entry from the row's existing entry (_guest_entry), so an input never changes players / fol; scripts/sim-smoke.py joins with a players list and asserts it survives the input burst. No firmware change: the device-side re-sync is behaving as designed, the input it saw was wrong.

15. Extensions share the same round (fw 0.8.570, 2026-09-18)

A Lua extension whose manifest says online = true plays its round across the devices on exactly this wire - no new messages, no cloud change. The runtime (src/GAME/LuaOnline.cpp) owns the round and the relay; the script owns the picture and the roll.

Mini golf (Lua) (store code CFZW5) is the worked example: its state() is {aim, adev, aseq}, its on_input applies another device's aim, and its apply_state adopts the aim, reloads the hole's geometry when the host has dealt a new one, and redraws.