Player API reference

Read public state, send intent and render the Engine's committed result.

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, which is what connects a game to Interaction Cloud. 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 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

gamestage client

For most games, import gamestage.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:

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.

MethodPathReturns
GET/play/v1/games/{game}/runtimeRuntime rules and capabilities
GET/play/v1/games/{game}/rounds/currentCurrent public container
GET/play/v1/games/{game}/rounds/{round}Named public container
GET/play/v1/games/{game}/roundsEvery container this game has
GET/play/v1/games/{game}/rounds/mineWhich of them this player has played
GET/play/v1/games/{game}/rounds/nextOne this player has not finished
POST/play/v1/games/{game}/sessionSigned anonymous player session
POST/play/v1/games/{game}/session/restoreRestore a player from a recovery code
GET/play/v1/games/{game}/players/me/recovery-codeWhether this player has a recovery code
POST/play/v1/games/{game}/players/me/recovery-codeIssue a recovery code, replacing any earlier one
POST/play/v1/games/{game}/rounds/{round}/playsCommitted play result
GET/play/v1/games/{game}/rounds/{round}/players/mePlayer state in one container
GET/play/v1/games/{game}/players/me/profileCross-container profile
GET/play/v1/games/{game}/leaderboardRanked 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:

{ "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.

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

Plays

A raw request has this shape:

{
  "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:

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:

CallReturnsWhat 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.