Sim sharing — several devices feeding one GSPro session
Status: design agreed 2026-09-04 (this session); cloud + firmware built the
same day. Cross-repo: foogolf-ota-cloud (endpoints, tables, CloudFront
behavior) and foodoublebassesp32 (host/guest firmware). Firmware-side
details live in foodoublebassesp32/docs/sim-sharing.md.
1. Goal
One device is physically attached to the PC running GSPro (over TCP 921 — "GSPro Direct" — or Springbok on port 8888). Other FooGolf devices in the room join that device's session with a share code and play into the same sim: their Left/Right aim taps, their club-selector shots and their real putts all reach GSPro exactly as if they had been made on the host device.
2. Decisions (user, 2026-09-04)
- Long-polling, not push. The device has no inbound channel and cannot
hold a TLS stream (see
serverless-screen-mirror-design.md§2). The host device holds a plain-HTTP POST open for up to 20 s from a core-0 task; the cloud answers as soon as an input is queued. This is a background network loop only while a sim integration is active; it is not an OTA check-in and does not touch the deterministic-OTA rule. - The share code is per device and permanent. Minted by the cloud the
first time the device hosts, stored on the device row (
sim_code), shown on the sim page every time. "New invite code" regenerates it (if it leaked); since fw 0.8.416 + cloud 2026-09-07 it asks "New code, leave all guests" (default: the joined guests are carried onto the new code and redirected to it) or "New code, remove all guests" (the old row is deleted). - No stimp anywhere in sim modes. The sim owns the green speed. Sim experiences (GSPro Direct, GSPro port 8888, Sim sharing) don't announce or pass stimp.
- Impact ratio = the guest device's current player. A proxied putt
carries the ratio of whoever is the current player on the guest device
(per-player
impactRatioPct, falling back to the device's global value). - Same club list and Up/Down selector on the guest, including "Repeat previous shot". The selector now opens with Cancel (row 0) focused on host and guest alike.
- One sim page for host and guest replaces the green "Waiting for putt" page: combined widget bottom-right (after a putt), aim/club-shot key hints, the share code, the list of players sharing the session, and a status line ("Putt from Bob", "Putt sent", "Sharing offline").
- Host feedback is a line on that page, not its own screen: "Putt from
". - Inputs are one FIFO per session — the host drains in order; concurrent guests interleave.
- "Repeat previous shot" is session-wide (user, 2026-09-04, fw 0.8.395):
from any device it repeats the last shot that reached the sim, whoever
made it, preceded by the net aim taps from any device since the shot
before. The host's GSPro integration is the record of truth (every
proxied tap and shot passes through it); a guest sends
kind: repeat. - Sim sharing is gameMode 20, a first-class experience: only one of GSPro Direct / port 8888 / Sim sharing / a drill is active at a time. A guest reboot re-joins the saved code if the host is still up, otherwise lands on the code-entry page. A host reboot re-registers its session on activation (boot restore or menu Play).
- Bench: the user has several units, so both roles are tested on hardware.
3. Architecture
guest device ── POST /sim/join, /sim/input ──┐
▼
share.foogolf.com (CloudFront, allow-all, /sim/*)
│ https
▼
us-east-1 HttpApi → sim_* Lambdas
│
foogolf-devices.sim_code ─┤─ foogolf-sim-sessions (code)
└─ foogolf-sim-inputs (code, seq) TTL 120 s
▲
host device ── POST /sim/host (start/stop/regenerate) ┘
── POST /sim/poll (held ≤ 20 s) ─────────┘ → items → HID taps / writeShot
Every call is HMAC-signed with the existing device headers
(X-Device-Id / X-Timestamp / X-Signature over "<ts>." + body). The
cloud verifies against foogolf-devices as every other endpoint does.
Endpoints (all POST, JSON, path prefix /sim/)
| Path | Who | Body | 200 body |
|---|---|---|---|
/sim/host |
host | {action: start\|stop\|regenerate\|heartbeat, sim: gspro\|springbok, name} |
{status: ok, code, next_seq, players} |
/sim/join |
guest | {code, name, action: join\|leave\|heartbeat} |
{status: ok\|invalid\|offline, host_label, sim, players} |
/sim/input |
guest | {code, name, kind: aim\|club\|putt\|repeat, steps, club, putt:{...}} |
{status: ok\|invalid\|offline, seq, players} |
/sim/poll |
host | {code, since, wait_ms} |
{status: ok, items: [...], next_seq, players} |
players is [{name, host: true}, {name}, ...] — the host's current player
first, then guests seen within the last 90 s. An unknown code returns
status: invalid with HTTP 200 so the device parses one shape; offline
means the session exists but the host hasn't polled for 45 s.
Input items: {seq, kind, name, steps: ±n, club: idx, putt: {mps, face, aoa, ratio}}.
putt.mps is club speed in m/s, face the face angle at the ball (deg, +
right), aoa attack angle, ratio the guest's impact ratio.
Tables
foogolf-sim-sessions— HASHcode.host_device_id,host_label,host_name,sim,active,last_seen(host poll heartbeat, unix s),next_seq(atomic counter),guests(map device_id →{name, seen}). No TTL: the row is the code's home.regeneratewithkeep_guests=falsedeletes the old row; withkeep_guests(default, 2026-09-07) the live guests are copied onto the new row and the old row is kept as a redirect (moved_to,moved_at,active=false): a device listed in itsgueststhat calls/sim/joinor/sim/inputwith the old code within 10 min is served on the new code and toldcode= the new one; everyone else readsinvalid. A/sim/joinheartbeat held in the long poll returns at once when the row gainsmoved_to.foogolf-sim-inputs— HASHcode, RANGEseq(N). TTLexpires= now + 120 s. The host trackssinceitself (it starts at the session'snext_seqwhen it registers, so a rebooted host never replays stale taps).foogolf-devices.sim_code— the device's permanent code.
Long poll
sim_poll has Timeout: 25; it queries the inputs table every 250 ms for up
to wait_ms (capped at 20 s) and returns the first non-empty page (≤ 25
items). API Gateway HTTP API caps integrations at 30 s and CloudFront's
default origin read timeout is 30 s, so 20 s + overhead fits without
touching either. The host's HTTP client uses a 28 s timeout.
Cost at bench scale: ~80 eventually-consistent reads + one 20 s × 256 MB Lambda per idle poll ≈ $0.02 per host-hour. Latency for a tap ≈ guest POST (~300 ms NZ→Sydney edge→us-east-1) + ≤ 250 ms queue poll + host response in flight ≈ 0.5–0.8 s.
4. Firmware shape (summary — details in the firmware doc)
src/CLOUD/SimShare.{h,cpp}— one core-0 task (16 KB PSRAM stack, like ScreenCast), plain HTTP only. Modes: Off / Host / Guest. Host mode:/sim/host startthen the/sim/pollloop; items are pushed to a queue the main loop drains (SimShare::tick). Guest mode: a send queue of inputs posted in order, plus a 30 s/sim/join heartbeatthat refreshes the player list. Both publish aplayerssnapshot + status text the page reads under a version counter.- Host drain (main loop, in order):
aim→HidKeyboard::injectTaps(n)(paced 80 ms, tallied like real taps);club→GSProIntegration::sendClubShot(ignored on a Springbok host);putt→GSProIntegration::sendProxiedPutt/SpringbokIntegration::postProxiedPuttwith the guest's ratio. A shot item waits until injected taps have drained and settled. src/GAME/SimSharingGuest.{h,cpp}— gameMode 20.forward()posts the putt; Up/Down open the club selector (when the host sim is GSPro Direct) whose rows postclub/repeat(session-wide: the host replays its own record of the last shot + preceding taps, from any device); bottom-left/right postaim ±1and play the key tick.wantsKeyboardHid()is false — nothing goes out over USB from a guest.src/UI/SimSharePage.{h,cpp}replacesWaitingPagefor all three sim experiences. OK on the host page opens a two-row list (Cancel / New share code); HOME opens the menu.- Menu: Sim row gains a "Sim sharing" cell (Join / About). Join shows the
explanation page, OK opens the keyboard prefilled with the last code (NVS
simCode), the code is validated synchronously via/sim/join(invalid → "Code XXXX not found" page; offline → "That device isn't sharing right now").
5. Round 2 (2026-09-06) — LED, replay mute, live sync, 4 players
Cloud protocol additions (Lambda lambdas/sim_share/app.py, deployed
2026-09-06; smoke test scripts/sim-smoke.py covers them):
- Every response carries session state:
players,ev{seq, kind, name} (the session's last event — every accepted guest input, plus the host's own aim taps / putts / club shots / repeat reported on its poll body asev),replay(+replay_by),host_online, andst, a 4-byte blake2b tag over players + ev seq + replay + online. - Long polls wake on the tag.
/sim/poll(host) takesst,replayandev, and returns early when items arrive or the tag changes (session row re-read every 1 s)./sim/joinheartbeat(guest) takeswait_ms(≤ 20 s) andstand holds the same way (re-read every 0.5 s), answeringofflineat once if the host drops. Guests are considered gone 40 s after their last request (was 90). - Replay mute: while the session row's
replayis true,/sim/inputanswersbusyand queues nothing. The host clears the flag on its next poll after the shot went out; a hoststartresets it and the host's local event counter (host_ev_seq). full: a guest not already on the live list is refused when three guests are live (_MAX_PLAYERS= 4 incl. host), on join and on input.- Items delivered to the host carry
ms(cloud creation time) so the host can drop inputs created inside its replay window.
Cost note: a session of four now holds four Lambda invocations open
continuously (one long poll each); at bench scale that is well inside the
free tier — see aws-cost-ballpark-2026-09.md.
Firmware side: foodoublebassesp32/docs/sim-sharing.md "Round 2".
5.1 Fixes after bench (fw 0.8.403 + cloud 2026-09-06)
Cloud: the event carries the aim direction (ev.steps, from the guest
input's steps or the host poll body's ev.steps). Firmware: one LED per
player row, pulses fired from the keystroke itself (tap hook), the same
"HidKeyboard::injectActive() — see the firmware doc.
5.2 Reset aim + invite code (fw 0.8.404 + cloud 2026-09-06)
Input kind reset (no payload) = GSPro's A / Reset Aim hotkey, tapped
by the host for whichever device asked; event kind reset feeds the LEDs
and the "sim_code); guests no longer show a "Host:" line.
5.3 Repeat aim + Mulligan (fw 0.8.405 + cloud 2026-09-06)
Input kinds repeat_aim (last shot's aim taps only, no shot) and
mulligan (GSPro Ctrl+M). "Repeat previous shot" is now "Repeat previous
aim + shot". The HID report carries a modifier byte since this release.
5.4 Sim page relaid (fw 0.8.406)
Choice selector (repeat / aim at pin / mulligan / clubs) always on the page, blue with a scrollbar, no Cancel rows; game name right-justified, players + invite code + status on the right; host OK menu gone ("New invite code" in the integration's menu). Cloud unchanged. Firmware doc: 0.8.406.
5.5 Presence writes keep the guest's players (cloud 2026-09-14)
/sim/input and the replay-busy touch wrote guests.<device> as a bare
{name, seen}, wiping the guest's players + fol until its next
heartbeat. Harmless for the GSPro relay, but a host running a shared round
re-dealt it on every guest aim step (Mini golf turn jump - see
online-round-design.md section 13). Every presence write now starts from
the row's existing entry (_guest_entry in lambdas/sim_share/app.py);
the smoke test asserts it.