@indiegems/gem-web-sdk 16.0.1 → 16.2.0

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.
@@ -150,6 +150,163 @@ silently. Use `putBytes` and `getBytes`, and name the content type you are actua
150
150
  `saves` exactly like the player's, scoped to that character — the same declared slots, separate
151
151
  stored saves. Most browser games have no characters and can ignore this.
152
152
 
153
+ ## Handling the common cases
154
+
155
+ The one failure that does real harm is a game that could not load the player's save, started
156
+ from defaults, and then saved — over the player's real progress. Everything in this section is
157
+ about never doing that, and about not hammering the platform while you avoid it.
158
+
159
+ ### Load before you save
160
+
161
+ **Load a slot with `get` (or `getBytes`) before you first write it.** The SDK remembers, per
162
+ slot, the version that load found, and sends it with every write. The platform then refuses any
163
+ write that would replace a save you have not seen: one another device or tab wrote since your
164
+ load. Each successful write updates the version, so you never pass it yourself.
165
+
166
+ What the SDK knows about a slot decides what a write to it does:
167
+
168
+ | The slot's last load or write here | What a write sends | It is refused when |
169
+ | ---------------------------------- | ---------------------------------------- | ------------------------------------- |
170
+ | found a save, or wrote one | that save's version | the stored save is a different one |
171
+ | found no save, or deleted it | "only if there is still no save" | a save has appeared since |
172
+ | **failed** | nothing — the SDK refuses it before that | always, until a load succeeds |
173
+ | never happened in this session | no condition, as before | never; a write here replaces anything |
174
+
175
+ `describe` and `list` do not count as a load: they read records, not your progress.
176
+
177
+ ### When the load fails
178
+
179
+ A failed load (a `rate_limited` answer, a timeout, the player offline) means you do not know
180
+ what is stored. **Never write defaults over an unknown save.** Instead:
181
+
182
+ - keep the game playable, but do not save, and say so if the player would expect it to;
183
+ - retry the load later, with a backoff, and resume saving once one succeeds;
184
+ - if the player chooses to start over, write with `overwrite: true` — the only way past it.
185
+
186
+ A write to a slot whose last load failed is refused with `GemSaveError` reason `load_failed`
187
+ before anything is sent. It is your safety net, not the plan: check the load's result instead.
188
+
189
+ ```js
190
+ let canSave = false;
191
+ let state = defaults();
192
+
193
+ async function load() {
194
+ try {
195
+ const saved = await gem.me.saves.get('campaign', { timeoutMs: 5000 });
196
+ if (saved) state = saved;
197
+ canSave = true; // a null means a new player, and saving is safe
198
+ } catch {
199
+ setTimeout(load, 30_000); // play on, unsaved, and try again later
200
+ }
201
+ }
202
+ ```
203
+
204
+ A load that fails after an earlier one succeeded keeps the earlier version: you still hold that
205
+ copy, and a write conditioned on it is as safe as it was.
206
+
207
+ ### Don't let the boot read hold the game
208
+
209
+ Give the boot read a short `timeoutMs`, a few seconds, and if it fails start the game anyway in
210
+ the unsaved state above, rather than holding the loading screen. A load that meets a rate limit
211
+ whose wait will not fit in that `timeoutMs` does not sleep through it: it rejects at once as
212
+ `rate_limited` (below), so the loading screen is not held either way.
213
+
214
+ ### When a save conflicts
215
+
216
+ A write refused because the stored save changed rejects as `GemSaveError` reason
217
+ `save_conflict`. Nothing was written. Resending the same write fails the same way, so:
218
+
219
+ 1. load the slot again (`get`), which learns the new version;
220
+ 2. decide what to write: merge the two, keep the stored one, or ask the player which to keep;
221
+ 3. write the result, which now replaces the version you just loaded.
222
+
223
+ To keep this device's progress after asking the player, write with `overwrite: true`.
224
+
225
+ ### Save on events, and debounce
226
+
227
+ Save when something worth keeping happens — a level cleared, a purchase, a run ended — never on
228
+ a frame timer or on every change. When changes come fast, wait until they stop for a few
229
+ seconds, then write once.
230
+
231
+ **The SDK keeps one write per slot in flight, and spaces them.** A `put` made while another to
232
+ the same slot is in flight waits for it, and a later `put` replaces the one waiting: the newest
233
+ value is the one sent, and every call it replaced resolves with the same record. Writes to one
234
+ slot go out at least five seconds apart; a write made sooner waits its turn, though never past
235
+ half its own `timeoutMs`. This is a floor under your game, not a debounce: it keeps a runaway save
236
+ loop to a few writes a minute, but your own debounce is still what decides how often you save.
237
+
238
+ A replaced call keeps its own options. Its `signal` and `timeoutMs` still reject that call alone,
239
+ without cancelling the newer write; the write that goes out runs on the newest call's. Its
240
+ `teardown` and `overwrite` still apply to that write: an autosave that lands behind your teardown
241
+ flush does not make it wait out the spacing, and one that lands behind a confirmed "new game"
242
+ still overwrites. A call that gives up first takes its `teardown` and `overwrite` with it. So
243
+ does a call replaced after the signed-in player changed: it is refused as `save_conflict`, so one
244
+ player's "new game" never overwrites the next player's save.
245
+
246
+ **Do not re-save immediately because the state changed while a write was in flight.** That loop
247
+ writes back to back, defeats any debounce, and spends the rate limit every other player behind
248
+ the same network address shares. The spacing above limits the damage; it does not make the loop
249
+ right. Wait out the debounce again.
250
+
251
+ ```js
252
+ let dirty = false;
253
+ let writing = null;
254
+ let timer = null;
255
+
256
+ function changed() {
257
+ dirty = true;
258
+ clearTimeout(timer);
259
+ timer = setTimeout(flush, 5000);
260
+ }
261
+
262
+ async function flush() {
263
+ if (writing || !dirty || !canSave) return;
264
+ dirty = false;
265
+ writing = gem.me.saves.put('campaign', state).catch(onSaveError);
266
+ await writing;
267
+ writing = null;
268
+ if (dirty) changed(); // wait the debounce again, never write at once
269
+ }
270
+ ```
271
+
272
+ ### On a 429, back off
273
+
274
+ A save or load refused for the rate rejects as `GemApiError` reason `rate_limited`, with
275
+ `retryAfterSeconds` when the platform named a wait. **That is the one shape, whichever leg was
276
+ refused**: the platform's checks, the upload or download of the bytes, or a wait the platform
277
+ asked for that your call's `timeoutMs` will not cover, which rejects at once instead of
278
+ sleeping. So one test covers every save loop:
279
+
280
+ ```js
281
+ function onSaveError(err) {
282
+ if (err instanceof GemApiError && err.reason === 'rate_limited') {
283
+ holdSavesFor(err.retryAfterSeconds ?? 60); // keep the state dirty
284
+ }
285
+ }
286
+ ```
287
+
288
+ **Never retry at once.** Hold every write for at least `retryAfterSeconds` (a minute if it is
289
+ absent), keep the state dirty, and write once when the wait is over. A rate-limited load is the
290
+ "load fails" case above.
291
+
292
+ The SDK holds too. After a `rate_limited` answer on any leg, it sends no write for the wait the
293
+ answer named: a write made meanwhile waits for it, or, when the wait outlasts its `timeoutMs`,
294
+ rejects at once as `rate_limited` without being sent.
295
+
296
+ In earlier releases of this SDK, the same refusal could also arrive as a `save_timeout` from a call whose `timeoutMs`
297
+ ran out while it waited out a rate limit, or as an `upload_failed` whose message named a 429. A
298
+ game that matched on either can drop it.
299
+
300
+ ### Saving on teardown
301
+
302
+ `onWillTerminate` gives you `graceMs`, and Back, a closed tab or a reload give you nothing. Keep
303
+ your last save recent, and in the callback flush only what changed, with a `timeoutMs` inside the
304
+ grace and `teardown: true` — see [Saving against a deadline](#saving-against-a-deadline).
305
+ `teardown` sends the write without waiting for the spacing between writes; it still waits for a
306
+ write already in flight to that slot, and still honours a rate-limit hold, rejecting at once as
307
+ `rate_limited` when the hold outlasts the grace. Skip the flush when the slot cannot be saved (its
308
+ load failed) or your own rate-limit hold is active.
309
+
153
310
  ## Showcase slots: a little progress your friends can read
154
311
 
155
312
  Everything above is the player's own document, read back by the same player. A **showcase
@@ -285,7 +442,10 @@ const gem = createGem({
285
442
  // The parent told you how long it will wait. Spend a little less than that,
286
443
  // so a failure still has somewhere to be handled.
287
444
  gem.me.saves
288
- .put('campaign', state, { timeoutMs: Math.max(1000, notice.graceMs - 500) })
445
+ .put('campaign', state, {
446
+ timeoutMs: Math.max(1000, notice.graceMs - 500),
447
+ teardown: true, // skip the spacing between writes; see "Saving on teardown"
448
+ })
289
449
  .catch(report);
290
450
  },
291
451
  });
@@ -293,9 +453,11 @@ const gem = createGem({
293
453
 
294
454
  `timeoutMs` is on `put`, `putBytes`, `get` and `getBytes`; it covers every hop together, and it
295
455
  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`,
456
+ waiting for one. It counts from the call, so a write waiting for the one before it on the same
457
+ slot spends it waiting. A save that misses it rejects as `GemSaveError` with reason `save_timeout`,
297
458
  naming the budget you gave it rather than the default, so a log tells you which of the two you
298
- were running under.
459
+ were running under. A rate limit whose wait it will not cover is not a miss: that rejects at once
460
+ as `rate_limited`.
299
461
 
300
462
  **The save that fits a teardown window is a small one.** Nothing here turns three round trips
301
463
  into fewer, so the durable answer is not to be holding a megabyte of unsaved progress when the
@@ -310,7 +472,7 @@ clock starts — save on the events that matter, and let the teardown flush be a
310
472
  | `precondition_failed` | something else saved this slot while yours was in flight | re-read, rebuild, save again — **never** retry the same payload |
311
473
  | `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
474
  | `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 |
475
+ | `rate_limited` | too many save calls from this network address, refused on any leg of the save or load | back off by what it tells you; then save less often |
314
476
  | `GemSaveError` | the payload could not be pushed to or pulled from storage | see below |
315
477
 
316
478
  **`too_large` is the one to get right.** It used to reach game code labelled as a retryable
@@ -323,7 +485,11 @@ loop to enter.
323
485
  **`GemSaveError` is the half that does not go through the API.** Your payload travels to and
324
486
  from storage over a short-lived signed link, so a failure there is not an API error and cannot
325
487
  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.
488
+ or `save_malformed` for a payload that is not what it claims. Two more refuse a write to keep
489
+ the player's progress safe, and neither is worth retrying as it stands: `save_conflict`, the
490
+ stored save changed since you loaded it, and `load_failed`, the slot's last load failed. See
491
+ [Handling the common cases](#handling-the-common-cases). A rate limit on the storage leg is not a
492
+ `GemSaveError`: it is `GemApiError` reason `rate_limited`, like every other leg's.
327
493
 
328
494
  **If you see `upload_failed` on every save, from every player, in a browser** — that is not a
329
495
  transient fault and no amount of retrying moves it. Two causes have that shape, both platform
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v16.0.1/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v16.0.1/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v16.2.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v16.2.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "16.0.1",
3
+ "version": "16.2.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -4470,6 +4470,11 @@
4470
4470
  "maxLength": 32,
4471
4471
  "optional": true,
4472
4472
  "$comment": "The game's own name for the screen it is leaving - 'world-1-3', 'results'. UNTRUSTED TEXT. Shown ONLY in a development host's log, so a reviewer can map moments to screens. NEVER FORWARDED TO THE PLATFORM, NEVER STORED, NEVER RENDERED TO A PLAYER AND NEVER SENT TO AN AD NETWORK: a game-authored string that fits this grammar fits a dashless UUID too. The SDK sends only `^[a-z0-9_-]{1,32}$`; codegen emits type and length only, so the charset is the sender's to keep and the receiver's to re-check."
4473
+ },
4474
+ "wall": {
4475
+ "kind": "boolean",
4476
+ "optional": true,
4477
+ "$comment": "`true` - THE GAME ACCEPTS THAT THE PLATFORM MAY SHOW ITS APP WALL AT THIS BREAK: a panel that may leave the player no way on in this browser, so a game sets it only at a break where its progress is already saved and stopping there is acceptable. Absent or `false` is an ordinary break, exactly as before this field existed; the SDK sends it only as `true`. AN ACCEPTANCE, NOT AN ASK: the parent's arbiter still decides, under its own mode, whether anything is shown, and usually nothing is. It only ever widens what the platform may do over THIS game, so a frame that lies about it costs only itself, and it is never a reason to show anything to anyone else. An N-1 parent ignores it as an unknown field and treats the break as ordinary."
4473
4478
  }
4474
4479
  },
4475
4480
  "rateLimit": {