by Intelliverse X

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

record_result refuses to mint (HTTP 409, reason logged and counted) unless:

  1. the observer joined the session's currently-bound match (match-not-observed);
  2. every claimed participant was seen as a presence in that match (participant-not-observed);
  3. the claimed participants map to distinct presences (participants-shared-a-socket);
  4. the claimed winner sent at least one input frame (winner-sent-no-input, toggle KIOSKX_GAME_REQUIRE_WINNER_INPUT);
  5. 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

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

Residual trust assumptions (stated plainly)

  1. 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.
  2. 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.
  3. Nakama's defaultkey socket 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.
  4. 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.