FooGolf · firmware 0.8.442 · cloud sim_share 2026-09-08

Online Play Wire Protocol

Every message that passes between a hosting device, the cloud and its guests for Online play, the shared Stroke play round and sim sharing, in the order it happens. Bodies are the real field names the firmware sends and the Lambda reads.

Transport Plain HTTP, no TLS on the device. Four routes on one Lambda, reached through the share.foogolf.com CloudFront /sim/* behaviour. The device side is the core-0 SimShare task; it never touches the network while a main-loop TLS call runs.
Every request is signed Three headers over the raw body. The cloud looks the device up by id and rejects a timestamp more than 5 minutes off. X-Device-Id: <id> X-Timestamp: <unix s> X-Signature: HMAC-SHA256(secret, "<ts>." + body)
The two long polls The host holds /sim/poll and each guest holds /sim/join heartbeat, both for up to 20 s. The cloud returns either the moment the session's state tag st changes, so a change anywhere reaches every device within about a second.
sent by the host sent by a guest cloud response, or a held request returning request held by the cloud

1. Hosting, joining and the roster

Nothing hosts by itself. Become a host registers the session; a guest joins it by code. From then on both sides sit in their long polls and the roster syncs through them.

Host devicerole Host, hot-pink LED
Cloudsim_share Lambda · sessions + inputs tables
Guest devicesrole Guest, green following / amber detached
Become a host · GuestMode::startHosting → SimShare::hostStart. "New host code" sends regenerate instead (section 5).
POST /sim/host
{"action":"start","sim":"none|gspro|springbok","name":"<device>","keep_guests":true, "exp":{"mode":11,"gen":N,"cfg":{...}}}
Response {status, code, next_seq, +state}. The code is the device's permanent host code; the persisted experience rides along so a rebooted host does not re-pull its guests.
The host's long poll · sent again the instant the previous one returns; always held, guests or not.
POST /sim/poll
{"code":"NTY2","since":<last seq>,"wait_ms":20000,"name":"<device>","st":"<tag seen>", "replay":false,"players":[{"n":"Andrew","h":4}], "ev":{"seq":K,"kind":"aim|putt|club|repeat","name":"Andrew","steps":-2}}
players = the host's active players. ev = the host's own latest aim tap or shot, for everyone's LED and status line. replay mutes every guest while a shot is replayed. The stamp also keeps the host "online" for guests.
Held up to 20 s. The Lambda queries the inputs FIFO every 0.25 s and re-reads the session row every 1 s; it returns when an input lands or st no longer matches. The device aborts it itself for Stop hosting, a new experience or a round publish.
Join a host · the keyboard's OK validates the code with this same message (SimShare::joinBlocking), then GuestMode::joinHost starts the guest loop, whose first call is another join.
POST /sim/join
{"code":"NTY2","name":"<device>","action":"join","wait_ms":0,"st":"", "players":[{"n":"Bob","h":12}],"fol":true}
Response {status: ok | invalid | offline | full, code, host_label, host_name, +state}. invalid also answers a device joining its own code. Cap: 4 players across the session.
The roster reaches the host · its held poll returns because the guest changed the state tag.
held /sim/poll returns
{status:"ok", items:[], next_seq, +state} with the new device in state.players. The Guests page re-renders in place; a Stroke play round still before its first putt adds the guest's players as seats.
The guest's long poll · every call after the join is a heartbeat, sent again the moment the previous one returns.
POST /sim/join
{"code":"NTY2","name":"<device>","action":"heartbeat","wait_ms":20000,"st":"<tag seen>", "players":[{"n":"Bob","h":12}],"fol":true|false}
fol is this device's following flag. The device aborts the held request itself when it has an input to send, when fol flips, or on Leave host.
Held up to 20 s. Re-reads the session row every 0.5 s; returns when st changes, the host goes offline (status:"offline", plus host_stopped:true after Stop hosting) or the code was regenerated with keep-guests (answers at once with the new code).
Leave host · the held heartbeat is aborted first so this goes out at once.
POST /sim/join
{"code":"NTY2","action":"leave"}
The guest is removed from the row. The host's held poll returns with the smaller roster; a shared round drops that device's seats and everyone flashes "<device> has left" through the round state (section 3).
Stop hosting · the held poll is aborted first.
POST /sim/host
{"action":"stop"}
The row is marked inactive.
every held heartbeat returns
{status:"offline", host_stopped:true, +state}. Guests leave the session and go to role Off. A host that merely drops off the network gives host_stopped:false: guests stay joined with a grey LED until it is back (45 s without a poll = offline).

2. The host drives the experience

The example you asked about: the host nudges the Lag putting distance. Any setting the experience carries in its cfg takes this path, and so does every menu selection, which bumps gen instead.

Host device
Cloud
Guest devices
Up/Down on Lag putting · the setter calls GuestMode::noteLocalSettingChange, which on a host marks the cfg dirty; the main-loop tick rebuilds it and hands it to SimShare, whose held poll is aborted so the publish leaves at once.
cfg rebuilt: stimp, shots, resume + ContinuousSwingGame::exportConfig (csMin, csMax, manual, target)
POST /sim/host
{"action":"experience","sim":"none","name":"<device>","keep_guests":true, "exp":{"mode":9,"gen":N,"cfg":{"stimp":10,"shots":10,"resume":0,"csMin":6,"csMax":30,"manual":1,"target":18}}}
Response {status, code, next_seq, +state}. Same gen, new cfg: a setting change. Up to 24 name→integer entries, keys ≤ 12 chars. Stored on the session row; exp is part of the state tag.
Fan-out · there is no push. Every guest's held heartbeat returns because the tag changed, and the new experience rides in its state.
every held /sim/join heartbeat returns
{status:"ok", +state} with state.exp = {mode:9, gen:N, cfg:{…}}. GuestMode::tick sees the new cfg under the same gen.
Following guest: applies stimp and shots app-wide, then Game::importConfig → the drill's target becomes 18 ft (applyRemoteTarget, or parked in _remoteTargetFt if the page is not up yet). Setters run under applyingRemote(), so they do not count as the guest's own change.
Detached guest: ignores the cfg. Its own hand change would have sent fol:false on its next heartbeat (section 4).
A selection instead of a setting · Play, New, Resume or Basic view on the host: saveGameModeToNvs → GuestMode::localSelection bumps and persists gen, then publishes the same message.
POST /sim/host
{"action":"experience","exp":{"mode":11,"gen":N+1,"cfg":{"stimp":10,"shots":10,"resume":1,...}}}
gen changed: every guest, following or detached, is pulled into mode 11 through Menu_StartExperience when its screen can be taken (never mid-OTA, in the WiFi picker, on the keyboard, in a modal or during a shot). resume:1 makes guests resume their open round too. Guests that follow send fol:true again.

3. One Stroke play round across devices

While the host runs Stroke play the round is shared. The host is the only scorer; guests overwrite their engine from the host's published state and replay other seats' putts from the same physics inputs.

Host devicedeals holes, scores every putt
Cloudround state on the session row · putts in the inputs FIFO
Guest devicesreplay, adopt the verdict
Round published · on activation, every roster change, every putt (twice) and every turn. The held poll is aborted so it leaves at once.
POST /sim/host
{"action":"round","round":{"id":R,"ph":"lobby|play|done","hole":3,"turn":1,"n":4,"nh":9, "ro":[{"n":"Andrew","d":"","c":0,"h":4},{"n":"Bob","d":"Ferocious ferret","c":1,"h":12},...], "ho":[..],"sc":[[2,3,..],..],"rem":[..],"hd":[..],"td":[..],"ord":[..], "shot":{"seq":S,"p":1,"hole":3,"mps":1.9,"face":-0.4,"aoa":0,"ratio":1.65,"v":"","holed":false,"x":0,"y":0,"rem":0}, "note":{"seq":Q,"t":"Ferocious ferret has left"}}}
≤ 1600 chars. ro is the roster with each seat's device ("" = the host) and palette colour. shot goes out first without a verdict so guests start replaying in parallel with the host's animation, then again with v, scores and the next turn. note is the "<device> has left" flash. Stored on the row; part of the state tag.
every held heartbeat returns
state.round (also echoed to the host on its own poll). A new id is a new round: guests apply it whatever they were showing, including the finished-round card.
A guest's putt · accepted only for a seat this device owns and that is on turn; otherwise "Putt ignored" flashes and nothing is sent.
POST /sim/input
{"code":"NTY2","name":"<device>","kind":"putt", "putt":{"mps":1.912,"face":-0.40,"aoa":0.00,"ratio":1.65,"p":1,"hole":3}}
Response {status: ok | busy | invalid | offline | full, seq, +state}. Appended to the inputs FIFO (TTL 120 s) and bumps ev. The heartbeat that was held is aborted so this goes first.
held /sim/poll returns
{"status":"ok","items":[{"seq":S,"kind":"putt","name":"Ferocious ferret","ms":.., "putt":{"mps":1.912,"face":-0.4,"aoa":0,"ratio":1.65,"p":1,"hole":3}}],"next_seq":S,+state}
App::serviceSimShareHost drains it → StrokePlayGame::remotePutt → the host animates and scores it, publishing the round twice as above. The next poll carries since:S.
The host's own putt · no input message; the host scores locally and the two round publishes are the whole story.
publishRound() pre-verdict → animation → publishRound() with verdict, scores, turn
seat not mine → replayShot from mps/face/ratio; seat mine → keep provisional scores until the verdict arrives

4. Following and detaching

A guest that changes a host-driven setting by hand, or starts its own experience, stops following. That state travels in the heartbeat and comes back to everyone in the roster.

Host device
Cloud
Guest devices
Guest turns Lag putting Down by hand → noteLocalSettingChange → following = false, flash "No longer following …", amber LED. SimShare::setFollowing aborts the held heartbeat.
POST /sim/join
{"code":"NTY2","action":"heartbeat","wait_ms":20000,"st":"..","players":[..],"fol":false}
The cloud stores fol on the guest's entry and lists it in state.players[].fol; the tag changes.
held poll and every held heartbeat return
Every device draws that device's players struck through (status page, results, scorecard; the host's Guests page shows link-off). The host marks its seats as sitting the hole out so the lock-step turn never waits on it. Its own game is private: nothing more is sent or applied until Resume following, which re-applies the host's current experience and sends fol:true.

5. Sim sharing inputs and the host code

With GSPro Direct or port 8888 active on the host, guests feed its sim through the same FIFO. Every accepted input becomes the session's event for the LEDs and status lines.

Host devicereplays into GSPro via HID keys / shot sender
Cloud
Guest devicessim page, gameMode 20
POST /sim/input · one of
{"code":"NTY2","name":"<device>","kind":"aim","steps":-3} Left/Right taps (±50) {"code":"NTY2","name":"<device>","kind":"club","club":7} club-selector full swing (0..31) {"code":"NTY2","name":"<device>","kind":"putt","putt":{"mps":..,"face":..,"aoa":..,"ratio":..}} {"code":"NTY2","name":"<device>","kind":"repeat"} repeat previous aim + shot {"code":"NTY2","name":"<device>","kind":"repeat_aim"} repeat previous aim {"code":"NTY2","name":"<device>","kind":"reset"} GSPro Reset Aim (A) {"code":"NTY2","name":"<device>","kind":"mulligan"} Ctrl+M
Response status:"busy" while the host has replay:true: the input is dropped, not queued (every player is muted during a replay). The host's poll delivers accepted items in seq order.
held /sim/poll returns items
aim → HidKeyboard::injectTaps; putt/club/repeat → the integration's shot senders; reset/mulligan → one hotkey tap. Every device's state.ev becomes {seq, kind, name, steps} → LED flash and "<name>: aim left" for 4 s.
New host code · Online play › Become a host › New host code, or the integration menu's New invite code.
POST /sim/host
{"action":"regenerate","sim":"gspro","name":"<device>","keep_guests":true|false,"exp":{..}}
Response carries the new code. keep_guests:true (default) copies the live guests onto the new row and leaves the old row as a redirect (moved_to) for 10 minutes; false deletes the old row so guests read invalid.
held heartbeats return
{status:"ok", code:"<new>", …}. A guest adopts the code from any response and persists it. A device asking the old code within the grace period is redirected the same way.

6. The state envelope every response carries

All four routes append the same block. st is a 4-byte hash over the parts that matter; both long polls compare it with the tag the caller sent.

{
  "players": [
    {"name": "Kindly koala",  "host": true,
     "players": [{"n": "Andrew", "h": 4}]},
    {"name": "Ferocious ferret", "host": false, "fol": true,
     "players": [{"n": "Bob", "h": 12}]}
  ],
  "ev":          {"seq": 41, "kind": "aim", "name": "Bob", "steps": -2},
  "replay":      false,
  "replay_by":   "",
  "host_online": true,
  "exp":         {"mode": 9, "gen": 17, "cfg": {"stimp": 10, ...}},
  "round":       {"id": .., "ph": "play", ...} | null,
  "sim":         "none" | "gspro" | "springbok",
  "st":          "9f3a1c02"
}
players
Host first, then guests seen within 40 s. Device names are the cloud's labels ("Ferocious ferret"), never the body name. Each lists its active players with handicaps; guests carry fol.
ev
The session's last event: every accepted guest input, plus the host's own taps and shots reported on its poll body. Drives the per-player LEDs and the status line on every device.
replay
The host is replaying a previous shot; inputs answer busy and guests mute locally. replay_by names it.
host_online
The host polled within 45 s.
exp
The host's current experience: gameMode, generation, and the cfg block (section 2).
round
The last published online round state (section 3), or null.
sim
Derived from exp.mode (2 = gspro, 4 = springbok) so a guest lands on the sim page.
st
blake2b-4 over players, ev.seq, replay, host_online, exp and round. Changing any of them wakes every held request.

7. Message catalogue

One row per distinct message, with what triggers it and what comes back.

Route · actionSenderTriggerBody beyond code/nameResponse beyond +state
/sim/host starthostBecome a host; host loop (re)registering after a failure or rebootsim, keep_guests, expstatus, code, next_seq
/sim/host experiencehostAny menu selection (gen+1) or a cfg-carried setting change (same gen)exp {mode, gen, cfg}status, code, next_seq
/sim/host roundhostRound activated, roster change, each putt (pre-verdict and verdict), turn change, leave noteround {…} ≤ 1600 charsstatus, code, next_seq
/sim/host regeneratehostNew host code / New invite codesim, keep_guests, expstatus, new code, next_seq
/sim/host stophostStop hostingstatus, code
/sim/pollhostContinuously while hosting; held ≤ 20 ssince, wait_ms, st, replay, players, evstatus ok|invalid|forbidden, items[], next_seq
/sim/join joinguestCode typed (validation) and the guest loop's first callwait_ms:0, st, players, folstatus ok|invalid|offline|full, code, host_label, host_name
/sim/join heartbeatguestContinuously while joined; held ≤ 20 s; aborted for an input, a fol flip, leavewait_ms:20000, st, players, folas join, plus host_stopped with offline
/sim/join leaveguestLeave host; a guest reacting to host_stoppedstatus
/sim/input puttguestAn own seat's putt on turn (online round: with p, hole); a sim guest's puttputt {mps, face, aoa, ratio[, p, hole]}status ok|busy|invalid|offline|full, seq
/sim/input aim / clubguestCorner-button aim taps; club-selector full swingsteps (±50) / club (0..31)status, seq
/sim/input repeat / repeat_aim / reset / mulliganguestSelector rows on the sim pagestatus, seq

8. Timing that shapes what you see

WhereValueEffect
Host poll hold · guest heartbeat hold20 sIdle traffic is one request per device every 20 s; HTTP read timeout 28 s.
Lambda re-reads the session row (heartbeat / poll)0.5 s / 1 sA state change reaches held requests within about a second.
Lambda queries the inputs FIFO (poll)0.25 sA guest input reaches the host within about a quarter second of landing.
Host cfg publishimmediateA setting change is published on the next main-loop tick; a 500 ms poll catches anything else. The scan loop yields for it.
Online round guest tick · host roster watch · strike check250 ms · 1 s · 500 msHow fast a guest applies a new state, the host adds or sits out seats, and strike-through redraws.
Host counted online · guest listed45 s · 40 sA device that stops polling drops out after this; a rebooting host is "unreachable" (grey) to guests meanwhile.
Control POST timeout · retry backoff8 s · 3 s → 30 shost/join/input; after three failures the loop backs off to 30 s so a rejected device does not hammer the cloud.
Inputs FIFO TTL · redirect after a new code120 s · 600 sStale inputs vanish; the old host code forwards guests for 10 minutes.
Signature timestamp tolerance±300 sA device with an unsynced clock is rejected (the task waits for SNTP before its first request).

Not on this page: the boot check-in (which names an unnamed device and carries the OTA offer), shot waveform uploads, the LAN cast and the cloud screen mirror. They use other routes and never carry Online play state. Sources: foodoublebassesp32 src/CLOUD/SimShare.cpp, src/CLOUD/GuestMode.cpp, src/GAME/StrokePlayGame.cpp; foogolf-ota-cloud lambdas/sim_share/app.py; designs in golfclaude docs/guest-mode-design.md, docs/online-round-design.md, docs/sim-sharing-design.md.