# Play — for agents and third-party apps

Tool list: `agents/tools-play.json`
Adapted from the source matchmaking agent guide (`docs/agents/matchmaking.md` @ 46b22cb31, private source tree) for this
site's one endpoint, `/api/play?op=…`.

Play finds people a game, has every player **sign a match ticket**, runs fair commit-reveal randomness, and issues a
**signed result**. Game kinds: the **fly race** (played in both players' browsers, fully replayable), **CS2** (played on a
dedicated server that signs the result — on this site only a fixture server exists), and **app games** (apps in the apps
registry, played in a locked-down frame, results signed by the app's own registered key).

## The one rule you must keep: no stakes

Lobbies are for play, rating and fun. **No stakes, wagers, entry fees, prizes, skin bets, item prizes, and no payment of any
kind.** Play has no payment hook. Any ticket, lobby rule set, server report or result carrying a money- or prize-like field
(`stake`, `wager`, `bet`, `fee`, `prize`, `payout`, `reward`, `pot`, `buy_in`, `skin_bet`, …) is refused. An app that charges
NEVERS for its own features must not charge for entry to a match and must not pay anything out based on an outcome; its
NEVERS purchases never touch a match.

## Never act for your human without them

- Never accept a ready check, reveal a seed, sign a move or co-sign a result for your human. Those are their signatures,
  made with a key that lives on their device.
- Ranked play needs your human's own 18+ declaration (and the apex age check). You cannot declare it for them.
- Do not link or unlink Steam, or opt a wallet in, unless your human asked you to.

## How a match goes

1. `GET /api/play?op=config` — game kinds, modes, regions, ready-check seconds, the house issuer key.
2. The player's device makes an Ed25519 key and registers its public half: `POST op=key {kind:"session", x}`.
3. `POST op=queue {game_kind, mode, ranked, region}` or `POST op=lobby_create {name, game_kind, mode, size, ranked, is_private}`
   / `POST op=lobby_join {lobby_id | invite_code}`. The lobby list (`GET op=lobbies`) shows fill only ("2 in · starts at 4").
4. Poll `GET op=state` (send `If-None-Match`; unchanged → 304). When `active_match` appears, `GET op=match&id=…` gives the
   ticket. The player signs `"match-ticket:v0|" + ticket_hash + "|" + person_ref + "|" + seed_commit`, with
   `seed_commit = sha256hex("match-seed:v0|" + seed)`, and sends `POST op=accept {match_id, seed_commit, sig}` (or
   `op=decline`). Declining or missing it gives a growing wait before the next search.
5. When the ticket is final: `POST op=reveal {match_id, seed}`. A missing or wrong reveal is a **forfeit**.
6. Play.
   - Fly race: every 600 ms `POST op=moves {match_id, since_tick, moves:[{tick, mseq, intent, sig}]}`; each `sig` is over
     `"match-move:v0|" + match_id + "|" + person_ref + "|" + mseq + "|" + canonical(intent)`. A move may arrive at most
     1.2 s after its tick and at most 3 s before it. The reply carries the other players' final moves. When the heat is
     over, `POST op=finish` — the server replays every signed move and issues the result.
   - CS2: rostered players see the connect string in `op=match`; the server admits only the ticket's SteamIDs and reports a
     signed result (`op=cs2_report`, server-to-server only).
   - App games: the app's own backend signs the result with the app's key; the page's shell passes it on (`op=app_result`).
7. Check anything: `GET op=result&id=…` (the signed envelope, verifiable with the house key), `POST op=dispute`
   (fly race: replay; CS2: demo hash; app: signature), `GET op=notary&day=…` (the day's Merkle root, anchored as a devnet memo).

## Third-party apps (the frame bridge)

Your game runs in `<iframe sandbox="allow-scripts">` on `/play/app?app=<your app id>`. Ask the page by `postMessage`:
`play:quick`, `play:lobby_create`, `play:lobby_join`, `play:lobbies`, `play:status`, `play:result`. You get back only
`{type:'play:reply', ref, ok, match_id, lobby_id, state, lobbies, error}`. The page binds your frame to your app id: you
cannot name another app, see its lobbies or report on its matches. To host matches you need: an **approved** app in the apps
registry, and your result-signing public key registered by the app's owner (`POST op=app_issuer {app_id, issuer_key:{kid,x}}`).
Results you sign: issuer `app.<app_id>`, `body.app_id = <app_id>`, the match id, the match's `ticket_hash`, exactly the
ticket's players; `rated:true` only for ranked matches, with ratings that start from the site's rating book and follow
`elo_k32_v1` (anything else is refused).

## What you will never get

Another player's keys, seeds before they are revealed, wallet addresses (a "verified wallet" tick at most, and only if that
person opted in), connect strings for matches they are not on, the apex account behind a face, or any way to attach money
to a match.
