@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.
- package/README.md +54 -0
- package/dist/gem-web-sdk.esm.js +1 -1
- package/dist/gem-web-sdk.game.js +1 -1
- package/dist/gem-web-sdk.min.js +1 -1
- package/dist/gem-web-sdk.sdk.js +1 -1
- package/dist/gem-web-sdk.server.js +1 -1
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +530 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +5 -5
- 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.
|