All developer documentation

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 is the page for it. Do not reach for it to rank players either: standings are 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 endsPOST /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 startGET /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 pickedGET /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 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.

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:

ParameterRequiredWhat it does
opponent_typeyesThe type, by slug or by UUID
versionyesWhich declared version to draw from; a draw never mixes versions
countnoHow many to return. Defaults to 5, minimum 1, maximum 25
exclude_selfnoDefaults to true. Resolved from your token; you never name yourself
exclude_opponent_idsnoIds to leave out, at most 50 entries
game_idnoCarried by the token a framed game already holds
channelnoCarried 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.

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

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 getWhat happenedWhat to do
200 with empty_reason: "empty_pool"No records exist for this type and version at allOffer 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 retiredCheck 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 oneClear the exclusions accumulated this session and draw again
200 with empty_reason: "all_expired"Records exist but every one has expiredRaise 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 downPlay the ones you got and draw again shortly; do not read it as a diagnosis
404 async_opponent_not_found on a fetchThe record expired, or its slot was recycled since your drawFall back to a fresh draw; this is ordinary play, not an error state
404 async_opponent_not_found on a withdrawAlready expired, already recycled, written under another channel, or a record for an entity you may not address — a drawn id lands hereTreat it as already gone, and withdraw only ids for entities your player owns
400 async_opponent_property_invalidA property the version's schema does not accept, or the wrong type for one it doesRe-read that version's schema from the type listing; the same push will not start working
400 async_opponent_payload_too_largeThe payload is over the type's max_payload_bytesPush less, or raise the ceiling on the type
400 async_opponent_payload_not_permittedThe type declares no max_payload_bytes, so it takes no payloadDrop the payload, or declare a ceiling on the type
400 too_many_exclusionsMore than 50 entries in exclude_opponent_idsBound the list you keep — the fifty most recent
403 async_opponent_type_not_player_writableA player token pushed to a type only game servers may writePush from your server bundle, or open the type
403 async_opponent_property_not_client_writableA player push set a property the version does not declare client_writableDeclare the property writable, or leave it for the server half to write
403 forbidden on a withdrawThe credential is not a game-scoped writer, decided before any record is read — or the record moved entity under the deleteWithdraw with the token your frame holds, or from your server bundle
404 async_opponent_type_not_foundYour active config declares no type with that slug or idRe-read the type listing; do not retry the call
404 async_opponent_version_retired on a pushThe 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_assignedThe channel has no assigned config version at allPromote a config to this channel; a reason to stop asking rather than to retry
404 not_found on a pushThe entity_id is not one this caller may write for — the same answer as an id naming nothingPush only for entities your player owns
429 rate_limitedThis address is over the route's per-minute budgetBack off for the wait err.retryAfterSeconds names
429 async_opponent_contendedThis entity's records were being written concurrently; the budget is not the problemRetry shortly; a second attempt usually lands
503 service_unavailable on a pushThe limiter refused the push itself, rather than the platform being short of capacity — it says nothing about your recordRetry 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.

Read this page as Markdown