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)
- Lock-step turns. All players play one game and take turns; every device shows whose turn it is; only the device that owns the current player may putt — the others say "Waiting for Carol on Grimace" and ignore a putt taken out of turn.
- Everyone sees the whole roster (host players first, then each guest's players in join order), capped at four for now (the engine and the scorecard's A–D columns are built for four; eight is a scorecard redesign for later). A fixed palette of colours is assigned by roster index and is identical on every device; each player shows a solid colour circle beside the name.
- Stroke play first; Match play later (the USNDP putting test was removed in 0.8.452 — its distance deck is now the default stroke/match deal, and it is shared exactly as before).
- Shot details, not trajectories. The physics (
PuttingPhysics.h) is deterministic, so the owning device captures the putt, animates at once and posts the record {player, hole, mps, face, aoa, ratio}; the host scores it and republishes it with its verdict {holed, resting x/y, remaining ft}; every other device replays the roll from the same numbers and takes the score from the verdict (so a physics tweak on one firmware version can never split the card). - Host-authoritative randomness. The host deals the hole distances (and the USNDP deck / honour order) once and publishes them; guests never roll their own.
- Handicaps and putt history stay on the player's own device; the other devices hold the strokes only for the round.
- Leave mid-round: the host drops that device's players, every device
flashes "
has left" in a large font, the round continues with the remaining players. Join: before the first putt of the round the host adds the joiner's players (space permitting) and republishes; after that the joiner follows into the game as a spectator (sees the round, cannot putt) until the host starts a new game.
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:
gthe game (0 Course play, 23 Mini golf, 24..31 an extension's slot),seedthe course seed,bx/byevery seat's ball,aim/ad/asthe shared aim and who last turned it,shot.angthe launch angle.sctthe scoring type (stroke / match / skins). It wasscuntil fw 0.8.570 - which is the STROKES array, so writing it replaced every score with an integer and each guest rebuilt the round with all scores zero. Mini golf hosts were right and their guests were not, from 0.8.507 to 0.8.569. A state from an older host is still read (sc, when it is a number), andfoodoublebassesp32/test/test_online_roundnow round-trips the whole document on the PC.xan extension's own state (fw 0.8.570): the JSON object a scripted game returns fromg.state(), at most 192 bytes, opaque to everything but that extension. See section 15.
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
- Host (
OnlineRound::isHost()): the existing engine, roster built from the combined list,_holeTeeFtdealt as today then published, turn advance publishes, every putt (local or relayed) scored here. - Guest / spectator: the engine is driven from the round state:
roster, holes, strokes, hole and turn are overwritten from each new
round; a local putt is accepted only for an own player on turn; others' putts are replayed as animations; the scorecard renderssc. - Speech: the "
is up" announcement fires on every device on a turn change; names are spoken as today (spelled name clips exist only for local players — remote players get the generic "next player").
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.
- Which extension. A gameMode names a SLOT (24 + index) and two devices
install their extensions in whatever order they were added, so the host's
experience cfg carries
lx, a 24-bit hash of the extension's share code.Menu_StartExperiencestarts the slot whose extension hashes the same; a guest that does not have it plays privately (and keeps its own settings). - Seats, turns, scoring, replay are the built-in games' rules, applied by the runtime: the host seats every device's active players (four seats), the honour alternates devices, only the device owning the seat on turn may putt (the runtime shows the standard "Putt ignored" notice), the host scores every putt, and every device replays every other device's putt from the same physics inputs.
r:recordreturns"pending"on a guest and changes nothing; the host's next state carries the verdict, and the runtime then calls the script'sapply_state(t)- which doubles as "the round moved under you, redraw".- Guest to host inputs reuse the existing kinds:
aim(absolute, with the sender's sequence),reset_aim,repeat_aim,mulligan. A generic payload would need a new field on/sim/input; nothing needs one yet. - The aim a device last sent rides with its putt (
shot.ang), so a bank shot replays identically even when the last aim step has not landed.
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.