@indiegems/gem-web-sdk 7.4.0 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +54 -0
  2. package/dist/gem-web-sdk.esm.js +1 -1
  3. package/dist/gem-web-sdk.game.js +1 -1
  4. package/dist/gem-web-sdk.min.js +1 -1
  5. package/dist/gem-web-sdk.sdk.js +1 -1
  6. package/dist/gem-web-sdk.server.js +1 -1
  7. package/docs/achievements.md +232 -0
  8. package/docs/analytics.md +271 -0
  9. package/docs/api/api-reference-game.md +15308 -0
  10. package/docs/api/api-reference-server.md +4397 -0
  11. package/docs/api/reference.json +24130 -0
  12. package/docs/arcade-economy.md +263 -0
  13. package/docs/async-opponents.md +396 -0
  14. package/docs/brand-art.md +334 -0
  15. package/docs/cross-promotion.md +137 -0
  16. package/docs/daily-challenges.md +530 -0
  17. package/docs/dedicated-server-flow.md +879 -0
  18. package/docs/deprecation-policy.md +124 -0
  19. package/docs/friends.md +233 -0
  20. package/docs/game-economy-tutorial.md +326 -0
  21. package/docs/getting-started.md +552 -0
  22. package/docs/inventory-and-catalogs.md +1035 -0
  23. package/docs/leaderboards.md +277 -0
  24. package/docs/manifest.json +135 -0
  25. package/docs/match-session.md +288 -0
  26. package/docs/p2p-and-parties.md +783 -0
  27. package/docs/platform-rules.md +372 -0
  28. package/docs/player-sign-in.md +89 -0
  29. package/docs/room-shapes.md +195 -0
  30. package/docs/rooms-and-matches.md +779 -0
  31. package/docs/saving-player-progress.md +457 -0
  32. package/docs/server-bundles.md +1041 -0
  33. package/docs/serverless-functions.md +748 -0
  34. package/docs/trading-and-gifting.md +98 -0
  35. package/docs/what-a-game-must-do.md +1231 -0
  36. package/package.json +7 -2
  37. package/schema/bridge.v1.json +5 -5
  38. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,396 @@
1
+ # Async opponents
2
+
3
+ Not every game needs an opponent, and this is a capability rather than an obligation. But if your
4
+ game has a run, a deck, a build or a lap worth measuring against another player's, the platform
5
+ will hold a durable snapshot of one and hand it to a different player's game hours later, with
6
+ nothing live between them.
7
+
8
+ An **async opponent** is that snapshot: one record, shaped by you, carrying whatever your game
9
+ needs to rebuild an opponent and play against it. A game whose matches run entirely in the browser
10
+ has no server of its own to hold that state or to arbitrate a live match, and this is the
11
+ competitive mode that needs neither.
12
+
13
+ **A snapshot is data, and nothing more is implied by it.** The player it came from is not online,
14
+ has not agreed to this particular match, and is not told it happened. Design the screen that way:
15
+ your player is playing a record, not challenging a person.
16
+
17
+ ## 1. Whether you need this
18
+
19
+ Async opponents are the platform's asynchronous competitive mode — one player's saved state,
20
+ played against by another player later, with nothing live in between.
21
+
22
+ Reach for it when your game is played alone but wants opposition: a ghost lap, a defending deck, a
23
+ fortress somebody else built, a rival seeded from a real run. It suits a game that runs entirely in
24
+ the browser, because no part of the loop needs a server you operate.
25
+
26
+ Do not reach for it when the players have to be in the same match at the same time — that is a live
27
+ room, and [`rooms-and-matches.md`](rooms-and-matches.md) is the page for it. Do not reach for it to
28
+ rank players either: standings are [`leaderboards.md`](leaderboards.md), and they are written from
29
+ the server side, by a dedicated-server match or by your game's serverless function, rather than
30
+ from a snapshot.
31
+
32
+ The whole loop in one sentence: **your game pushes a record after a match, draws a few records at
33
+ the next match's start, and fetches the full data for the one your player picks.**
34
+
35
+ ## 2. The loop, and why it has this shape
36
+
37
+ The loop is three REST calls, and they do not cost the same. The order below is what keeps the
38
+ expensive one off the path a player is waiting on.
39
+
40
+ 1. **Push a snapshot, after a match ends** — `POST /v1/async-opponents`. Blocks nobody: the match
41
+ is over and nobody is waiting on it. It makes the play that just happened available to other
42
+ players' future draws.
43
+ 2. **Draw a set, at match start** — `GET /v1/async-opponents`. **This is the gameplay-blocking
44
+ call**, with a player watching a loading screen while it runs, so it is bounded work that does
45
+ not grow as the pool does.
46
+ 3. **Fetch the one your player picked** — `GET /v1/async-opponents/{opponent_id}`. The only
47
+ operation that returns `payload`, the dense data your game needs to actually reconstruct that
48
+ opponent.
49
+
50
+ **The draw deliberately omits `payload`, and the third call exists because of it.** A draw can
51
+ return twenty-five records and the player plays one; carrying dense data on all twenty-five would
52
+ mean paying twenty-five heavy reads to use a single one. So the batch is scalars and the payload is
53
+ fetched once, for the pick.
54
+
55
+ Two consequences to design for now rather than later. The by-id fetch is load-bearing — it is not
56
+ an optimisation you can fold away. And **"the opponent I picked is gone" is a routine state rather
57
+ than an edge case**: records expire and slots are recycled in the seconds between a draw and a
58
+ pick, so plan the fallback before you meet it.
59
+
60
+ ## 3. How your game reaches it
61
+
62
+ Async opponents are REST operations under `/v1/`, and your game calls them directly.
63
+
64
+ **The browser SDK has no named async-opponent surface.** What it has is the published raw client:
65
+ `gem.client.get`, `gem.client.post` and `gem.client.delete` take any `/v1/` path, sign it with the
66
+ token your frame already holds, and throw a typed `GemApiError` you branch on — so
67
+ `gem.client.get('/v1/async-opponents?…')`, `gem.client.post('/v1/async-opponents', body)` and
68
+ `gem.client.delete('/v1/async-opponents/…')` are the three calls this page is about. That client is
69
+ marked deprecated in favour of the SDK's named surfaces, and it is still the right call here: it is
70
+ the door for the case the named surfaces do not cover, and this is that case.
71
+
72
+ **That deprecation carries a floor, not a date.** `gem.client` keeps working for the rest of `3.x`,
73
+ and `4.0.0` is the earliest release that may remove it — [the deprecation
74
+ policy](deprecation-policy.md#2-the-rule-for-the-packages-api-surface) is the whole rule: it names
75
+ the soonest a removal could reach you rather than scheduling one. And what closes the gap before
76
+ then is you saying it is there — the notice on `gem.client` asks you to report anything the named
77
+ surfaces do not offer, and this page is one of those things written down. **Reaching for it does
78
+ not decide who signs the write:** `gem.client` signs with the play token your frame already holds,
79
+ so a record the platform has to trust belongs on the server-authored route below rather than on a
80
+ client call.
81
+
82
+ **A server-authored push is the same route with a different token.** A dedicated-server match's
83
+ bundle holds a game-server token and the same client shape, so a server that has just decided an
84
+ outcome can write the record itself, from values a modified client never touched — see
85
+ [`server-bundles.md`](server-bundles.md) for how a bundle calls the backend. Most of this page
86
+ reads the same from either side; what differs is the write gates in section 4, the withdraw scope
87
+ in section 9, and `exclude_self`, which does nothing for a game server token because it carries no
88
+ `sub`. If the same bundle runs as a browser listen host (`gem.match.serve`), it holds the hosting
89
+ player's play token rather than a game-server token: its pushes are player pushes, gated as in
90
+ section 4, and `exclude_self` applies.
91
+
92
+ ## 4. What you declare, and what it does at runtime
93
+
94
+ No async-opponent *record* route answers until your game config declares an **async opponent
95
+ type**. A type is a policy: who may write records of it, how many one entity may keep, how long a
96
+ record lives, whether it carries a payload, and what its properties are. The config section is
97
+ `async_opponent_types`, and `gemctl config schema` is the authoritative field list — do not
98
+ re-derive one from this page. What follows is what each declared value does once your game is
99
+ running.
100
+
101
+ - **`player_writable`** — whether a player's own client may push. It has a cost; see below.
102
+ - **`max_slots_per_entity`** — how many records one entity may hold **at each version**. A cap
103
+ rather than a quota: once it is full, the next push recycles the oldest.
104
+ - **`ttl_seconds`** — how long a record lives, from the push. Set it against your push cadence; a
105
+ TTL shorter than the interval at which your players finish matches empties your own pool.
106
+ - **`max_payload_bytes`** — the ceiling on `payload`. **Absent means the type accepts no payload at
107
+ all**, not that it accepts an unbounded one, and a push supplying one is refused rather than
108
+ silently truncated.
109
+ - **`versions`** — each declared version carries its own property schema and its own `searchable`
110
+ list. What versions do to the cap and to a draw is section 5.
111
+
112
+ **`player_writable` buys you a competitive mode with no server of your own, and it costs
113
+ authority.** With it off, only a game server may push, and every value in the pool was written by
114
+ code a player cannot reach. With it on, a browser game runs the whole loop by itself — and the
115
+ values are self-reported, so a modified client can push a lap nobody drove, and a player's push may
116
+ replace a record a game server wrote for that same entity. **Two gates must both pass for a player
117
+ push: the type declares `player_writable`, and every property that push sets declares
118
+ `client_writable`.** Both default to closed, platform-wide, so a property you never opened is
119
+ refused even on a type you did.
120
+
121
+ **`searchable` names properties the platform indexes as records are written.** No draw takes a
122
+ property filter today — the draw's parameters are the seven in section 7 and no others — so what
123
+ the declaration builds is the index itself. **Declare it when you first write the version.** The
124
+ index is built from records as they arrive and nothing goes back over the ones already stored, so a
125
+ record written under a version that did not name the property is not in that index and will not be.
126
+ The list sits beside the property map on the version, not on the individual property definition.
127
+
128
+ **Read the types at runtime rather than hardcoding the schema.**
129
+ `GET /v1/async-opponent-types` returns every type in your game's active config, each with the full
130
+ property schema of every version it declares, which is what lets a config change land without a
131
+ re-ship. It is also the authority: it reads the live config for your channel, so if a copy you are
132
+ holding disagrees with it, the copy is the stale side.
133
+
134
+ ## 5. Versions, and the cap that follows them
135
+
136
+ Every async opponent type declares versions, and a version is a contract rather than a revision
137
+ marker: each carries its own property schema, so v2 may add, drop or retype a property without
138
+ invalidating the records already written as v1.
139
+
140
+ Four things follow from that, and the fourth is a trap.
141
+
142
+ - **The slot cap is per entity, per type, per version.** An entity already at its maximum on v1 can
143
+ still fill a set on v2, so rolling a version out does not drain the pool your players are still
144
+ drawing from.
145
+ - **A draw names one type and one version, and never mixes them.** `version` is required on the
146
+ draw for the reason it is required on the push: it selects the property schema, so it cannot be
147
+ inferred.
148
+ - **A retired version keeps its schema declared.** Records written under it stay readable until
149
+ they expire; the version simply stops accepting pushes and draws.
150
+ - **A version your config does not currently accept is one signal covering two different faults.**
151
+ Never declared, and declared-then-retired, answer a draw identically: `200`, an empty `opponents`
152
+ array, and `empty_reason: "version_retired"`.
153
+
154
+ **That last one is where a client hangs.** A build shipping a version number the config never
155
+ declared reads "version retired" as a thin pool, shows an empty state and waits — and waiting
156
+ cannot fix it, because the fault is a constant compiled into the client. Check the version you ship
157
+ against `GET /v1/async-opponent-types` at startup: a version missing from the listing is your bug,
158
+ and a version present and inactive is a rollout.
159
+
160
+ ## 6. Push a snapshot, after the match
161
+
162
+ A push writes one record for one entity, and it runs after a match has ended, when nothing is
163
+ waiting on it.
164
+
165
+ `POST /v1/async-opponents` takes `opponent_type`, `version`, `entity_type`, `entity_id` and
166
+ `properties`; `payload` is optional, and permitted only on a type that declares
167
+ `max_payload_bytes`.
168
+
169
+ ```js
170
+ const { playerId } = await gem.awaitIdentity();
171
+
172
+ const pushed = await gem.client.post('/v1/async-opponents', {
173
+ opponent_type: 'ghost-lap', // by slug or by UUID
174
+ version: 3,
175
+ entity_type: 'player',
176
+ entity_id: playerId,
177
+ properties: { track: 'coast', lap_ms: 84210, car: 'roadster' },
178
+ payload: { inputs: recordedInputs }, // only on a type that declares max_payload_bytes
179
+ });
180
+ // pushed.opponent_id, pushed.expires_at
181
+ ```
182
+
183
+ `properties` are scalars only and are validated against the schema the named version declares: a
184
+ key that version does not declare is a refusal, not an extra field that rides along.
185
+
186
+ **Keep personal data out of what you push.** Everything in a record is served to other players'
187
+ clients, so `properties` and `payload` are the wrong place for anything about the player that your
188
+ game would not print on their profile.
189
+
190
+ **`entity_id` names a target, never a credential.** Ownership comes from the token: a player token
191
+ may name only an entity that player owns, and a game server token only an entity in its own game.
192
+ Naming somebody else's entity is refused with the same answer as naming nothing at all — the
193
+ refusal is deliberately useless for testing whether an id is real.
194
+
195
+ **Every push mints a fresh `opponent_id`, including one that recycles.** Once the entity's slots
196
+ for that type and version are full, the next push evicts the oldest, so an entity's pool is always
197
+ its most recent snapshots and never grows without bound. The `201` carries `opponent_id` and
198
+ `expires_at`; there is no update call and no id worth holding between pushes.
199
+
200
+ ## 7. Draw a set, at match start
201
+
202
+ A draw is the call your player waits on. It reads records for one type and one version and returns
203
+ a handful of them at random.
204
+
205
+ `GET /v1/async-opponents` takes seven parameters and no others:
206
+
207
+ | Parameter | Required | What it does |
208
+ | --- | --- | --- |
209
+ | `opponent_type` | yes | The type, **by slug or by UUID** |
210
+ | `version` | yes | Which declared version to draw from; a draw never mixes versions |
211
+ | `count` | no | How many to return. **Defaults to 5**, minimum 1, maximum 25 |
212
+ | `exclude_self` | no | **Defaults to true.** Resolved from your token; you never name yourself |
213
+ | `exclude_opponent_ids` | no | Ids to leave out, **at most 50 entries** |
214
+ | `game_id` | no | Carried by the token a framed game already holds |
215
+ | `channel` | no | Carried by the token a framed game already holds |
216
+
217
+ **`exclude_self` resolves to the calling player only.** A player who pushed a record for one of
218
+ their own characters can draw that character back; if that reads wrong in your game, exclude the
219
+ id yourself.
220
+
221
+ The response is `opponents` and, when the draw came up short, `empty_reason`. **`empty_reason` is
222
+ absent when the draw returned exactly `count` — the key is omitted, not `null`.** There is no value
223
+ meaning "nothing was wrong"; its presence is the whole signal, and section 10 says what each of its
224
+ five values means. A short draw is an ordinary `200` rather than an error: a new game's pool is
225
+ empty by definition, so your match-start screen has to render that as a designed state.
226
+
227
+ **One response never carries two records from the same entity**, so ten drawn opponents are ten
228
+ different players or characters.
229
+
230
+ Every drawn record carries `opponent_id`, `opponent_type`, `version`, `entity_type` (`player` or
231
+ `character`), `entity_id`, `properties`, `created_at` and `expires_at` — all of them, always — plus
232
+ the optional player identity fields — `player_id`, `display_name`, `discriminator`, `display_tag`,
233
+ the portrait `avatar_icon_id` and `avatar_icon_url`, and `badges` — which **are absent entirely for
234
+ a `character` entity**. For a player whose identity is withheld from you, `player_id` stays (it is
235
+ the `entity_id` you already hold) and the name, tag and portrait are absent. `created_at` and `expires_at`
236
+ are Unix timestamps in seconds. There is no `payload` here; that is section 8.
237
+
238
+ **`opponent_type` goes in as a slug or a UUID and comes back as a UUID.** A game that pushes with a
239
+ slug and then matches drawn records against that same slug matches nothing, and nothing raises.
240
+ Compare against the id from the type listing, or do not compare on it at all.
241
+
242
+ ```ts
243
+ // The raw client returns what you type it as, so declare the shape you read.
244
+ // Untyped, `drawn` is `unknown` and every line that touches it fails to compile.
245
+ interface Drawn {
246
+ opponents: {
247
+ display_tag?: string | null;
248
+ properties: Record<string, string | number | boolean>;
249
+ }[];
250
+ empty_reason?: string;
251
+ }
252
+
253
+ const query = new URLSearchParams({ opponent_type: 'ghost-lap', version: '3', count: '10' });
254
+ const drawn = await gem.client.get<Drawn>(`/v1/async-opponents?${query}`);
255
+
256
+ if (drawn.empty_reason) {
257
+ // Fewer than `count` came back, and the reason decides what to do — see section 10.
258
+ }
259
+
260
+ for (const opponent of drawn.opponents) {
261
+ // `mapIdentity`, imported beside `text`: the identity fields every surface that shows a player
262
+ // carries, the name and tag as PlayerText.
263
+ const who = mapIdentity(opponent);
264
+ const name = who.displayName ? text.toPlain(who.displayName) : 'Anonymous player';
265
+ const row = document.createElement('li');
266
+ row.textContent = `${name} — ${opponent.properties.lap_ms} ms`;
267
+ results.append(row);
268
+ }
269
+ ```
270
+
271
+ **Those strings were typed by another player. Render them through `textContent` or your
272
+ framework's escaping — never `innerHTML`, and never as an image source or a link target, which are
273
+ script-URL sinks.** That covers every value in `properties` on a `player_writable` type, and it
274
+ covers `display_name`, `discriminator` and `display_tag` on every type. The rule runs the other way
275
+ at push time, which is why section 6 says to keep personal data out of what you write: every value
276
+ in a record is served to somebody else's client.
277
+
278
+ Three controls stand behind that rule, and how far each one reaches is worth knowing, because what
279
+ you are handed is a flat object with nothing wrapping it:
280
+
281
+ - **A length bound, always.** Every string property on an async opponent type must declare a
282
+ `max_length` — a config that omits one is refused — so every string you draw is bounded, and the
283
+ ceiling is tighter on a `player_writable` type, where the value came from another player.
284
+ `GET /v1/async-opponent-types` publishes the bound each of your versions declares.
285
+ - **A scrub on the way out.** The platform strips invisible and formatting characters from a
286
+ record's `properties` as it serves them.
287
+ - **The SDK's `PlayerText` binding, which you opt into on this path.** Where the SDK hands you a
288
+ player's text through one of its named surfaces, it arrives as `PlayerText` rather than `string`,
289
+ so `el.innerHTML = value` will not compile. **A value you read through `gem.client` is an ordinary
290
+ JSON string** — so pass each drawn record through `mapIdentity`, which gives you the same
291
+ identity fields every other surface carries, name and tag wrapped. It covers the identity fields
292
+ only; nothing stands between a `properties` value and `innerHTML` except the way you render it.
293
+
294
+ **The draw is uniform over records, not over entities.** `max_slots_per_entity` is a cap and not a
295
+ quota, so an entity holding three records is drawn three times as often as one holding a single
296
+ record — even though no one response carries two records from the same entity. That is fine for
297
+ picking an opponent to play; it is not a fairness guarantee, and nothing in your game should be
298
+ built as though it were.
299
+
300
+ ## 8. Fetch the one your player picked
301
+
302
+ A drawn record carries no `payload`. Once the player has chosen one, fetch that record by its id:
303
+ this is the only operation that returns it.
304
+
305
+ `GET /v1/async-opponents/{opponent_id}` returns the same fields the draw returned, plus `payload`:
306
+
307
+ ```js
308
+ const opponent = await gem.client.get(`/v1/async-opponents/${opponentId}`);
309
+ // {
310
+ // opponent_id, opponent_type, version, entity_type, entity_id,
311
+ // properties: { track: 'coast', lap_ms: 84210, car: 'roadster' },
312
+ // display_name, discriminator, display_tag,
313
+ // created_at, expires_at,
314
+ // payload: { inputs: [] },
315
+ // }
316
+ ```
317
+
318
+ **`properties` is flat.** The values sit directly on it, with nothing wrapping them and no marker
319
+ saying which of them a player authored — which is why the rule in section 7 is a rule about your
320
+ rendering rather than a field you can test.
321
+
322
+ **`payload` is returned as stored.** It is scrubbed of invisible characters, it is absent when the
323
+ type declares no `max_payload_bytes` or the push supplied none, and **it is not validated against
324
+ any schema** — the only bounds on it are the type's byte ceiling and a depth cap, which makes it
325
+ the least constrained thing this API hands you. Everything the rule in section 7 says applies to
326
+ what you find inside it, at least as hard as it applies to `properties`.
327
+
328
+ **A `404` here is routine.** Records expire and slots are recycled between a draw and a pick, so a
329
+ player taking a moment to choose is enough to cause one. Fall back to a fresh draw rather than
330
+ reporting an error — and note that every refusal on this route is that same `404`, so it does not
331
+ tell "expired" apart from "not yours to read".
332
+
333
+ ## 9. Withdraw a record
334
+
335
+ A record leaves the pool by itself when its TTL expires. Withdrawing is for the player who wants
336
+ out sooner, and for a game server tidying up behind itself.
337
+
338
+ `DELETE /v1/async-opponents/{opponent_id}` answers `204` on success. A player token may withdraw
339
+ records for entities that player owns, their own characters included — the path names a record, and
340
+ it is the record's entity that gets authorised, so your own player id is never assumed to be it. A
341
+ game server token may withdraw any record in its own game and channel, including ones it did not
342
+ write.
343
+
344
+ **A `404` means already gone** — expired, recycled, written under another channel, or never yours.
345
+ Treat it as done and do not retry it.
346
+
347
+ **A drawn `opponent_id` is a handle for reading, never for withdrawing.** This is the mistake the
348
+ shape of the loop invites: a game holding ten ids from a draw calls delete on one to take it out of
349
+ the player's list, and gets `404 async_opponent_not_found` — the same answer a record that expired
350
+ would give, because "a real record for an entity you may not address" is deliberately
351
+ indistinguishable from "gone". It is not the `403`: that refusal is about your credential rather
352
+ than about the record, and section 10 carries both.
353
+
354
+ ## 10. When a draw comes up short, and when a call is refused
355
+
356
+ **The first five rows are not errors.** A draw that comes back short is an ordinary `200` carrying
357
+ an `empty_reason` — the state a new game's pool is in most of the time — and it is in this table
358
+ because a match-start screen has to handle it beside the refusals, not because it is one.
359
+
360
+ Everything below them is a refusal, thrown as a typed error, and what you branch on is not the HTTP
361
+ status — one status covers several distinct refusals. **Branch on `err.code` where the platform
362
+ sent one and on `err.reason` otherwise.** These routes do send `code`, which is what makes the
363
+ table below actionable.
364
+
365
+ | What you get | What happened | What to do |
366
+ | --- | --- | --- |
367
+ | `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 |
368
+ | `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 |
369
+ | `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 |
370
+ | `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 |
371
+ | `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 |
372
+ | `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 |
373
+ | `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 |
374
+ | `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 |
375
+ | `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 |
376
+ | `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 |
377
+ | `400 too_many_exclusions` | More than 50 entries in `exclude_opponent_ids` | Bound the list you keep — the fifty most recent |
378
+ | `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 |
379
+ | `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 |
380
+ | `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 |
381
+ | `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 |
382
+ | `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 |
383
+ | `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 |
384
+ | `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 |
385
+ | `429 rate_limited` | This address is over the route's per-minute budget | Back off for the wait `err.retryAfterSeconds` names |
386
+ | `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 |
387
+ | `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 |
388
+
389
+ **`err.retryAfterSeconds` is a whole number of seconds when the server named a wait, and missing
390
+ when it did not** — which means "no estimate", never "do not retry". `err.retryable` is the field
391
+ that answers that.
392
+
393
+ **A session that cannot be refreshed is not in this table.** The SDK replays a `401` against a
394
+ fresh token and, if that fails too, raises `AuthError` rather than `GemApiError` — a different
395
+ class, and one that carries no `retryable` at all. A `catch` written from this table alone falls
396
+ through it, and at match start that is the gameplay-blocking path.