FooGolf developers
Documentation › docs/sim-sharing-design.md

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)

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

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)

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):

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 ": aim left / putt / club shot / repeat shot" status on every device fading after 4 s, legend pinned to the bottom line, and the proxied-Left sign bug in 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 ": reset aim" status. UI wording is "Invite code" (cloud field still 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.