Multiplayer game integrity (v2) — winner-credit minting
How the QR multiplayer game (/game/screen/{machineNo}) protects the thing
with money attached: the winner's KX- vend credit. This is the v2
architecture that closed the QA loop's remaining hard cap — a REST-only
client walking create → join → bind-match → report-result and minting a
real credit without any gameplay (live proof: credit KX-FBF5CF2D).
The invariant enforced: no credit mints unless the server independently observed a real round on the bound Nakama match, with the claimed winner among real joined controllers.
1. Screen attestation (who gets a screenToken)
POST /game/api/sessions is the only place a screenToken (the round-report
credential) is issued, and it now requires one of two identities:
| Caller | Attestation |
|---|---|
| Kiosk screen page / emulator | Signed session bootstrap embedded in the server-rendered page. GET /game/screen/{machineNo} mints gat_<b64(machineNo\|issuedAt\|nonce)>.<hmac-sha256> (secret KIOSKX_GAME_ATTEST_SECRET, falls back to the deployment's OAuth signing secret). Single-use (nonce burned on verify), machine-bound, TTL KIOSKX_GAME_ATTEST_TTL (default 120 s). |
| Physical kiosk hardware | Machine device secret: header x-kiosk-secret: <QUESTX_KIOSK_DEVICE_SECRET> — the same shared-secret pattern the QuestX voucher bridge and completion webhook already use (app/routes/play.py, app/routes/device.py). Constant-time compared; path disabled when the secret is unconfigured. |
A bare REST POST from anywhere else gets 401 and never sees a screen token.
The screenToken also rotates on every attested create, so a scraped
page bootstrap cannot silently retain the live screen's reporting identity —
at worst it kills the current screen's token (a visible griefing signal, not
a mint).
Hardware path when the WebView ships
The vending APK's WebView simply loads GET /game/screen/{machineNo} — the
page render carries the bootstrap, so the browser path is attested with no
device changes. If the native shell ever needs to create sessions itself
(headless pre-warm, screen supervision), it sends the machine's provisioned
device secret in x-kiosk-secret exactly like the QuestX bridge calls it
already makes. Per-machine (rather than fleet-shared) secrets are the planned
upgrade when device provisioning grows per-device keys; the seam is
GameService._verify_attestation.
2. Server-authoritative result validation (the hard gate)
Attestation raises the bar but cannot close client-authoritative forgery —
the screen page is public HTML, so its bootstrap is scrapeable. The mint
decision therefore never trusts the reporter. OSS Nakama gives relayed
matches no server-side introspection (GET /v2/match lists only
authoritative matches; presence history dies with the match), so the backend
joins its own matches: when the screen binds a match id
(POST .../match), the backend device-auths its own Nakama identity and
joins that relayed match with a lightweight observer socket
(app/game/observer.py, one thread + websocket per active session; join
retries across the 3 Nakama replicas like every other client).
The observer records first-hand evidence keyed by the sender presence Nakama stamps on every relayed frame (unforgeable without owning that socket):
- the join itself → the match exists on a real pod;
- controller
HELLOframes (sent on connect and re-sent every 5 s as a heartbeat) →playerId ↔ presenceroster; - controller
INPUT/ACTIONframes → who actually played, and when.
record_result refuses to mint (HTTP 409, reason logged and counted) unless:
- the observer joined the session's currently-bound match
(
match-not-observed); - every claimed participant was seen as a presence in that match
(
participant-not-observed); - the claimed participants map to distinct presences
(
participants-shared-a-socket); - the claimed winner sent at least one input frame (
winner-sent-no-input, toggleKIOSKX_GAME_REQUIRE_WINNER_INPUT); - at least
KIOSKX_GAME_MIN_ROUND_SECONDS(default 10) of server wall clock passed after the full roster was observed (round-too-short) — forging a credit costs real round time, per credit.
Rounds that mint nothing (solo practice, abandoned) record without
observation, so a degraded observer never breaks the attract loop — it only
withholds prizes. Validation fails closed: Nakama unreachable ⇒ no mints.
KIOSKX_GAME_VALIDATION=off exists for Nakama-less local dev only.
Why observer sockets (approach chosen) and not alternatives
- Nakama REST/RPC lookup at result time: impossible for relayed matches on OSS Nakama — no roster API, no history. Rejected.
- Server-created authoritative matches (custom Go/Lua match handler): the strongest end state (server-side game loop = server-side scoring) but requires shipping and operating custom Nakama runtime modules; the observer gets equivalent mint-integrity evidence with zero Nakama changes. Documented as the natural v3 if the game grows stakes.
- Trusting screen-reported STATE frames: the screen is the claimant; its broadcasts are recorded but never count as roster evidence.
2b. Solo Challenge — single-player prize mode
Solo is a first-class prize mode, not practice: a lone scanner starts a real
round immediately (no waiting for a second player) and wins the same funded
product credit by reaching the server-owned target score
(KIOSKX_GAME_SOLO_TARGET_SCORE, default 120 = 12 pellets) before the round
timer, without crashing. Losses are free retries; only wins spend budget.
Reward economics (chosen model: free skill challenge, brake-limited).
Solo wins ride the exact same money rails as multiplayer wins — the QuestX
KX- play-credit ledger (source: game-solo-winner), the spec §4 funding
gate (no funder ⇒ prize withheld, game unaffected), and the shared
per-machine velocity cap. Rejected alternatives: pay-to-play (still reserved,
no HTTP surface — adding money intake to solo would recreate the removed
Stripe stub), and "daily free attempt per device" (device identity in a QR
web flow is localStorage-deep and trivially reset — unenforceable, so we
enforce at the machine, where the physical prize actually is).
A single player can loop the challenge, so solo has two extra brakes (per machine, on top of the shared cap):
| Brake | Default | Effect when hit |
|---|---|---|
| Solo win cooldown | KIOSKX_GAME_SOLO_WIN_COOLDOWN = 900 s |
round records, prize withheld with an honest "next solo prize unlocks in ~N min" |
| Solo hourly sub-cap | KIOSKX_GAME_SOLO_VELOCITY_MAX = 2 per KIOSKX_GAME_SOLO_VELOCITY_WINDOW = 3600 s |
round records, prize withheld, counted in mintRefusals.soloCap |
Worst-case giveaway per machine per hour stays bounded by the shared cap (6); a lone farmer is bounded much tighter (2/h, ≥15 min apart) while legitimate multiplayer play keeps its full budget.
Server-authoritative solo-win validation (_validate_solo_mint, fail
closed like multiplayer): the observer must have joined the bound match, seen
the solo player as a live presence, seen ≥ KIOSKX_GAME_SOLO_MIN_INPUTS
(8) input frames from that player's own socket (a real target-score run
takes dozens of steering inputs; a REST replay sends none), and
≥ KIOSKX_GAME_SOLO_MIN_ROUND_SECONDS (15) of server wall clock since
the player was first observed. The floor is deliberately below the honest
minimum (~20-30 s; ~12-15 s for a heavy-boost expert) — a real fast player
must never be refused; the economic bound is the brakes above, the floor
only prices REST replays. The claimed score must also meet the server's own target
(solo-target-not-reached otherwise); the target is published to the screen
via the session API, never accepted from it. Solo losses (no winner
claimed) record without observation, so a degraded observer never breaks the
attract loop — it only withholds prizes.
Lobby / transition rules (screen + controller, app/game/pages.py):
one phone in the lobby ⇒ the controller shows a primary "START SOLO
CHALLENGE" button (BOOST does the same) — there is no dead wait; 2+ phones ⇒
the battle auto-starts as before. A second scanner during a live solo run is
queued ("up next"), never kills the run, and the battle starts right after.
A countdown abandoned down to one player converts to a solo round instead of
a one-snake "battle" (which the server would refuse to reward anyway).
3. Defense in depth
- Velocity cap: at most
KIOSKX_GAME_CREDIT_VELOCITY_MAX(default 6) credits per machine perKIOSKX_GAME_CREDIT_VELOCITY_WINDOW(default 3600 s), sized against the promo funding budget. Hitting the cap records the round, withholds the credit (creditWithheld: "velocity-cap"), and the screen tells players the hour's prize budget is spent — no error. - Refusal telemetry: every refused bootstrap and refused mint logs a
game.mint-refusalwarning with machine, session, bucket, and reason, andGET /api/v1/game/statsnow carriesmintRefusals{attestation, validation, velocityCap}per machine and in totals. - Existing round-1 controls stay: match binding required, participant
validation, one credit per round (replay refused), per-session rate
limits, single-use machine-bound
KX-codes, cross-path double-spend guard (cloud claim vs keypad).
Residual trust assumptions (stated plainly)
- A full game emulation still mints. An attacker who scrapes the page bootstrap, stands up a real Nakama match, joins N distinct controller sockets, sends input, and burns ≥10 s per round is indistinguishable from real players at this layer — the game logic still runs client-side. The velocity cap bounds the damage to the hourly promo budget per machine (which legitimate play could consume anyway); server-authoritative match handlers are the v3 that removes this class.
- The fleet device secret is shared, not per-machine — compromise of one device reveals the hardware attestation path for all (same standing assumption as the QuestX bridge). Per-device secrets are the upgrade.
- Nakama's
defaultkeysocket key is semi-public by design — anyone can auth as a device guest and join a match whose id they know. The observer treats presences as evidence of distinct sockets, not of distinct humans. - Single-process state: attest nonces, observations, and velocity counters are in-memory (matching the STORE design, single uvicorn worker). A restart wipes them; sessions and credits die with them, so nothing becomes mintable that wasn't.
Config reference
| Env | Default | Meaning |
|---|---|---|
KIOSKX_GAME_ATTEST_SECRET |
falls back to KIOSKX_OAUTH_SECRET |
HMAC key for screen bootstrap tokens |
KIOSKX_GAME_ATTEST_TTL |
120 |
Bootstrap validity (seconds) |
KIOSKX_GAME_VALIDATION |
nakama |
nakama = observer-validated mints (fail closed); off = local dev only |
KIOSKX_GAME_MIN_ROUND_SECONDS |
10 |
Min wall clock from full-roster-observed to mint |
KIOSKX_GAME_REQUIRE_WINNER_INPUT |
true |
Winner must have sent an input frame |
KIOSKX_GAME_CREDIT_VELOCITY_MAX |
6 |
Credits per machine per window |
KIOSKX_GAME_CREDIT_VELOCITY_WINDOW |
3600 |
Velocity window (seconds) |
KIOSKX_GAME_SOLO_TARGET_SCORE |
120 |
Solo Challenge win threshold (server-owned) |
KIOSKX_GAME_SOLO_MIN_INPUTS |
8 |
Min observed input frames for a solo mint |
KIOSKX_GAME_SOLO_MIN_ROUND_SECONDS |
15 |
Min wall clock for a solo mint |
KIOSKX_GAME_SOLO_VELOCITY_MAX |
2 |
Solo credits per machine per solo window |
KIOSKX_GAME_SOLO_VELOCITY_WINDOW |
3600 |
Solo velocity window (seconds) |
KIOSKX_GAME_SOLO_WIN_COOLDOWN |
900 |
Cooldown between solo credits per machine |
KIOSKX_GAME_STALE_PLAYER_SECONDS |
45 |
Full roster reclaims a slot whose controller has been silent this long |
Round pace brake
The observer's firstSeen timestamps only bound the first round of a
session — after that they are stale, so back-to-back rounds would sail
through the wall-clock validation gate (observed live: auto-restarting ~5 s
battles minted 3 credits in under a minute). A rewardable round must
therefore also put KIOSKX_GAME_MIN_ROUND_SECONDS (solo:
KIOSKX_GAME_SOLO_MIN_ROUND_SECONDS) of server wall clock between itself
and the session's previous recorded round — win or loss, so a loss
followed by an instant "win" replay is priced too. This is a brake, not
a refusal: the round genuinely happened, so it records normally and only the
prize is withheld, with the honest reason pushed to the winner's phone and
the refusal counted in the operator-visible pace bucket. On the screen
side, the lobby only auto-starts a battle when a newly scanned phone armed
it (the mid-solo queued-joiner handoff); rematches require a fresh BOOST
press, so rounds never loop unattended.
Roster liveness
Kiosk sessions are long-lived (one per machine) and phones vanish without
calling /leave — a closed tab, dead battery, or someone simply walking away.
A full roster therefore reclaims the stalest abandoned slot when a new
scanner joins: liveness is server-observed (controllers HELLO/INPUT over
their own Nakama socket every ~5 s and the observer stamps lastSeen on
every frame; a phone that never opened a socket falls back to its HTTP
joinedAt). Only players silent for KIOSKX_GAME_STALE_PLAYER_SECONDS are
candidates — four genuinely live phones still refuse a 5th join, and an
evicted phone that comes back reconnects by token into a free slot only.