# Async opponents

Not every game needs an opponent, and this is a capability rather than an obligation. But if your
game has a run, a deck, a build or a lap worth measuring against another player's, the platform
will hold a durable snapshot of one and hand it to a different player's game hours later, with
nothing live between them.

An **async opponent** is that snapshot: one record, shaped by you, carrying whatever your game
needs to rebuild an opponent and play against it. A game whose matches run entirely in the browser
has no server of its own to hold that state or to arbitrate a live match, and this is the
competitive mode that needs neither.

**A snapshot is data, and nothing more is implied by it.** The player it came from is not online,
has not agreed to this particular match, and is not told it happened. Design the screen that way:
your player is playing a record, not challenging a person.

## 1. Whether you need this

Async opponents are the platform's asynchronous competitive mode — one player's saved state,
played against by another player later, with nothing live in between.

Reach for it when your game is played alone but wants opposition: a ghost lap, a defending deck, a
fortress somebody else built, a rival seeded from a real run. It suits a game that runs entirely in
the browser, because no part of the loop needs a server you operate.

Do not reach for it when the players have to be in the same match at the same time — that is a live
room, and [`rooms-and-matches.md`](rooms-and-matches.md) is the page for it. Do not reach for it to
rank players either: standings are [`leaderboards.md`](leaderboards.md), and they are written from
a dedicated-server match rather than from a snapshot.

The whole loop in one sentence: **your game pushes a record after a match, draws a few records at
the next match's start, and fetches the full data for the one your player picks.**

## 2. The loop, and why it has this shape

The loop is three REST calls, and they do not cost the same. The order below is what keeps the
expensive one off the path a player is waiting on.

1. **Push a snapshot, after a match ends** — `POST /v1/async-opponents`. Blocks nobody: the match
   is over and nobody is waiting on it. It makes the play that just happened available to other
   players' future draws.
2. **Draw a set, at match start** — `GET /v1/async-opponents`. **This is the gameplay-blocking
   call**, with a player watching a loading screen while it runs, so it is bounded work that does
   not grow as the pool does.
3. **Fetch the one your player picked** — `GET /v1/async-opponents/{opponent_id}`. The only
   operation that returns `payload`, the dense data your game needs to actually reconstruct that
   opponent.

**The draw deliberately omits `payload`, and the third call exists because of it.** A draw can
return twenty-five records and the player plays one; carrying dense data on all twenty-five would
mean paying twenty-five heavy reads to use a single one. So the batch is scalars and the payload is
fetched once, for the pick.

Two consequences to design for now rather than later. The by-id fetch is load-bearing — it is not
an optimisation you can fold away. And **"the opponent I picked is gone" is a routine state rather
than an edge case**: records expire and slots are recycled in the seconds between a draw and a
pick, so plan the fallback before you meet it.

## 3. How your game reaches it

Async opponents are REST operations under `/v1/`, and your game calls them directly.

**The browser SDK has no named async-opponent surface.** What it has is the published raw client:
`gem.client.get`, `gem.client.post` and `gem.client.delete` take any `/v1/` path, sign it with the
token your frame already holds, and throw a typed `GemApiError` you branch on — so
`gem.client.get('/v1/async-opponents?…')`, `gem.client.post('/v1/async-opponents', body)` and
`gem.client.delete('/v1/async-opponents/…')` are the three calls this page is about. That client is
marked deprecated in favour of the SDK's named surfaces, and it is still the right call here: it is
the door for the case the named surfaces do not cover, and this is that case.

**A server-authored push is the same route with a different token.** A dedicated-server match's
bundle holds a game-server token and the same client shape, so a server that has just decided an
outcome can write the record itself, from values a modified client never touched — see
[`server-bundles.md`](server-bundles.md) for how a bundle calls the backend. Most of this page
reads the same from either side; what differs is the write gates in section 4, the withdraw scope
in section 9, and `exclude_self`, which does nothing for a game server token because it carries no
`sub`. If the same bundle runs as a browser listen host (`gem.match.serve`), it holds the hosting
player's play token rather than a game-server token: its pushes are player pushes, gated as in
section 4, and `exclude_self` applies.

## 4. What you declare, and what it does at runtime

No async-opponent *record* route answers until your game config declares an **async opponent
type**. A type is a policy: who may write records of it, how many one entity may keep, how long a
record lives, whether it carries a payload, and what its properties are. The config section is
`async_opponent_types`, and `gemctl config schema` is the authoritative field list — do not
re-derive one from this page. What follows is what each declared value does once your game is
running.

- **`player_writable`** — whether a player's own client may push. It has a cost; see below.
- **`max_slots_per_entity`** — how many records one entity may hold **at each version**. A cap
  rather than a quota: once it is full, the next push recycles the oldest.
- **`ttl_seconds`** — how long a record lives, from the push. Set it against your push cadence; a
  TTL shorter than the interval at which your players finish matches empties your own pool.
- **`max_payload_bytes`** — the ceiling on `payload`. **Absent means the type accepts no payload at
  all**, not that it accepts an unbounded one, and a push supplying one is refused rather than
  silently truncated.
- **`versions`** — each declared version carries its own property schema and its own `searchable`
  list. What versions do to the cap and to a draw is section 5.

**`player_writable` buys you a competitive mode with no server of your own, and it costs
authority.** With it off, only a game server may push, and every value in the pool was written by
code a player cannot reach. With it on, a browser game runs the whole loop by itself — and the
values are self-reported, so a modified client can push a lap nobody drove, and a player's push may
replace a record a game server wrote for that same entity. **Two gates must both pass for a player
push: the type declares `player_writable`, and every property that push sets declares
`client_writable`.** Both default to closed, platform-wide, so a property you never opened is
refused even on a type you did.

**`searchable` names properties the platform indexes as records are written.** No draw takes a
property filter today — the draw's parameters are the seven in section 7 and no others — so what
the declaration builds is the index itself. **Declare it when you first write the version.** The
index is built from records as they arrive and nothing goes back over the ones already stored, so a
record written under a version that did not name the property is not in that index and will not be.
The list sits beside the property map on the version, not on the individual property definition.

**Read the types at runtime rather than hardcoding the schema.**
`GET /v1/async-opponent-types` returns every type in your game's active config, each with the full
property schema of every version it declares, which is what lets a config change land without a
re-ship. It is also the authority: it reads the live config for your channel, so if a copy you are
holding disagrees with it, the copy is the stale side.

## 5. Versions, and the cap that follows them

Every async opponent type declares versions, and a version is a contract rather than a revision
marker: each carries its own property schema, so v2 may add, drop or retype a property without
invalidating the records already written as v1.

Four things follow from that, and the fourth is a trap.

- **The slot cap is per entity, per type, per version.** An entity already at its maximum on v1 can
  still fill a set on v2, so rolling a version out does not drain the pool your players are still
  drawing from.
- **A draw names one type and one version, and never mixes them.** `version` is required on the
  draw for the reason it is required on the push: it selects the property schema, so it cannot be
  inferred.
- **A retired version keeps its schema declared.** Records written under it stay readable until
  they expire; the version simply stops accepting pushes and draws.
- **A version your config does not currently accept is one signal covering two different faults.**
  Never declared, and declared-then-retired, answer a draw identically: `200`, an empty `opponents`
  array, and `empty_reason: "version_retired"`.

**That last one is where a client hangs.** A build shipping a version number the config never
declared reads "version retired" as a thin pool, shows an empty state and waits — and waiting
cannot fix it, because the fault is a constant compiled into the client. Check the version you ship
against `GET /v1/async-opponent-types` at startup: a version missing from the listing is your bug,
and a version present and inactive is a rollout.

## 6. Push a snapshot, after the match

A push writes one record for one entity, and it runs after a match has ended, when nothing is
waiting on it.

`POST /v1/async-opponents` takes `opponent_type`, `version`, `entity_type`, `entity_id` and
`properties`; `payload` is optional, and permitted only on a type that declares
`max_payload_bytes`.

```js
const { playerId } = await gem.awaitIdentity();

const pushed = await gem.client.post('/v1/async-opponents', {
  opponent_type: 'ghost-lap', // by slug or by UUID
  version: 3,
  entity_type: 'player',
  entity_id: playerId,
  properties: { track: 'coast', lap_ms: 84210, car: 'roadster' },
  payload: { inputs: recordedInputs }, // only on a type that declares max_payload_bytes
});
// pushed.opponent_id, pushed.expires_at
```

`properties` are scalars only and are validated against the schema the named version declares: a
key that version does not declare is a refusal, not an extra field that rides along.

**Keep personal data out of what you push.** Everything in a record is served to other players'
clients, so `properties` and `payload` are the wrong place for anything about the player that your
game would not print on their profile.

**`entity_id` names a target, never a credential.** Ownership comes from the token: a player token
may name only an entity that player owns, and a game server token only an entity in its own game.
Naming somebody else's entity is refused with the same answer as naming nothing at all — the
refusal is deliberately useless for testing whether an id is real.

**Every push mints a fresh `opponent_id`, including one that recycles.** Once the entity's slots
for that type and version are full, the next push evicts the oldest, so an entity's pool is always
its most recent snapshots and never grows without bound. The `201` carries `opponent_id` and
`expires_at`; there is no update call and no id worth holding between pushes.

## 7. Draw a set, at match start

A draw is the call your player waits on. It reads records for one type and one version and returns
a handful of them at random.

`GET /v1/async-opponents` takes seven parameters and no others:

| Parameter | Required | What it does |
| --- | --- | --- |
| `opponent_type` | yes | The type, **by slug or by UUID** |
| `version` | yes | Which declared version to draw from; a draw never mixes versions |
| `count` | no | How many to return. **Defaults to 5**, minimum 1, maximum 25 |
| `exclude_self` | no | **Defaults to true.** Resolved from your token; you never name yourself |
| `exclude_opponent_ids` | no | Ids to leave out, **at most 50 entries** |
| `game_id` | no | Carried by the token a framed game already holds |
| `channel` | no | Carried by the token a framed game already holds |

**`exclude_self` resolves to the calling player only.** A player who pushed a record for one of
their own characters can draw that character back; if that reads wrong in your game, exclude the
id yourself.

The response is `opponents` and, when the draw came up short, `empty_reason`. **`empty_reason` is
absent when the draw returned exactly `count` — the key is omitted, not `null`.** There is no value
meaning "nothing was wrong"; its presence is the whole signal, and section 10 says what each of its
five values means. A short draw is an ordinary `200` rather than an error: a new game's pool is
empty by definition, so your match-start screen has to render that as a designed state.

**One response never carries two records from the same entity**, so ten drawn opponents are ten
different players or characters.

Every drawn record carries `opponent_id`, `opponent_type`, `version`, `entity_type` (`player` or
`character`), `entity_id`, `properties`, `created_at` and `expires_at` — all of them, always — plus
the optional display triple `display_name`, `discriminator` and `display_tag`, which arrives whole
or not at all and **is absent entirely for a `character` entity**. `created_at` and `expires_at`
are Unix timestamps in seconds. There is no `payload` here; that is section 8.

**`opponent_type` goes in as a slug or a UUID and comes back as a UUID.** A game that pushes with a
slug and then matches drawn records against that same slug matches nothing, and nothing raises.
Compare against the id from the type listing, or do not compare on it at all.

```ts
// The raw client returns what you type it as, so declare the shape you read.
// Untyped, `drawn` is `unknown` and every line that touches it fails to compile.
interface Drawn {
  opponents: {
    display_tag?: string | null;
    properties: Record<string, string | number | boolean>;
  }[];
  empty_reason?: string;
}

const query = new URLSearchParams({ opponent_type: 'ghost-lap', version: '3', count: '10' });
const drawn = await gem.client.get<Drawn>(`/v1/async-opponents?${query}`);

if (drawn.empty_reason) {
  // Fewer than `count` came back, and the reason decides what to do — see section 10.
}

for (const opponent of drawn.opponents) {
  const row = document.createElement('li');
  row.textContent = `${opponent.display_tag ?? 'anonymous'} — ${opponent.properties.lap_ms} ms`;
  results.append(row);
}
```

**Those strings were typed by another player. Render them through `textContent` or your
framework's escaping — never `innerHTML`, and never as an image source or a link target, which are
script-URL sinks.** That covers every value in `properties` on a `player_writable` type, and it
covers `display_name`, `discriminator` and `display_tag` on every type. The rule runs the other way
at push time, which is why section 6 says to keep personal data out of what you write: every value
in a record is served to somebody else's client.

Three controls stand behind that rule, and how far each one reaches is worth knowing, because what
you are handed is a flat object with nothing wrapping it:

- **A length bound, always.** Every string property on an async opponent type must declare a
  `max_length` — a config that omits one is refused — so every string you draw is bounded, and the
  ceiling is tighter on a `player_writable` type, where the value came from another player.
  `GET /v1/async-opponent-types` publishes the bound each of your versions declares.
- **A scrub on the way out.** The platform strips invisible and formatting characters from a
  record's `properties` as it serves them.
- **The SDK's `PlayerText` binding, which is not on this path.** Where the SDK hands you a player's
  text through one of its named surfaces, it arrives as `PlayerText` rather than `string`, so
  `el.innerHTML = value` will not compile. **A value you read through `gem.client` is an ordinary
  JSON string.** Nothing stands between it and `innerHTML` except the way you render it.

**The draw is uniform over records, not over entities.** `max_slots_per_entity` is a cap and not a
quota, so an entity holding three records is drawn three times as often as one holding a single
record — even though no one response carries two records from the same entity. That is fine for
picking an opponent to play; it is not a fairness guarantee, and nothing in your game should be
built as though it were.

## 8. Fetch the one your player picked

A drawn record carries no `payload`. Once the player has chosen one, fetch that record by its id:
this is the only operation that returns it.

`GET /v1/async-opponents/{opponent_id}` returns the same fields the draw returned, plus `payload`:

```js
const opponent = await gem.client.get(`/v1/async-opponents/${opponentId}`);
// {
//   opponent_id, opponent_type, version, entity_type, entity_id,
//   properties: { track: 'coast', lap_ms: 84210, car: 'roadster' },
//   display_name, discriminator, display_tag,
//   created_at, expires_at,
//   payload: { inputs: [] },
// }
```

**`properties` is flat.** The values sit directly on it, with nothing wrapping them and no marker
saying which of them a player authored — which is why the rule in section 7 is a rule about your
rendering rather than a field you can test.

**`payload` is returned as stored.** It is scrubbed of invisible characters, it is absent when the
type declares no `max_payload_bytes` or the push supplied none, and **it is not validated against
any schema** — the only bounds on it are the type's byte ceiling and a depth cap, which makes it
the least constrained thing this API hands you. Everything the rule in section 7 says applies to
what you find inside it, at least as hard as it applies to `properties`.

**A `404` here is routine.** Records expire and slots are recycled between a draw and a pick, so a
player taking a moment to choose is enough to cause one. Fall back to a fresh draw rather than
reporting an error — and note that every refusal on this route is that same `404`, so it does not
tell "expired" apart from "not yours to read".

## 9. Withdraw a record

A record leaves the pool by itself when its TTL expires. Withdrawing is for the player who wants
out sooner, and for a game server tidying up behind itself.

`DELETE /v1/async-opponents/{opponent_id}` answers `204` on success. A player token may withdraw
records for entities that player owns, their own characters included — the path names a record, and
it is the record's entity that gets authorised, so your own player id is never assumed to be it. A
game server token may withdraw any record in its own game and channel, including ones it did not
write.

**A `404` means already gone** — expired, recycled, written under another channel, or never yours.
Treat it as done and do not retry it.

**A drawn `opponent_id` is a handle for reading, never for withdrawing.** This is the mistake the
shape of the loop invites: a game holding ten ids from a draw calls delete on one to take it out of
the player's list, and gets `404 async_opponent_not_found` — the same answer a record that expired
would give, because "a real record for an entity you may not address" is deliberately
indistinguishable from "gone". It is not the `403`: that refusal is about your credential rather
than about the record, and section 10 carries both.

## 10. When a draw comes up short, and when a call is refused

**The first five rows are not errors.** A draw that comes back short is an ordinary `200` carrying
an `empty_reason` — the state a new game's pool is in most of the time — and it is in this table
because a match-start screen has to handle it beside the refusals, not because it is one.

Everything below them is a refusal, thrown as a typed error, and what you branch on is not the HTTP
status — one status covers several distinct refusals. **Branch on `err.code` where the platform
sent one and on `err.reason` otherwise.** These routes do send `code`, which is what makes the
table below actionable.

| What you get | What happened | What to do |
| --- | --- | --- |
| `200` with `empty_reason: "empty_pool"` | No records exist for this type and version at all | Offer single-player; retrying does not create a pool |
| `200` with `empty_reason: "version_retired"` | The version is not one this type currently accepts — undeclared, or declared and retired | Check the version you ship against the type listing before blaming the pool |
| `200` with `empty_reason: "all_excluded"` | Records exist and are live; `exclude_self` or your exclusion list removed every one | Clear the exclusions accumulated this session and draw again |
| `200` with `empty_reason: "all_expired"` | Records exist but every one has expired | Raise `ttl_seconds`, or push more often — your cadence is slower than the type's TTL |
| `200` with `empty_reason: "pool_exhausted"` | Fewer than `count` survived expiry, exclusions and one-per-entity — **and it is also the answer when the cause cannot be pinned down** | Play the ones you got and draw again shortly; do not read it as a diagnosis |
| `404 async_opponent_not_found` on a fetch | The record expired, or its slot was recycled since your draw | Fall back to a fresh draw; this is ordinary play, not an error state |
| `404 async_opponent_not_found` on a withdraw | Already expired, already recycled, written under another channel, or a record for an entity you may not address — a drawn id lands here | Treat it as already gone, and withdraw only ids for entities your player owns |
| `400 async_opponent_property_invalid` | A property the version's schema does not accept, or the wrong type for one it does | Re-read that version's schema from the type listing; the same push will not start working |
| `400 async_opponent_payload_too_large` | The payload is over the type's `max_payload_bytes` | Push less, or raise the ceiling on the type |
| `400 async_opponent_payload_not_permitted` | The type declares no `max_payload_bytes`, so it takes no payload | Drop the payload, or declare a ceiling on the type |
| `400 too_many_exclusions` | More than 50 entries in `exclude_opponent_ids` | Bound the list you keep — the fifty most recent |
| `403 async_opponent_type_not_player_writable` | A player token pushed to a type only game servers may write | Push from your server bundle, or open the type |
| `403 async_opponent_property_not_client_writable` | A player push set a property the version does not declare `client_writable` | Declare the property writable, or leave it for the server half to write |
| `403 forbidden` on a withdraw | The credential is not a game-scoped writer, decided before any record is read — or the record moved entity under the delete | Withdraw with the token your frame holds, or from your server bundle |
| `404 async_opponent_type_not_found` | Your active config declares no type with that slug or id | Re-read the type listing; do not retry the call |
| `404 async_opponent_version_retired` on a push | The version is undeclared or inactive; the draw answers the same input with `empty_reason: "version_retired"` | Ship a version the config declares, and check it at startup rather than at push time |
| `404 no_config_assigned` | The channel has no assigned config version at all | Promote a config to this channel; a reason to stop asking rather than to retry |
| `404 not_found` on a push | The `entity_id` is not one this caller may write for — the same answer as an id naming nothing | Push only for entities your player owns |
| `429 rate_limited` | This address is over the route's per-minute budget | Back off for the wait `err.retryAfterSeconds` names |
| `429 async_opponent_contended` | This entity's records were being written concurrently; the budget is not the problem | Retry shortly; a second attempt usually lands |
| `503 service_unavailable` on a push | The limiter refused the push itself, rather than the platform being short of capacity — it says nothing about your record | Retry it; the record was neither written nor rejected |

**`err.retryAfterSeconds` is a whole number of seconds when the server named a wait, and missing
when it did not** — which means "no estimate", never "do not retry". `err.retryable` is the field
that answers that.

**A session that cannot be refreshed is not in this table.** The SDK replays a `401` against a
fresh token and, if that fails too, raises `AuthError` rather than `GemApiError` — a different
class, and one that carries no `retryable` at all. A `catch` written from this table alone falls
through it, and at match start that is the gameplay-blocking path.
