@indiegems/gem-web-sdk 7.3.1 → 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 (42) hide show
  1. package/README.md +54 -0
  2. package/dist/game.d.ts +156 -22
  3. package/dist/gem-web-sdk.bridge.js +1 -1
  4. package/dist/gem-web-sdk.esm.js +2 -2
  5. package/dist/gem-web-sdk.game.js +2 -2
  6. package/dist/gem-web-sdk.min.js +2 -2
  7. package/dist/gem-web-sdk.sdk.js +2 -2
  8. package/dist/gem-web-sdk.server.js +2 -2
  9. package/dist/index.d.ts +89 -6
  10. package/dist/sdk.d.ts +156 -22
  11. package/docs/achievements.md +232 -0
  12. package/docs/analytics.md +271 -0
  13. package/docs/api/api-reference-game.md +15308 -0
  14. package/docs/api/api-reference-server.md +4397 -0
  15. package/docs/api/reference.json +24130 -0
  16. package/docs/arcade-economy.md +263 -0
  17. package/docs/async-opponents.md +396 -0
  18. package/docs/brand-art.md +334 -0
  19. package/docs/cross-promotion.md +137 -0
  20. package/docs/daily-challenges.md +530 -0
  21. package/docs/dedicated-server-flow.md +879 -0
  22. package/docs/deprecation-policy.md +124 -0
  23. package/docs/friends.md +233 -0
  24. package/docs/game-economy-tutorial.md +326 -0
  25. package/docs/getting-started.md +552 -0
  26. package/docs/inventory-and-catalogs.md +1035 -0
  27. package/docs/leaderboards.md +277 -0
  28. package/docs/manifest.json +135 -0
  29. package/docs/match-session.md +288 -0
  30. package/docs/p2p-and-parties.md +783 -0
  31. package/docs/platform-rules.md +372 -0
  32. package/docs/player-sign-in.md +89 -0
  33. package/docs/room-shapes.md +195 -0
  34. package/docs/rooms-and-matches.md +779 -0
  35. package/docs/saving-player-progress.md +457 -0
  36. package/docs/server-bundles.md +1041 -0
  37. package/docs/serverless-functions.md +748 -0
  38. package/docs/trading-and-gifting.md +98 -0
  39. package/docs/what-a-game-must-do.md +1231 -0
  40. package/package.json +7 -2
  41. package/schema/bridge.v1.json +440 -7
  42. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,457 @@
1
+ # Saving a player's progress
2
+
3
+ Games do not have to save anything. This is a capability, not an obligation — a puzzle game
4
+ that ends when the tab closes is a complete game. Read this if yours has progress worth keeping.
5
+
6
+ If it does, keep it here rather than in the browser. A save in browser storage is lost when the
7
+ player changes device, changes browser, or clears site data. The platform holds one instead, and
8
+ the routes are reachable with the token your frame already holds.
9
+
10
+ A save often holds a snapshot of what the player owns — a level, a party, a bag of items. What a
11
+ player holds on the platform, as opposed to what your game chooses to serialise into a save, lives
12
+ in [`inventory-and-catalogs.md`](inventory-and-catalogs.md); the two are neighbours in a reader's
13
+ head, and a game with both usually reads them together.
14
+
15
+ ## Two roles, and they are not the same person
16
+
17
+ This is the thing to get straight before anything else, because the two halves happen at
18
+ different times and only one of them is code:
19
+
20
+ | | Who | When |
21
+ | ------------------------------ | ------------------------ | -------------------------------------------------------------------- |
22
+ | **Declaring the slot** | you, the developer | once, in your game config — the same way a room template is declared |
23
+ | **Writing and reading a save** | your game's running code | every session, once per player |
24
+
25
+ A save lives in a **slot**, and a slot is something you publish. Game code cannot create one:
26
+ writing to a key you never declared is refused with `not_found` naming the key, at the first
27
+ call and at every retry after it. That is the wall almost everyone hits first, and it is why
28
+ this page starts with the config rather than with the API.
29
+
30
+ ## 1. Declare the slot, in your game config
31
+
32
+ Save slots are a section of the game config, alongside the room templates:
33
+
34
+ ```yaml
35
+ save_schemas:
36
+ - key: campaign
37
+ max_size_bytes: 262144
38
+ - key: settings_blob
39
+ ```
40
+
41
+ Only `key` is required. `max_size_bytes` defaults to **1048576** — 1 MiB — and is the ceiling for
42
+ that slot alone. The access fields default to what a single-player game wants: the player may
43
+ read and write their own save, your dedicated server may read and write it, and no other player
44
+ can do either. Letting other players read a slot is its own kind of slot — a
45
+ [showcase slot](#showcase-slots-a-little-progress-your-friends-can-read), declared with
46
+ `visibility`, and it trades the whole upload flow away for that.
47
+
48
+ **Why the declaration exists at all**, rather than a first write creating the slot: the ceiling
49
+ is checked _before_ your game is given anywhere to upload, so a payload that would not fit is
50
+ refused in the first call rather than after the bytes have been pushed. A slot invented at
51
+ runtime would have no ceiling to check against.
52
+
53
+ It ships the way the rest of your config ships — no new build, versioned, rollback-able:
54
+
55
+ ```bash
56
+ gemctl config schema # the authoritative field list
57
+ gemctl config upload --wait # registers a version; validation is ASYNC
58
+ gemctl config promote <version> --channel <channel> # this is what makes it live
59
+ ```
60
+
61
+ **Uploading is not shipping.** Until a `gemctl config promote` puts the version on the channel
62
+ your game is running, the slot does not exist for that channel and every save against it is a
63
+ `not_found`. This is the same rule room templates follow; see
64
+ [`platform-rules.md` §5](platform-rules.md#5-templateslug-what-it-resolves-against-and-what-you-can-pass-today)
65
+ for why uploading alone is not enough.
66
+
67
+ **Rolling back is promoting the earlier version**, not deleting the new one. A config version no
68
+ channel points at can be deleted, and stays recoverable by support for 30 days; one a channel
69
+ still points at cannot. See
70
+ [Deleting a version you no longer want](getting-started.md#deleting-a-version-you-no-longer-want).
71
+
72
+ Three things worth knowing about save keys and slots:
73
+
74
+ - **Keys are 1–64 characters**, ASCII letters, digits and underscores, and may not begin with `_`
75
+ or `$` — those prefixes are reserved. No hyphens, unlike a template slug. Config validation and
76
+ every save call agree on all of that, and the SDK refuses a bad key locally before any call.
77
+ - ⚠️ **They did not always agree.** Config validation used to accept up to 128 characters and a
78
+ leading `_`, so a slot declared that way validated, uploaded, shipped to players and could then
79
+ never be written to — every save against it failed, permanently. If you have such a slot, your
80
+ next config upload will refuse it: rename it. Nothing was ever stored in it.
81
+ - **Your game cannot read its own slot list back at runtime.** The published config document a
82
+ game fetches carries items, currencies and room templates, and does not carry the save slots.
83
+ The key your game code writes to is a literal in that code, and the only thing that checks the
84
+ two agree is a save that works.
85
+
86
+ ## 2. Writing and reading a save, from the game
87
+
88
+ One call each way. `gem.me` is the signed-in player.
89
+
90
+ ```js
91
+ // After a level, a purchase, a run ending.
92
+ await gem.me.saves.put('campaign', { level: 7, party, inventory });
93
+
94
+ // On boot. `null` means this player has never saved.
95
+ const state = await gem.me.saves.get('campaign');
96
+ if (state) resumeFrom(state);
97
+ ```
98
+
99
+ `put` serialises with `JSON.stringify` and resolves once the save is the current one, with the
100
+ stored record: its size, its version and whatever metadata you attached. `get` fetches the
101
+ stored bytes and parses them back. **Nothing about either is device-specific, and that is the
102
+ whole point** — the same player signing in on another machine holds a token naming the same
103
+ player, so `get` there returns what `put` stored here.
104
+
105
+ **A save is three round trips**, two of them to the API and one carrying your payload to
106
+ storage. The SDK does all three and rolls back nothing halfway: either the save became the
107
+ current one or it did not. What that costs you is time, so save on the events that matter — a
108
+ level cleared, a purchase, a run ended — and never on a frame timer. A load is two, and asking
109
+ whether you need to load at all is one.
110
+
111
+ **Do not leave the save until `onWillTerminate`.** Three round trips and a payload is a lot to
112
+ fit inside a grace period the parent chose and you cannot negotiate, and Back, a closed tab and a
113
+ reload give you no warning at all. Treat that callback as a last flush of something small, and
114
+ keep the last completed save recent enough that losing the seconds after it is survivable.
115
+ [Saving against a deadline](#saving-against-a-deadline) is the whole of what to do about it.
116
+
117
+ ### Deciding whether to load at all
118
+
119
+ ```js
120
+ const record = await gem.me.saves.describe('campaign');
121
+ if (record?.etag !== lastLoadedEtag) {
122
+ const state = await gem.me.saves.get('campaign');
123
+ }
124
+ ```
125
+
126
+ `describe` reads the record without downloading the payload — size, metadata, and a version that
127
+ changes whenever the save does. Pass a version you already hold as `ifNoneMatch` and it resolves
128
+ `null` for "still yours", which is one small request instead of a download.
129
+
130
+ `metadata` is the field that makes this worth doing: attach `{ level: 7, chapter: 'The Vault' }`
131
+ to a `put` and a save-slot menu can be drawn from `list()` alone, with nothing downloaded.
132
+
133
+ ### The rest of the surface
134
+
135
+ ```js
136
+ await gem.me.saves.list(); // every save this player holds, newest state, no payloads
137
+ await gem.me.saves.delete('campaign'); // immediate; deleting one that is not there is not an error
138
+ await gem.me.saves.putBytes('replay', bytes, { contentType: 'application/msgpack' });
139
+ await gem.me.saves.getBytes('replay');
140
+ ```
141
+
142
+ `list` pages at 20 by default and 100 at most, and carries no download links — a listing cannot
143
+ be turned into a bulk read. Follow `nextCursor` if you have more slots than a page.
144
+
145
+ **Binary has its own pair of methods on purpose.** `JSON.stringify` turns a `Uint8Array` into
146
+ `{"0":1,"1":2}` without complaining, so a `put` of raw bytes would store something wrong and
147
+ silently. Use `putBytes` and `getBytes`, and name the content type you are actually sending.
148
+
149
+ **Characters have the same accessor.** `gem.characters.get(id)` resolves a character carrying
150
+ `saves` exactly like the player's, scoped to that character — the same declared slots, separate
151
+ stored saves. Most browser games have no characters and can ignore this.
152
+
153
+ ## Showcase slots: a little progress your friends can read
154
+
155
+ Everything above is the player's own document, read back by the same player. A **showcase
156
+ slot** is the other thing games want from this section of the config: a value _other players_
157
+ may read — how far each friend has progressed, drawn on a map screen without pulling profiles
158
+ and without anyone downloading anyone's save.
159
+
160
+ A slot becomes one where it is declared, by saying who may see it:
161
+
162
+ ```yaml
163
+ save_schemas:
164
+ - key: campaign
165
+ max_size_bytes: 262144
166
+ - key: map_progress
167
+ visibility: friends # or `public`; the default, `self`, is an ordinary save slot
168
+ ```
169
+
170
+ Declaring a non-`self` visibility changes what the slot _is_, not just who reads it. The value
171
+ becomes a small JSON document — **4096 bytes** serialised at most, and a `max_size_bytes` above
172
+ that will not validate — stored inline and carried on the calls themselves. Nothing about the
173
+ three-round-trip upload flow above applies: a write is one request, a read is one request, and
174
+ the batch read is the point of the whole feature:
175
+
176
+ ```js
177
+ // After clearing a level: publish where you are. One round trip.
178
+ await gem.showcase.set('map_progress', { level: 12 });
179
+
180
+ // The friends screen: every accepted friend's value, one request.
181
+ const { entries } = await gem.showcase.friends('map_progress');
182
+ for (const row of entries) {
183
+ const name = row.displayName ? text.toPlain(row.displayName) : 'Anonymous player';
184
+ pin(name, row.value, { you: row.isViewer });
185
+ }
186
+ ```
187
+
188
+ The friend graph is resolved on the platform — your game receives values and display names,
189
+ never the graph. Friends who have written nothing are absent, an empty list is a real answer,
190
+ and the player's own row arrives marked `isViewer`, so "you" renders from the same list as
191
+ everyone else. `gem.showcase.of(playerId, key)` reads one player instead, and
192
+ `gem.showcase.clear(key)` removes your value; clearing an empty slot is not an error. And when
193
+ what you are building is a whole **inspect-this-player screen** — identity plus every visible
194
+ value at once, not one slot at a time — that is `gem.profiles.of(playerId)`, one request; the
195
+ profiles section of [`friends.md`](friends.md) covers it.
196
+
197
+ Four things to hold on to:
198
+
199
+ - **The batch is a rendering, not live state.** It is cached per viewer for a short window —
200
+ `generatedAt` dates it — so a friend's write moments ago may not be in it yet. The same
201
+ freshness rule as the friend leaderboard: display data lags a little, and polling harder
202
+ re-downloads the same rendering. These calls also share the saves routes' 120-a-minute
203
+ bucket, which is one more reason not to.
204
+ - **`of()` resolves `null` for every refusal at once, deliberately.** The platform answers one
205
+ indistinguishable not-found whether nothing was written, the slot's visibility does not admit
206
+ you, that player blocked you, or the key is not a showcase slot at all — so a response can
207
+ never reveal whether a hidden value exists. The corollary bites in development: a mistyped
208
+ key looks exactly like a player with nothing to show. If every read is `null`, check the key
209
+ against your config before anything else. `friends()` is the exception — its not-found can
210
+ only mean the slot itself, so it throws as the config error it is.
211
+ - **A showcase value is display data.** The player writes their own, which means a modified
212
+ client writes whatever fits — a shown-off number was never trustworthy. A game that wants
213
+ unforgeable values declares `access_self_write: false` and `access_game_server_write: true`
214
+ on the slot and writes it from the dedicated server, the same shape as an unforgeable save.
215
+ - **Keep the save and the showcase separate on purpose.** Real progress goes in the save;
216
+ `set()` publishes the small displayable summary next to it, and nothing updates one when the
217
+ other changes. Last writer wins on a showcase slot — display data carries no version to race
218
+ on — and there is no binary form: a value is JSON, or it is refused.
219
+
220
+ ## Saving against a deadline
221
+
222
+ Most saves happen with all the time in the world — a level cleared, a shop closed. Two do not,
223
+ and both of them are clocks somebody else set.
224
+
225
+ **A browser's `onWillTerminate` has whatever the embedder allows** — it arrives as `graceMs` on
226
+ the notice, so you are told the number but do not get to argue with it, and it can be a couple of
227
+ seconds. That is the tight one. Back, a closed tab and a reload give you no warning at all.
228
+
229
+ **A dedicated server's `shutdown` has ten minutes** when the match is over. It is long on
230
+ purpose: saving what just happened is exactly what a bundle is expected to do there, and the pod
231
+ holding fleet capacity for a few minutes is the cheaper side of that trade. A node drain is not
232
+ even bounded by that — the runtime waits for the bundle however long it takes, out to the pod's
233
+ own two-hour limit.
234
+
235
+ ### Does a save fit?
236
+
237
+ **Comfortably on both clocks, unless the payload is large and the uplink is poor.** The
238
+ arithmetic is short, because there is only one thing to count:
239
+
240
+ > **a save ≈ 3 × round-trip time + payload ÷ uplink**
241
+
242
+ The SDK's own cost is not in it. Measured on this branch, `put` spends **0.35 ms** of its own on
243
+ a 4 KiB save and **4.5 ms** on one at the 1 MiB default ceiling — serialising, encoding and
244
+ sequencing the hops. Against any of these budgets that is nothing, and it is why the number that
245
+ decides whether a save fits is your payload and your connection rather than anything the SDK
246
+ does.
247
+
248
+ Measured end to end, driving the real helper with each hop's latency injected:
249
+
250
+ | Round trip | Uplink | 256 KiB save | 1 MiB save |
251
+ | ---------- | ---------- | ------------ | ---------- |
252
+ | 10 ms | datacenter | 0.03 s | 0.04 s |
253
+ | 50 ms | 10 Mbit/s | 0.36 s | 1.0 s |
254
+ | 100 ms | 10 Mbit/s | 0.51 s | 1.2 s |
255
+ | 200 ms | 1 Mbit/s | 2.7 s | **9.0 s** |
256
+
257
+ A dedicated server writes from inside a datacenter, on a connection it has been using all match,
258
+ so it lands in the top row: four hundredths of a second against a ten-minute window. **A bundle's
259
+ end-of-match save is not the case to worry about.** The three round trips are the same three from
260
+ a server bundle, which makes them through `gem.api.*` rather than `gem.me.saves` — see
261
+ [`server-bundles.md`](server-bundles.md) for the calls a game-server token makes — so the table
262
+ holds there too.
263
+
264
+ The bottom row is a browser on a poor uplink, and that is the case that bites, because the budget
265
+ there is the embedder's and may be shorter than the save. The number to take from it is that a
266
+ payload near the 1 MiB ceiling needs about **eight seconds of uplink alone** at 1 Mbit/s. That is
267
+ what makes a big save unsaveable in a teardown window — the payload, not the round trips.
268
+
269
+ **How this was measured, so you can weigh it:** the SDK's own cost is a real measurement of the
270
+ shipped code. The round trips are injected latency rather than a live save timed from inside a
271
+ match pod, so read the table as the arithmetic confirmed rather than as a production trace.
272
+
273
+ ### Give the call the deadline you actually have
274
+
275
+ The helper waits **30 seconds** by default, which outlives most browser teardown windows and sits
276
+ well inside a bundle's ten minutes. Left at the default in a window shorter than it, a save that
277
+ stalls is killed mid-flight: the promise never settles, nothing is logged, and your game ends up
278
+ with neither the save nor a reason.
279
+
280
+ Every save and load takes the budget you are actually racing, and rejects inside it:
281
+
282
+ ```js
283
+ const gem = createGem({
284
+ onWillTerminate(notice) {
285
+ // The parent told you how long it will wait. Spend a little less than that,
286
+ // so a failure still has somewhere to be handled.
287
+ gem.me.saves
288
+ .put('campaign', state, { timeoutMs: Math.max(1000, notice.graceMs - 500) })
289
+ .catch(report);
290
+ },
291
+ });
292
+ ```
293
+
294
+ `timeoutMs` is on `put`, `putBytes`, `get` and `getBytes`; it covers every hop together, and it
295
+ is a ceiling rather than a target — it does not make a save faster, it decides when to stop
296
+ waiting for one. A save that misses it rejects as `GemSaveError` with reason `save_timeout`,
297
+ naming the budget you gave it rather than the default, so a log tells you which of the two you
298
+ were running under.
299
+
300
+ **The save that fits a teardown window is a small one.** Nothing here turns three round trips
301
+ into fewer, so the durable answer is not to be holding a megabyte of unsaved progress when the
302
+ clock starts — save on the events that matter, and let the teardown flush be a small delta.
303
+
304
+ ## What can go wrong, and which of it is worth retrying
305
+
306
+ | What you get | What happened | What to do |
307
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
308
+ | `not_found` | the slot is not declared on this channel, or `get` found no save | publish the slot; or treat a null read as a new player |
309
+ | `invalid` | the key is malformed, or the metadata is not what the slot takes | fix the call; no retry helps |
310
+ | `precondition_failed` | something else saved this slot while yours was in flight | re-read, rebuild, save again — **never** retry the same payload |
311
+ | `too_large` | the payload is over the slot's ceiling, or this player's or this game's save storage is full — `code` says which | **do not retry.** Store less or raise `max_size_bytes`; or delete a save, or tell the player |
312
+ | `forbidden` | the player's session ended while the save was in flight | a fresh session, not a retry |
313
+ | `rate_limited` | too many save calls from this network address | back off by what it tells you; then save less often |
314
+ | `GemSaveError` | the payload could not be pushed to or pulled from storage | see below |
315
+
316
+ **`too_large` is the one to get right.** It used to reach game code labelled as a retryable
317
+ server fault, so a game that honoured that flag retried a refusal that can never succeed —
318
+ against a limit counted per network address, which two players behind one connection share. It
319
+ is now its own error, and `retryable` on it is `false`. A player whose saves have filled their
320
+ allowance meets this on every save until something is deleted; that is a message to show, not a
321
+ loop to enter.
322
+
323
+ **`GemSaveError` is the half that does not go through the API.** Your payload travels to and
324
+ from storage over a short-lived signed link, so a failure there is not an API error and cannot
325
+ be classified as one. It carries `reason`: `upload_failed`, `download_failed`, `save_timeout`,
326
+ or `save_malformed` for a payload that is not what it claims.
327
+
328
+ **If you see `upload_failed` on every save, from every player, in a browser** — that is not a
329
+ transient fault and no amount of retrying moves it. Two causes have that shape, both platform
330
+ side, and neither is anything your game can change:
331
+
332
+ - The storage account does not allow your game's origin to `PUT`. This is what the arcade's own
333
+ storage rules decide; a game origin was read-only until 2026-08, which meant **no browser game
334
+ could store a save at all** for the whole of gem-web-sdk v2.2.0 through v2.4.2.
335
+ - The upload reached storage without a header that storage requires. The SDK sends whatever the
336
+ platform's grant names in `upload_headers` and nothing it invented, so a mismatch here is a
337
+ disagreement between the platform and its own storage, not a call you made wrong.
338
+
339
+ Either one is worth reporting rather than working around. It is **not** a content-security-policy
340
+ problem: game origins carry no fetch directives by design.
341
+
342
+ ## The bounds, as numbers
343
+
344
+ | | |
345
+ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
346
+ | Key name | 1–64 characters, ASCII letters, digits and underscores; cannot begin with `_` or `$` |
347
+ | Slots per config | 1000 |
348
+ | One save's payload | whatever its slot declares; **1 MiB** if it does not say |
349
+ | One showcase value | **4096 bytes** of JSON, serialised — the ceiling for any slot whose `visibility` is `friends` or `public`, and such a slot cannot declare more |
350
+ | One player, one game | **100 MiB** across all their slots |
351
+ | One game | **10 GiB** across all its players, per channel |
352
+ | Metadata | **4096 bytes** of JSON after normalization, ≤50 keys per object, keys ≤64 bytes, string values ≤1024 bytes, nested no more than two levels below the top |
353
+ | Content type | `application/octet-stream` (the default for bytes), `application/json`, `application/x-protobuf`, `application/msgpack`, `application/cbor`, `application/x-binary` |
354
+ | Saves in flight, per player per game | **5** |
355
+ | How long one save has to complete | **10 minutes** for a browser game, from the first of its three calls. This is the upload credential's own clock, not the end-of-match shutdown grace that happens to share the number. That credential is not bound to your origin, so it is deliberately shorter-lived than the hour a native client gets |
356
+ | A download link | **15 minutes** |
357
+ | Request rate | **120 a minute** across these routes, counted per network address, and refused rather than waved through if the limiter is unreachable |
358
+
359
+ Two players behind one home connection share that last bucket. It is the reason a per-frame
360
+ autosave is the shape to avoid, and it is why the answer to `rate_limited` is to save less
361
+ often rather than to retry harder.
362
+
363
+ ## For something small, reach for settings instead
364
+
365
+ `gem.me.settings` needs no declaration and is one call: `put(key, value)` with a **string**
366
+ value, up to **8 KiB** each and **32** keys per player per game. It is the right home for a
367
+ chosen difficulty, a control scheme, a level counter.
368
+
369
+ Reach for a save when the payload is a document rather than a value — a world, an inventory, a
370
+ replay. Something that belongs to **this device** rather than to the player — a volume, a graphics
371
+ quality — belongs in neither; that is the next section. Progress a player would be upset to lose
372
+ belongs in a save, and nowhere else.
373
+
374
+ ### Device settings: local to this device, and only that
375
+
376
+ `gem.deviceSettings` is the third store, and the one that does **not** follow the player. The
377
+ Arcade keeps a small object for your game on the device it is being played on, hands it to your
378
+ game once per session, and stores whatever your game puts back. It exists because your game's own
379
+ browser storage cannot do this: every build of a web game runs on its own origin, so a value your
380
+ game kept in `localStorage` is gone the moment you publish again. The Arcade's own storage outlives
381
+ your builds; that is the whole mechanism.
382
+
383
+ **What goes here:** a master volume and the music/SFX mix, a graphics quality or resolution scale,
384
+ a chosen input layout, a tip the player dismissed, a "skip intro" toggle. Configuration that is
385
+ about **this screen and these speakers**.
386
+
387
+ ⚠️ **What never goes here: progression, unlocks, currency, anything a player would mind losing,
388
+ and anything that must follow them to another device.** This is not a save and it is not a
389
+ substitute for one:
390
+
391
+ - **It does not roam.** Another device, another browser, or the same browser after clearing site
392
+ data starts from nothing. A save follows the player; `gem.me.settings` follows the player; this
393
+ does not.
394
+ - **It is shared by every account on the device.** Two players on one family television share a
395
+ volume level. Nothing about the player is in the key.
396
+ - **It can vanish at any time**, and the Arcade may evict it. A game must boot correctly from
397
+ `null` — apply your defaults and carry on — and must never treat "nothing stored" as a first run
398
+ for progression purposes.
399
+ - **It is capped at 16 KiB** of serialised JSON, on purpose: a cap a save does not fit inside is the
400
+ surest way to keep saves out of it. Over the cap, `put` resolves `too_large` and nothing is
401
+ stored.
402
+
403
+ ```js
404
+ // On boot. Short budget; `null` means "use your defaults" and is the common case.
405
+ const settings = (await gem.deviceSettings.wait(1_500)) ?? {};
406
+ audio.setMasterVolume(settings.volume ?? 1);
407
+ renderer.setQuality(settings.quality ?? 'auto');
408
+
409
+ // When the player changes a setting: the WHOLE object, every time.
410
+ async function applySettings(next) {
411
+ audio.setMasterVolume(next.volume);
412
+ renderer.setQuality(next.quality);
413
+ const outcome = await gem.deviceSettings.put({ v: 1, ...next }); // 'stored' | 'too_large' | 'unavailable'
414
+ }
415
+ ```
416
+
417
+ Three things the shape above is doing on purpose:
418
+
419
+ - **Whole-object replace, no keys.** There is no `put(key, value)`; send the entire object with the
420
+ field that changed changed. The Arcade stores what it is sent and nothing else, and last writer
421
+ wins — across two tabs of the same game too.
422
+ - **Version your blob** (`v: 1`) and treat any shape as possible on read. What you read back was
423
+ written by a previous build of your game, not this one.
424
+ - **Every outcome resolves, none is an error.** `unavailable` means the Arcade could not store it
425
+ just now — a private window, a full quota. Keep running on the value you hold; a device that
426
+ cannot remember a volume is a device where the player sets it again. Do not retry in a loop, and
427
+ debounce a slider: the Arcade meters writes at thirty a minute.
428
+
429
+ You never say which game or which channel this is for. The Arcade files the object by the game and
430
+ the channel it is running — so a `dev` build's settings never reach the `release` build of the same
431
+ game, and a key you experiment with on `dev` cannot break a shipped build.
432
+
433
+ | You want | Where it goes | Follows the player? |
434
+ | -------- | ------------- | ------------------- |
435
+ | a world, an inventory, a replay | `gem.me.saves` — a declared slot | yes |
436
+ | a difficulty, a control scheme, a level counter | `gem.me.settings` — a string per key | yes |
437
+ | a volume, a quality preset, a dismissed tip | `gem.deviceSettings` — one object per device | **no** |
438
+
439
+ ## What the platform already does with these, so you do not
440
+
441
+ **A player's erasure covers their saves.** When an account is erased the stored payloads go with
442
+ the rows, and the game's usage is reconciled afterwards. **A data export covers them too** — by
443
+ reference, as short-lived download links against the same payloads, so a player asking what you
444
+ hold gets the saves as well as everything else.
445
+
446
+ Neither is something you implement, and both are reasons not to keep a second authoritative copy
447
+ of a player's progress somewhere the platform cannot see: a copy in your own backend is a copy
448
+ neither of those reaches.
449
+
450
+ ## Where the rest of it is
451
+
452
+ - [`what-a-game-must-do.md`](what-a-game-must-do.md) — what a game still owes the platform, and
453
+ the boot sequence that decides whether the handshake lands at all.
454
+ - [`platform-rules.md`](platform-rules.md) — how config is published and promoted, and the rules
455
+ gem-api enforces that no client-side signature states.
456
+ - [`server-bundles.md`](server-bundles.md) — the server half of a dedicated match calls these
457
+ same save operations, under Saves, and its `shutdown` is the ten-minute window above.