# Player API reference

The player API is the boundary between the browser and the Engine. The browser
renders a challenge and sends intent. The Engine decides whether the play is
accepted, changes player state and returns the committed result.

The current contract version is `2.0`. Local development and the internal
hosted Engine implement the same wire shape.

## What the Gamestage client is built on

`gamestage.js` is wiring over the
[Monterosa JavaScript SDK](https://products.monterosa.co/mic/developer-guide/embedding-experiences/web-1),
which is what connects a game to
[Interaction Cloud](https://products.monterosa.co/mic/core-concepts). It wraps
five of its kits: connect, interact, consent, storage and analytics. Identity is
not among them, deliberately, because a player's token comes from the game's own
login and the Engine validates it against a configured issuer: locally,
`gamestage dev --as` builds that configuration from the manifest's
`identity.jwt`, and a deployed Engine reads its issuer and JWKS from that
deployment's own environment instead.

Keep two facts apart. The
[SDK](https://products.monterosa.co/mic/developer-guide/interaction-sdk) is
documented and released by Monterosa, and only that link tracks its latest
version. What `@monterosa/gamestage` currently pins is **2.0.0-rc.9**, exactly
rather than as a range: SDK 2 has no stable release yet, and a caret across
release candidates takes breaking changes silently.

If you need something the Gamestage client does not expose, the SDK is the thing to reach
for, and `packages/gamestage/src/monterosa.ts` is the one file that touches it.

## Start with the generated client

```sh
gamestage client
```

For most games, import `gamestage.js`:

```js
import { start } from "./gamestage.js";

const game = await start();
render(game.round);

game.onRoundChanged((round) => render(round));

const result = await game.play({ picks: ["alpha"] });
renderResult(result.round_state, result.feedback, result.outcome);
```

`start()` reads `window.GAMESTAGE_API` and `window.GAMESTAGE_GAME`. `dev` prints
them and `deploy` injects them before the page's first script.

The Gamestage client also resolves an optional identity source, gates analytics and
storage on consent, follows platform announcements and re-reads the container
from the Engine. An announcement never carries authoritative container state.

## Use the smaller client when needed

Import `gamestage-player.js` when the game needs only the player API:

```js
import {
  createPlayerClient,
  newIdempotencyKey,
} from "./gamestage-player.js";

const client = createPlayerClient({
  baseUrl: window.GAMESTAGE_API,
  game: window.GAMESTAGE_GAME,
});

const round = await client.getCurrentRound();
const intent = newIdempotencyKey();
const result = await client.play(
  round.id,
  { picks: ["alpha"], roundVersion: round.version },
  intent,
);
```

Reuse the same idempotency key when retrying one lost request. Mint a new key
only for a new fan action.

## Routes

All player routes sit under `/play/v1` so they do not share the creator-authenticated
Backstage prefix.

| Method | Path | Returns |
| --- | --- | --- |
| GET | `/play/v1/games/{game}/runtime` | Runtime rules and capabilities |
| GET | `/play/v1/games/{game}/rounds/current` | Current public container |
| GET | `/play/v1/games/{game}/rounds/{round}` | Named public container |
| GET | `/play/v1/games/{game}/rounds` | Every container this game has |
| GET | `/play/v1/games/{game}/rounds/mine` | Which of them this player has played |
| GET | `/play/v1/games/{game}/rounds/next` | One this player has not finished |
| POST | `/play/v1/games/{game}/session` | Signed anonymous player session |
| POST | `/play/v1/games/{game}/session/restore` | Restore a player from a recovery code |
| GET | `/play/v1/games/{game}/players/me/recovery-code` | Whether this player has a recovery code |
| POST | `/play/v1/games/{game}/players/me/recovery-code` | Issue a recovery code, replacing any earlier one |
| POST | `/play/v1/games/{game}/rounds/{round}/plays` | Committed play result |
| GET | `/play/v1/games/{game}/rounds/{round}/players/me` | Player state in one container |
| GET | `/play/v1/games/{game}/players/me/profile` | Cross-container profile |
| GET | `/play/v1/games/{game}/leaderboard` | Ranked entries and the player's own rank |

## Runtime

`RuntimeInfo` says how this game behaves without exposing Engine internals:

* **`archetype`**: the rule set.

* **`marking`**: `at_commit` or `at_settlement`.

* **`challenge_source`**: shared, per player or feed-driven.

* **`schedule`**: fixed, local midnight, feed-driven or continuous.

* **`updates`**: poll or socket, with a freshness target.

* **`identity`**: whether identity is required and whether anonymous play can later be claimed.

* **`archive` and `capabilities`**: past-container access and named optional features.

Every runtime response also carries `server_time` and the deployed `snapshot`.

## Container reads

The wire type is `Round`. Creator-facing surfaces use the game's container
label instead of hardcoding that word.

The read contains:

* **`id`, `number` and `version`**: identity and optimistic concurrency.

* **`can`**: derived booleans for accepting plays, results available, solution revealed and closed.

* **`challenge`**: public content only.

* **`challenge_instance_id`**: present for a per-player challenge and echoed on the play.

* **`solution`**: present only after the Engine says it may be revealed.

Do not branch on an imagined lifecycle such as `settling`. The four `can`
values are the public capability.

## The pool, and offering another one

A game may hold more than one container. `rounds` returns them all as summaries
carrying no challenge and no solution, each marked `current` if it is the one
`rounds/current` answers with. `rounds/mine` returns what this player has
touched, with `finished` on each: a player who opened a board and left is owed
it back, and one who completed it is owed a different one.

`rounds/next` is the answer to "play another". It returns the whole container
when there is one, and it is worth reading its shape before you use it:

```json
{ "status": "ready", "round": { "id": "...", "number": 2, "challenge": {} } }
{ "status": "exhausted" }
```

**`status` is the point.** A response that simply omitted `round` when there
was nothing left would look identical to one that failed to include it, and
every client would have to decide for itself whether to draw an empty state or
an error. This says which, and the Engine answers 200 either way.

So draw the control only on `ready`. A button offering another round that then
cannot produce one is worse than no button.

Selection prefers a container the player has never opened over one they left
half done, and never returns one they finished or one that has stopped
accepting plays. Ordering and selection are the Engine's, not a store's,
because a query returns rows in whatever order its index gives.

```js
const next = await player.getNextRound();
if (next.status === "ready") show(next.round);
else offerNothing();
```

## Plays

A raw request has this shape:

```json
{
  "idempotency_key": "4f86a7b89a23912d4ce82610",
  "round_version": 3,
  "challenge_instance_id": null,
  "payload": {
    "picks": ["alpha"]
  }
}
```

The result includes:

* **`outcome`**: `accepted`, `rejected`, `duplicate`, `stale` or `busy`.

* **`marked`**: false when the play is committed now and marked at settlement.

* **`feedback`**: typed items with `correct`, `close`, `wrong`, `higher`, `lower` or `unknown` verdicts.

* **`round_state`**: the reducer output after the play, including score, attempts, mistakes, progress and finish state.

* **`retry_after_seconds`**: wait time when the outcome is `busy`.

A busy result is an expected capacity response rather than a server fault. It
can arrive as HTTP 200 so clients do not create a retry storm.

## Player state and profile

Container state holds attempts, mistakes, progress, score, score revision,
finish state and an optional spoiler-safe share card.

The profile crosses containers: played, won, current and maximum streaks,
distribution and any cross-container constraints.

Score belongs to the game. Platform points and settlement belong to the ledger
and are separate concerns.

## Identity

Without a real identity token, the client asks the Engine for a signed anonymous
session and stores it for later visits. The page never invents a player id.

For an identified player:

1. The App Manifest declares `identity.provider: jwt`, an issuer and a JWKS URI.
2. The game supplies a current token source to `start({ identity })` or `createPlayerClient({ identity })`.
3. The client sends it as `Authorization: Bearer <token>`.
4. The Engine validates it against a configured issuer and key set.

Locally, `gamestage dev --as` builds that configuration from the manifest
declared in step 1. A deployed Engine does not read the manifest for this, as
the note on step 4 below explains.

The anonymous session travels separately in `x-gamestage-player`. Do not put a
JWT in that header. Gamestage does not provide a fan login system at any plan.

Step 4 is implemented by the shared JWT validator and exercised by the local
`--as` harness. There, it reads the issuer and JWKS from the manifest. A
deployed Engine reads them from that deployment's own environment instead, not
from the manifest, and a deployment with neither set still refuses every token
rather than trusting one. So identified hosted play is deployment
configuration, not an internal implementation gap.

## Get a player's progress back on another device

An anonymous player's progress lives with the session on one browser. Clear the
browser or change phone and it is gone, unless the player saved a recovery code.
Give every game with anonymous players both halves of this:

```js
import { mountRecovery, start } from "./gamestage.js";

const game = await start({ /* ... */ });
// A "Your progress" button in the footer, opening a dialog with both halves.
mountRecovery(game, document.getElementById("progress"), {
  onRestored: () => location.reload(),
});
```

`mountRecovery` draws the dialog in any page. A Gamestage UI game adds the
`recovery-code` component instead (`npx gamestage-ui add recovery-code`), which
draws the same two screens from the calls below:

| Call | Returns | What it does |
| --- | --- | --- |
| `game.client.getRecoveryStatus()` | `{ has_code, issued_at }` | Whether this player has a code |
| `game.client.issueRecoveryCode()` | `{ code, issued_at, replaced }` | Issues a code, shown once. Issuing again replaces the old one, which stops working |
| `game.client.restoreFromRecoveryCode(code)` | `{ player_id, previous_player: "kept" }` | Switches this device to the player the code belongs to |

A code is four words and two digits, such as `RIVER-CABLE-ORBIT-MAPLE-42`. The
Engine accepts it in any case, with spaces or hyphens. It stores only a keyed
fingerprint of the code, never the code itself, and limits how often a device
can try one.

**When a code is refused**, `restoreFromRecoveryCode` throws a
`PlayerApiError` with the code `code_not_recognised`. A mistyped code and an
unknown one get the same answer, so a refusal says nothing about which codes
exist. Tell the player to check it against the one they saved.

Three limits to design around:

- **Anonymous players only.** A player signed in through your own login
  already has their progress on their account.
- **Restoring does not merge.** The progress this device had before stays with
  the player it belonged to; the device now plays as the restored player.
- **A code is effectively a password.** Anyone who has it can play as the
  player. Say so where it is shown, and never store it in your own analytics.

The words on both screens are Studio settings (`recovery_*`), filled in on the
first deploy from `gamestage.settings.json`.

## Errors

Non-play failures use an error envelope with a stable `code`, a human-readable
`message` and a `retryable` boolean. Branch on the code and retryable flag, not
on prose.

The generated client throws `PlayerApiError` with HTTP status, code and
retryability preserved.

## Deployment packaging

`deploy --file index.html` uploads both generated clients beside the page.
`deploy --dir .` uploads the directory as it exists. Run `gamestage client`
before a directory deploy and verify that imports resolve.
