@indiegems/gem-web-sdk 16.1.0 → 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.
- package/CHANGELOG.md +18 -0
- package/dist/function.d.ts +27 -3
- package/dist/game.d.ts +27 -3
- package/dist/gem-web-sdk.conformance.js +3 -3
- package/dist/gem-web-sdk.esm.js +3 -3
- package/dist/gem-web-sdk.function.js +3 -3
- package/dist/gem-web-sdk.game.js +3 -3
- package/dist/gem-web-sdk.min.js +3 -3
- package/dist/gem-web-sdk.sdk.js +3 -3
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/sdk.d.ts +29 -3
- package/dist/server-api.d.ts +3 -0
- package/dist/server.d.ts +3 -0
- package/docs/api/api-reference-function.md +169 -5
- package/docs/api/api-reference-game.md +169 -5
- package/docs/api/api-reference-server.md +1 -1
- package/docs/api/reference.json +145 -17
- package/docs/changelog.md +18 -0
- package/docs/getting-started.md +1 -1
- package/docs/manifest.json +8 -8
- package/docs/saving-player-progress.md +171 -5
- package/docs/server-bundles.md +2 -2
- package/package.json +1 -1
package/docs/api/reference.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema_version": 2,
|
|
3
3
|
"source": {
|
|
4
4
|
"repo": "IndieGems/gem-web-sdk",
|
|
5
|
-
"version": "16.
|
|
5
|
+
"version": "16.2.0"
|
|
6
6
|
},
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -2805,7 +2805,7 @@
|
|
|
2805
2805
|
"group": "saves",
|
|
2806
2806
|
"signature": "readonly etag: string",
|
|
2807
2807
|
"documented": true,
|
|
2808
|
-
"summary": "This save's version, which changes whenever the save does.\n\nNOT something to pass back on a write —
|
|
2808
|
+
"summary": "This save's version, which changes whenever the save does.\n\nNOT something to pass back on a write — the SDK remembers the version of\neach slot you last loaded or wrote and sends it for you, so a write that\nwould replace a save you never saw is refused as `save_conflict`. This is\nhow a game holding a copy tells whether the stored save is still the one\nit has.",
|
|
2809
2809
|
"deprecated": null,
|
|
2810
2810
|
"params": [],
|
|
2811
2811
|
"returns": null,
|
|
@@ -2936,9 +2936,9 @@
|
|
|
2936
2936
|
"name": "reason",
|
|
2937
2937
|
"anchor": "gemsaveerror-reason",
|
|
2938
2938
|
"group": "failures",
|
|
2939
|
-
"signature": "readonly reason: \"upload_failed\" | \"download_failed\" | \"save_timeout\" | \"save_malformed\"",
|
|
2939
|
+
"signature": "readonly reason: \"upload_failed\" | \"download_failed\" | \"save_timeout\" | \"save_malformed\" | \"save_conflict\" | \"load_failed\"",
|
|
2940
2940
|
"documented": true,
|
|
2941
|
-
"summary": "Which part failed.\n\n- `upload_failed` — the bytes could not be written to storage.\n- `download_failed` — the stored bytes could not be fetched.\n- `save_timeout` — a hop did not finish in the time the call allows.\n- `save_malformed` — the answer, or the stored payload, was not what it\n claims to be.\n\n`save_timeout`, and a one-off `upload_failed` or `download_failed`, are\nworth one retry. `save_malformed` is not: the same bytes will fail the\nsame way.\n\nAn `upload_failed` that repeats on EVERY save, for every player, is not\ntransient either — it is a platform-side misconfiguration, and retrying\nis a loop rather than a recovery. See the saves guide.",
|
|
2941
|
+
"summary": "Which part failed.\n\n- `upload_failed` — the bytes could not be written to storage.\n- `download_failed` — the stored bytes could not be fetched.\n- `save_timeout` — a hop did not finish in the time the call allows.\n- `save_malformed` — the answer, or the stored payload, was not what it\n claims to be.\n- `save_conflict` — a write was refused because the stored save changed\n since this game last loaded or wrote it: another device or tab saved\n in between. Nothing was written.\n- `load_failed` — a write was refused before anything was sent, because\n the last load of this slot failed, so what the write would replace is\n unknown — it could be the player's real progress.\n\n`save_timeout`, and a one-off `upload_failed` or `download_failed`, are\nworth one retry. `save_malformed` is not: the same bytes will fail the\nsame way.\n\nNeither is `save_conflict` or `load_failed`: resending the same write\nfails the same way. Load the slot again and decide what to write from\nwhat is stored — merge, or ask the player. Pass `overwrite: true` only to\nreplace whatever is stored on purpose.\n\nAn `upload_failed` that repeats on EVERY save, for every player, is not\ntransient either — it is a platform-side misconfiguration, and retrying\nis a loop rather than a recovery. See the saves guide.\n\nA RATE LIMIT IS NOT ONE OF THESE. Refused for the rate on any leg — the\nplatform, the upload, the download — a save or load rejects as\n`GemApiError` reason `rate_limited`, with `retryAfterSeconds` when a wait\nwas named.",
|
|
2942
2942
|
"deprecated": null,
|
|
2943
2943
|
"params": [],
|
|
2944
2944
|
"returns": null,
|
|
@@ -2991,7 +2991,7 @@
|
|
|
2991
2991
|
"group": "saves",
|
|
2992
2992
|
"signature": "readonly signal?: AbortSignal",
|
|
2993
2993
|
"documented": true,
|
|
2994
|
-
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.",
|
|
2994
|
+
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.\n\nA write that replaced earlier ones still waiting on its slot carries them,\nso cancelling it before it goes out cancels them too. Cancelling a write\nthat a later one replaced rejects only this call; the later write goes on.",
|
|
2995
2995
|
"deprecated": null,
|
|
2996
2996
|
"params": [],
|
|
2997
2997
|
"returns": null,
|
|
@@ -3007,7 +3007,39 @@
|
|
|
3007
3007
|
"group": "saves",
|
|
3008
3008
|
"signature": "readonly timeoutMs?: number",
|
|
3009
3009
|
"documented": true,
|
|
3010
|
-
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from [GemTerminationNotice](api-reference-game.md#gemterminationnotice), whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.",
|
|
3010
|
+
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from [GemTerminationNotice](api-reference-game.md#gemterminationnotice), whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.\n\nIt counts from the call, so a write waiting for the one before it on the\nsame slot spends it waiting. A rate limit whose wait it will not cover\nrejects at once as `GemApiError` reason `rate_limited`, not as\n`save_timeout`.\n\nIt stays this call's when a later write replaces this one: running out\nrejects only this call, and the later write goes on. The write that is\nsent runs on the newest call's `timeoutMs`, and running out of that rejects\nevery call it carries.",
|
|
3011
|
+
"deprecated": null,
|
|
3012
|
+
"params": [],
|
|
3013
|
+
"returns": null,
|
|
3014
|
+
"throws": null,
|
|
3015
|
+
"example": null,
|
|
3016
|
+
"defaultValue": null,
|
|
3017
|
+
"guides": []
|
|
3018
|
+
},
|
|
3019
|
+
{
|
|
3020
|
+
"id": "PutSaveOptions.teardown",
|
|
3021
|
+
"name": "teardown",
|
|
3022
|
+
"anchor": "putsaveoptions-teardown",
|
|
3023
|
+
"group": "saves",
|
|
3024
|
+
"signature": "readonly teardown?: boolean",
|
|
3025
|
+
"documented": true,
|
|
3026
|
+
"summary": "This is the last write before the page goes away: send it without waiting\nfor the spacing between writes to one slot.\n\nWrites to one slot go out a few seconds apart, and a write made sooner\nwaits its turn. Set this on the write you make from\n[GemTerminationNotice](api-reference-game.md#gemterminationnotice) or `pagehide`, with a `timeoutMs` inside the\ntime you have.\n\nIT STILL WAITS FOR A WRITE ALREADY IN FLIGHT to the same slot, and it\nstill honours a rate-limit hold: when the hold outlasts `timeoutMs` the\nwrite rejects at once as `rate_limited`, and nothing is sent. Not for\nordinary saves — the spacing is what keeps a save loop from spending the\nrate limit.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out skips the spacing when any call it carries set `teardown`.\nNot across a player change: a call replaced after the signed-in player\nchanged is refused as `save_conflict`, and its `teardown` goes with it.",
|
|
3027
|
+
"deprecated": null,
|
|
3028
|
+
"params": [],
|
|
3029
|
+
"returns": null,
|
|
3030
|
+
"throws": null,
|
|
3031
|
+
"example": null,
|
|
3032
|
+
"defaultValue": null,
|
|
3033
|
+
"guides": []
|
|
3034
|
+
},
|
|
3035
|
+
{
|
|
3036
|
+
"id": "PutSaveOptions.overwrite",
|
|
3037
|
+
"name": "overwrite",
|
|
3038
|
+
"anchor": "putsaveoptions-overwrite",
|
|
3039
|
+
"group": "saves",
|
|
3040
|
+
"signature": "readonly overwrite?: boolean",
|
|
3041
|
+
"documented": true,
|
|
3042
|
+
"summary": "Write even if it replaces a save this game has not loaded.\n\nWithout it, a write to a slot whose last load FAILED is refused as\n`load_failed`, and a write is refused as `save_conflict` when the stored\nsave changed since you last loaded or wrote it. Both refusals protect the\nplayer's real progress from a game that started from defaults.\n\nSet it only when replacing whatever is stored is what you mean — a \"new\ngame\" the player confirmed, or the player choosing this device's progress\nafter a conflict. Never set it to get past a failed load.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out overwrites when any call it carries set `overwrite`. A call\nthat gives up first (its `signal` or `timeoutMs`) takes it back, and so\ndoes one replaced after the signed-in player changed: it is refused as\n`save_conflict`, so one player's \"new game\" never overwrites the next's.",
|
|
3011
3043
|
"deprecated": null,
|
|
3012
3044
|
"params": [],
|
|
3013
3045
|
"returns": null,
|
|
@@ -3060,7 +3092,7 @@
|
|
|
3060
3092
|
"group": "saves",
|
|
3061
3093
|
"signature": "readonly signal?: AbortSignal",
|
|
3062
3094
|
"documented": true,
|
|
3063
|
-
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.",
|
|
3095
|
+
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.\n\nA write that replaced earlier ones still waiting on its slot carries them,\nso cancelling it before it goes out cancels them too. Cancelling a write\nthat a later one replaced rejects only this call; the later write goes on.",
|
|
3064
3096
|
"deprecated": null,
|
|
3065
3097
|
"params": [],
|
|
3066
3098
|
"returns": null,
|
|
@@ -3076,7 +3108,39 @@
|
|
|
3076
3108
|
"group": "saves",
|
|
3077
3109
|
"signature": "readonly timeoutMs?: number",
|
|
3078
3110
|
"documented": true,
|
|
3079
|
-
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from [GemTerminationNotice](api-reference-game.md#gemterminationnotice), whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.",
|
|
3111
|
+
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from [GemTerminationNotice](api-reference-game.md#gemterminationnotice), whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.\n\nIt counts from the call, so a write waiting for the one before it on the\nsame slot spends it waiting. A rate limit whose wait it will not cover\nrejects at once as `GemApiError` reason `rate_limited`, not as\n`save_timeout`.\n\nIt stays this call's when a later write replaces this one: running out\nrejects only this call, and the later write goes on. The write that is\nsent runs on the newest call's `timeoutMs`, and running out of that rejects\nevery call it carries.",
|
|
3112
|
+
"deprecated": null,
|
|
3113
|
+
"params": [],
|
|
3114
|
+
"returns": null,
|
|
3115
|
+
"throws": null,
|
|
3116
|
+
"example": null,
|
|
3117
|
+
"defaultValue": null,
|
|
3118
|
+
"guides": []
|
|
3119
|
+
},
|
|
3120
|
+
{
|
|
3121
|
+
"id": "PutBytesOptions.teardown",
|
|
3122
|
+
"name": "teardown",
|
|
3123
|
+
"anchor": "putbytesoptions-teardown",
|
|
3124
|
+
"group": "saves",
|
|
3125
|
+
"signature": "readonly teardown?: boolean",
|
|
3126
|
+
"documented": true,
|
|
3127
|
+
"summary": "This is the last write before the page goes away: send it without waiting\nfor the spacing between writes to one slot.\n\nWrites to one slot go out a few seconds apart, and a write made sooner\nwaits its turn. Set this on the write you make from\n[GemTerminationNotice](api-reference-game.md#gemterminationnotice) or `pagehide`, with a `timeoutMs` inside the\ntime you have.\n\nIT STILL WAITS FOR A WRITE ALREADY IN FLIGHT to the same slot, and it\nstill honours a rate-limit hold: when the hold outlasts `timeoutMs` the\nwrite rejects at once as `rate_limited`, and nothing is sent. Not for\nordinary saves — the spacing is what keeps a save loop from spending the\nrate limit.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out skips the spacing when any call it carries set `teardown`.\nNot across a player change: a call replaced after the signed-in player\nchanged is refused as `save_conflict`, and its `teardown` goes with it.",
|
|
3128
|
+
"deprecated": null,
|
|
3129
|
+
"params": [],
|
|
3130
|
+
"returns": null,
|
|
3131
|
+
"throws": null,
|
|
3132
|
+
"example": null,
|
|
3133
|
+
"defaultValue": null,
|
|
3134
|
+
"guides": []
|
|
3135
|
+
},
|
|
3136
|
+
{
|
|
3137
|
+
"id": "PutBytesOptions.overwrite",
|
|
3138
|
+
"name": "overwrite",
|
|
3139
|
+
"anchor": "putbytesoptions-overwrite",
|
|
3140
|
+
"group": "saves",
|
|
3141
|
+
"signature": "readonly overwrite?: boolean",
|
|
3142
|
+
"documented": true,
|
|
3143
|
+
"summary": "Write even if it replaces a save this game has not loaded.\n\nWithout it, a write to a slot whose last load FAILED is refused as\n`load_failed`, and a write is refused as `save_conflict` when the stored\nsave changed since you last loaded or wrote it. Both refusals protect the\nplayer's real progress from a game that started from defaults.\n\nSet it only when replacing whatever is stored is what you mean — a \"new\ngame\" the player confirmed, or the player choosing this device's progress\nafter a conflict. Never set it to get past a failed load.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out overwrites when any call it carries set `overwrite`. A call\nthat gives up first (its `signal` or `timeoutMs`) takes it back, and so\ndoes one replaced after the signed-in player changed: it is refused as\n`save_conflict`, so one player's \"new game\" never overwrites the next's.",
|
|
3080
3144
|
"deprecated": null,
|
|
3081
3145
|
"params": [],
|
|
3082
3146
|
"returns": null,
|
|
@@ -3108,7 +3172,7 @@
|
|
|
3108
3172
|
"group": "saves",
|
|
3109
3173
|
"signature": "class SavesApi",
|
|
3110
3174
|
"documented": true,
|
|
3111
|
-
"summary": "A player's saved progress — write it, read it, list it, delete it.\n\nReached as `gem.me.saves`, and on a character as\n`gem.characters.get(id).saves`.\n\nA SAVE GOES IN A SLOT YOU DECLARED. Slots live in your game config, published\nwith `gemctl`, next to your room templates. Writing to a slot that was never\ndeclared fails as `not_found` naming the key, on the first call and every\nretry — that is the wall almost everyone hits first, and it is a config\nproblem rather than a code one.\n\n```js\nawait gem.me.saves.put('slot1', { level: 7, checkpoint: 'bridge' });\nconst state = await gem.me.saves.get('slot1');\n```\n\nWHAT IS DELIBERATELY NOT HERE: no autosave timer, no save format, no\nmigration. Those are your game's, and a platform that guessed at them would\nguess wrong.",
|
|
3175
|
+
"summary": "A player's saved progress — write it, read it, list it, delete it.\n\nReached as `gem.me.saves`, and on a character as\n`gem.characters.get(id).saves`.\n\nA SAVE GOES IN A SLOT YOU DECLARED. Slots live in your game config, published\nwith `gemctl`, next to your room templates. Writing to a slot that was never\ndeclared fails as `not_found` naming the key, on the first call and every\nretry — that is the wall almost everyone hits first, and it is a config\nproblem rather than a code one.\n\n```js\nawait gem.me.saves.put('slot1', { level: 7, checkpoint: 'bridge' });\nconst state = await gem.me.saves.get('slot1');\n```\n\nLOAD BEFORE YOU SAVE. Each write carries the version of the slot your last\nload or write found, so a write that would replace a save this game has not\nseen — another device's, or the player's real progress after a load that\nfailed — is refused as `save_conflict` or `load_failed` rather than stored.\nAfter a failed load, play on without saving and load again later; pass\n`overwrite: true` only when the player chose to start over.\n\nONE WRITE PER SLOT AT A TIME. A write made while another to the same slot is\nin flight waits for it, and a later one replaces it: the newest value is the\none sent, and every caller it replaced resolves with it, keeping its own\n`signal`, `timeoutMs`, `teardown` and `overwrite`. Writes to one slot\ngo out a few seconds apart (`teardown: true` skips that), and after a\n`rate_limited` answer none goes out until the wait it named is over.\n\nWHAT IS DELIBERATELY NOT HERE: no autosave timer, no save format, no\nmigration. Those are your game's, and a platform that guessed at them would\nguess wrong.",
|
|
3112
3176
|
"deprecated": null,
|
|
3113
3177
|
"params": [],
|
|
3114
3178
|
"returns": null,
|
|
@@ -32521,7 +32585,7 @@
|
|
|
32521
32585
|
"group": "player",
|
|
32522
32586
|
"signature": "readonly etag: string",
|
|
32523
32587
|
"documented": true,
|
|
32524
|
-
"summary": "This save's version, which changes whenever the save does.\n\nNOT something to pass back on a write —
|
|
32588
|
+
"summary": "This save's version, which changes whenever the save does.\n\nNOT something to pass back on a write — the SDK remembers the version of\neach slot you last loaded or wrote and sends it for you, so a write that\nwould replace a save you never saw is refused as `save_conflict`. This is\nhow a game holding a copy tells whether the stored save is still the one\nit has.",
|
|
32525
32589
|
"deprecated": null,
|
|
32526
32590
|
"params": [],
|
|
32527
32591
|
"returns": null,
|
|
@@ -32652,9 +32716,9 @@
|
|
|
32652
32716
|
"name": "reason",
|
|
32653
32717
|
"anchor": "gemsaveerror-reason",
|
|
32654
32718
|
"group": "errors",
|
|
32655
|
-
"signature": "readonly reason: \"upload_failed\" | \"download_failed\" | \"save_timeout\" | \"save_malformed\"",
|
|
32719
|
+
"signature": "readonly reason: \"upload_failed\" | \"download_failed\" | \"save_timeout\" | \"save_malformed\" | \"save_conflict\" | \"load_failed\"",
|
|
32656
32720
|
"documented": true,
|
|
32657
|
-
"summary": "Which part failed.\n\n- `upload_failed` — the bytes could not be written to storage.\n- `download_failed` — the stored bytes could not be fetched.\n- `save_timeout` — a hop did not finish in the time the call allows.\n- `save_malformed` — the answer, or the stored payload, was not what it\n claims to be.\n\n`save_timeout`, and a one-off `upload_failed` or `download_failed`, are\nworth one retry. `save_malformed` is not: the same bytes will fail the\nsame way.\n\nAn `upload_failed` that repeats on EVERY save, for every player, is not\ntransient either — it is a platform-side misconfiguration, and retrying\nis a loop rather than a recovery. See the saves guide.",
|
|
32721
|
+
"summary": "Which part failed.\n\n- `upload_failed` — the bytes could not be written to storage.\n- `download_failed` — the stored bytes could not be fetched.\n- `save_timeout` — a hop did not finish in the time the call allows.\n- `save_malformed` — the answer, or the stored payload, was not what it\n claims to be.\n- `save_conflict` — a write was refused because the stored save changed\n since this game last loaded or wrote it: another device or tab saved\n in between. Nothing was written.\n- `load_failed` — a write was refused before anything was sent, because\n the last load of this slot failed, so what the write would replace is\n unknown — it could be the player's real progress.\n\n`save_timeout`, and a one-off `upload_failed` or `download_failed`, are\nworth one retry. `save_malformed` is not: the same bytes will fail the\nsame way.\n\nNeither is `save_conflict` or `load_failed`: resending the same write\nfails the same way. Load the slot again and decide what to write from\nwhat is stored — merge, or ask the player. Pass `overwrite: true` only to\nreplace whatever is stored on purpose.\n\nAn `upload_failed` that repeats on EVERY save, for every player, is not\ntransient either — it is a platform-side misconfiguration, and retrying\nis a loop rather than a recovery. See the saves guide.\n\nA RATE LIMIT IS NOT ONE OF THESE. Refused for the rate on any leg — the\nplatform, the upload, the download — a save or load rejects as\n`GemApiError` reason `rate_limited`, with `retryAfterSeconds` when a wait\nwas named.",
|
|
32658
32722
|
"deprecated": null,
|
|
32659
32723
|
"params": [],
|
|
32660
32724
|
"returns": null,
|
|
@@ -32707,7 +32771,7 @@
|
|
|
32707
32771
|
"group": "player",
|
|
32708
32772
|
"signature": "readonly signal?: AbortSignal",
|
|
32709
32773
|
"documented": true,
|
|
32710
|
-
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.",
|
|
32774
|
+
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.\n\nA write that replaced earlier ones still waiting on its slot carries them,\nso cancelling it before it goes out cancels them too. Cancelling a write\nthat a later one replaced rejects only this call; the later write goes on.",
|
|
32711
32775
|
"deprecated": null,
|
|
32712
32776
|
"params": [],
|
|
32713
32777
|
"returns": null,
|
|
@@ -32723,7 +32787,39 @@
|
|
|
32723
32787
|
"group": "player",
|
|
32724
32788
|
"signature": "readonly timeoutMs?: number",
|
|
32725
32789
|
"documented": true,
|
|
32726
|
-
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from `GemTerminationNotice`, whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.",
|
|
32790
|
+
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from `GemTerminationNotice`, whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.\n\nIt counts from the call, so a write waiting for the one before it on the\nsame slot spends it waiting. A rate limit whose wait it will not cover\nrejects at once as `GemApiError` reason `rate_limited`, not as\n`save_timeout`.\n\nIt stays this call's when a later write replaces this one: running out\nrejects only this call, and the later write goes on. The write that is\nsent runs on the newest call's `timeoutMs`, and running out of that rejects\nevery call it carries.",
|
|
32791
|
+
"deprecated": null,
|
|
32792
|
+
"params": [],
|
|
32793
|
+
"returns": null,
|
|
32794
|
+
"throws": null,
|
|
32795
|
+
"example": null,
|
|
32796
|
+
"defaultValue": null,
|
|
32797
|
+
"guides": []
|
|
32798
|
+
},
|
|
32799
|
+
{
|
|
32800
|
+
"id": "PutSaveOptions.teardown",
|
|
32801
|
+
"name": "teardown",
|
|
32802
|
+
"anchor": "putsaveoptions-teardown",
|
|
32803
|
+
"group": "player",
|
|
32804
|
+
"signature": "readonly teardown?: boolean",
|
|
32805
|
+
"documented": true,
|
|
32806
|
+
"summary": "This is the last write before the page goes away: send it without waiting\nfor the spacing between writes to one slot.\n\nWrites to one slot go out a few seconds apart, and a write made sooner\nwaits its turn. Set this on the write you make from\n`GemTerminationNotice` or `pagehide`, with a `timeoutMs` inside the\ntime you have.\n\nIT STILL WAITS FOR A WRITE ALREADY IN FLIGHT to the same slot, and it\nstill honours a rate-limit hold: when the hold outlasts `timeoutMs` the\nwrite rejects at once as `rate_limited`, and nothing is sent. Not for\nordinary saves — the spacing is what keeps a save loop from spending the\nrate limit.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out skips the spacing when any call it carries set `teardown`.\nNot across a player change: a call replaced after the signed-in player\nchanged is refused as `save_conflict`, and its `teardown` goes with it.",
|
|
32807
|
+
"deprecated": null,
|
|
32808
|
+
"params": [],
|
|
32809
|
+
"returns": null,
|
|
32810
|
+
"throws": null,
|
|
32811
|
+
"example": null,
|
|
32812
|
+
"defaultValue": null,
|
|
32813
|
+
"guides": []
|
|
32814
|
+
},
|
|
32815
|
+
{
|
|
32816
|
+
"id": "PutSaveOptions.overwrite",
|
|
32817
|
+
"name": "overwrite",
|
|
32818
|
+
"anchor": "putsaveoptions-overwrite",
|
|
32819
|
+
"group": "player",
|
|
32820
|
+
"signature": "readonly overwrite?: boolean",
|
|
32821
|
+
"documented": true,
|
|
32822
|
+
"summary": "Write even if it replaces a save this game has not loaded.\n\nWithout it, a write to a slot whose last load FAILED is refused as\n`load_failed`, and a write is refused as `save_conflict` when the stored\nsave changed since you last loaded or wrote it. Both refusals protect the\nplayer's real progress from a game that started from defaults.\n\nSet it only when replacing whatever is stored is what you mean — a \"new\ngame\" the player confirmed, or the player choosing this device's progress\nafter a conflict. Never set it to get past a failed load.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out overwrites when any call it carries set `overwrite`. A call\nthat gives up first (its `signal` or `timeoutMs`) takes it back, and so\ndoes one replaced after the signed-in player changed: it is refused as\n`save_conflict`, so one player's \"new game\" never overwrites the next's.",
|
|
32727
32823
|
"deprecated": null,
|
|
32728
32824
|
"params": [],
|
|
32729
32825
|
"returns": null,
|
|
@@ -32776,7 +32872,7 @@
|
|
|
32776
32872
|
"group": "player",
|
|
32777
32873
|
"signature": "readonly signal?: AbortSignal",
|
|
32778
32874
|
"documented": true,
|
|
32779
|
-
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.",
|
|
32875
|
+
"summary": "Cancel the write.\n\nA save is several hops, so a cancelled write can land after the platform\nhas already agreed to it. Re-read rather than assuming nothing was stored.\n\nA write that replaced earlier ones still waiting on its slot carries them,\nso cancelling it before it goes out cancels them too. Cancelling a write\nthat a later one replaced rejects only this call; the later write goes on.",
|
|
32780
32876
|
"deprecated": null,
|
|
32781
32877
|
"params": [],
|
|
32782
32878
|
"returns": null,
|
|
@@ -32792,7 +32888,39 @@
|
|
|
32792
32888
|
"group": "player",
|
|
32793
32889
|
"signature": "readonly timeoutMs?: number",
|
|
32794
32890
|
"documented": true,
|
|
32795
|
-
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from `GemTerminationNotice`, whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.",
|
|
32891
|
+
"summary": "How long this whole save may take, every hop together. Overrides the 30\nsecond default.\n\nSET THIS WHEN YOU ARE RACING A CLOCK YOU DO NOT CONTROL — above all when\nsaving from `GemTerminationNotice`, whose `graceMs` can be shorter\nthan the default. Left at the default in that case a save is killed\nmid-flight: the promise never settles, nothing is logged, and the player\nloses progress silently. Set it to the budget you actually have and a save\nthat will not make it rejects as `save_timeout` while there is still time\nto do something about it.\n\nIt does not make a save faster. Three round trips plus the payload is what\na save costs; this only decides when to stop waiting.\n\nIt counts from the call, so a write waiting for the one before it on the\nsame slot spends it waiting. A rate limit whose wait it will not cover\nrejects at once as `GemApiError` reason `rate_limited`, not as\n`save_timeout`.\n\nIt stays this call's when a later write replaces this one: running out\nrejects only this call, and the later write goes on. The write that is\nsent runs on the newest call's `timeoutMs`, and running out of that rejects\nevery call it carries.",
|
|
32892
|
+
"deprecated": null,
|
|
32893
|
+
"params": [],
|
|
32894
|
+
"returns": null,
|
|
32895
|
+
"throws": null,
|
|
32896
|
+
"example": null,
|
|
32897
|
+
"defaultValue": null,
|
|
32898
|
+
"guides": []
|
|
32899
|
+
},
|
|
32900
|
+
{
|
|
32901
|
+
"id": "PutBytesOptions.teardown",
|
|
32902
|
+
"name": "teardown",
|
|
32903
|
+
"anchor": "putbytesoptions-teardown",
|
|
32904
|
+
"group": "player",
|
|
32905
|
+
"signature": "readonly teardown?: boolean",
|
|
32906
|
+
"documented": true,
|
|
32907
|
+
"summary": "This is the last write before the page goes away: send it without waiting\nfor the spacing between writes to one slot.\n\nWrites to one slot go out a few seconds apart, and a write made sooner\nwaits its turn. Set this on the write you make from\n`GemTerminationNotice` or `pagehide`, with a `timeoutMs` inside the\ntime you have.\n\nIT STILL WAITS FOR A WRITE ALREADY IN FLIGHT to the same slot, and it\nstill honours a rate-limit hold: when the hold outlasts `timeoutMs` the\nwrite rejects at once as `rate_limited`, and nothing is sent. Not for\nordinary saves — the spacing is what keeps a save loop from spending the\nrate limit.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out skips the spacing when any call it carries set `teardown`.\nNot across a player change: a call replaced after the signed-in player\nchanged is refused as `save_conflict`, and its `teardown` goes with it.",
|
|
32908
|
+
"deprecated": null,
|
|
32909
|
+
"params": [],
|
|
32910
|
+
"returns": null,
|
|
32911
|
+
"throws": null,
|
|
32912
|
+
"example": null,
|
|
32913
|
+
"defaultValue": null,
|
|
32914
|
+
"guides": []
|
|
32915
|
+
},
|
|
32916
|
+
{
|
|
32917
|
+
"id": "PutBytesOptions.overwrite",
|
|
32918
|
+
"name": "overwrite",
|
|
32919
|
+
"anchor": "putbytesoptions-overwrite",
|
|
32920
|
+
"group": "player",
|
|
32921
|
+
"signature": "readonly overwrite?: boolean",
|
|
32922
|
+
"documented": true,
|
|
32923
|
+
"summary": "Write even if it replaces a save this game has not loaded.\n\nWithout it, a write to a slot whose last load FAILED is refused as\n`load_failed`, and a write is refused as `save_conflict` when the stored\nsave changed since you last loaded or wrote it. Both refusals protect the\nplayer's real progress from a game that started from defaults.\n\nSet it only when replacing whatever is stored is what you mean — a \"new\ngame\" the player confirmed, or the player choosing this device's progress\nafter a conflict. Never set it to get past a failed load.\n\nA later write that replaces this one while it waits keeps it: the write\nthat goes out overwrites when any call it carries set `overwrite`. A call\nthat gives up first (its `signal` or `timeoutMs`) takes it back, and so\ndoes one replaced after the signed-in player changed: it is refused as\n`save_conflict`, so one player's \"new game\" never overwrites the next's.",
|
|
32796
32924
|
"deprecated": null,
|
|
32797
32925
|
"params": [],
|
|
32798
32926
|
"returns": null,
|
|
@@ -32824,7 +32952,7 @@
|
|
|
32824
32952
|
"group": "player",
|
|
32825
32953
|
"signature": "class SavesApi",
|
|
32826
32954
|
"documented": true,
|
|
32827
|
-
"summary": "A player's saved progress — write it, read it, list it, delete it.\n\nReached as `gem.me.saves`, and on a character as\n`gem.characters.get(id).saves`.\n\nA SAVE GOES IN A SLOT YOU DECLARED. Slots live in your game config, published\nwith `gemctl`, next to your room templates. Writing to a slot that was never\ndeclared fails as `not_found` naming the key, on the first call and every\nretry — that is the wall almost everyone hits first, and it is a config\nproblem rather than a code one.\n\n```js\nawait gem.me.saves.put('slot1', { level: 7, checkpoint: 'bridge' });\nconst state = await gem.me.saves.get('slot1');\n```\n\nWHAT IS DELIBERATELY NOT HERE: no autosave timer, no save format, no\nmigration. Those are your game's, and a platform that guessed at them would\nguess wrong.",
|
|
32955
|
+
"summary": "A player's saved progress — write it, read it, list it, delete it.\n\nReached as `gem.me.saves`, and on a character as\n`gem.characters.get(id).saves`.\n\nA SAVE GOES IN A SLOT YOU DECLARED. Slots live in your game config, published\nwith `gemctl`, next to your room templates. Writing to a slot that was never\ndeclared fails as `not_found` naming the key, on the first call and every\nretry — that is the wall almost everyone hits first, and it is a config\nproblem rather than a code one.\n\n```js\nawait gem.me.saves.put('slot1', { level: 7, checkpoint: 'bridge' });\nconst state = await gem.me.saves.get('slot1');\n```\n\nLOAD BEFORE YOU SAVE. Each write carries the version of the slot your last\nload or write found, so a write that would replace a save this game has not\nseen — another device's, or the player's real progress after a load that\nfailed — is refused as `save_conflict` or `load_failed` rather than stored.\nAfter a failed load, play on without saving and load again later; pass\n`overwrite: true` only when the player chose to start over.\n\nONE WRITE PER SLOT AT A TIME. A write made while another to the same slot is\nin flight waits for it, and a later one replaces it: the newest value is the\none sent, and every caller it replaced resolves with it, keeping its own\n`signal`, `timeoutMs`, `teardown` and `overwrite`. Writes to one slot\ngo out a few seconds apart (`teardown: true` skips that), and after a\n`rate_limited` answer none goes out until the wait it named is over.\n\nWHAT IS DELIBERATELY NOT HERE: no autosave timer, no save format, no\nmigration. Those are your game's, and a platform that guessed at them would\nguess wrong.",
|
|
32828
32956
|
"deprecated": null,
|
|
32829
32957
|
"params": [],
|
|
32830
32958
|
"returns": null,
|
package/docs/changelog.md
CHANGED
|
@@ -16,6 +16,24 @@ Its entries are grouped under up to five headings:
|
|
|
16
16
|
- **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
|
|
17
17
|
- **New capability**: something a game can now do. Nothing changes unless you use it.
|
|
18
18
|
|
|
19
|
+
## 16.2.0
|
|
20
|
+
|
|
21
|
+
Upgrading from 16.1.0. Impact: required.
|
|
22
|
+
|
|
23
|
+
### Must change
|
|
24
|
+
|
|
25
|
+
- The reason on a save error can now also be save_conflict or load_failed. Any code that switches over the reason exhaustively, or maps it to a message, will stop compiling or fall through, so handle both new values. save_conflict means the write was refused because the saved data changed underneath it, or because the player changed while the write was waiting. Reload the save and decide whether to retry, or pass overwrite when replacing the stored data is intended. load_failed means a save could not be loaded.
|
|
26
|
+
|
|
27
|
+
### Rebuild
|
|
28
|
+
|
|
29
|
+
- The save error constructor now accepts the two new reasons, and the saves API constructor takes an optional trailing identity function. Recompiling is enough; existing calls stay valid.
|
|
30
|
+
|
|
31
|
+
### New capability
|
|
32
|
+
|
|
33
|
+
- Save and byte writes accept an overwrite option. Set it when a write should replace whatever is stored without the conflict check; otherwise a write that collides with newer saved data is refused as save_conflict.
|
|
34
|
+
- Save and byte writes accept a teardown option. Set it on a final write, such as when the game is closing, so the write skips the spacing between writes and goes out right away.
|
|
35
|
+
- The saves options accept minWriteIntervalMs, which sets the minimum spacing between writes. Tune it if the default spacing does not suit how often your game saves.
|
|
36
|
+
|
|
19
37
|
## 16.1.0
|
|
20
38
|
|
|
21
39
|
Upgrading from 16.0.1. Impact: required.
|
package/docs/getting-started.md
CHANGED
package/docs/manifest.json
CHANGED
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"file": "changelog.md",
|
|
46
|
-
"sha256": "
|
|
47
|
-
"updated": "2026-10-
|
|
46
|
+
"sha256": "b2791239b695b7830c97ccb7ef1370c317b92070a9a3f17d8488d02d78965094",
|
|
47
|
+
"updated": "2026-10-29T20:57:56Z"
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"file": "daily-challenges.md",
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"file": "getting-started.md",
|
|
76
|
-
"sha256": "
|
|
77
|
-
"updated": "2026-10-
|
|
76
|
+
"sha256": "0699d1d9283ec251416a8068a6d1dfa050d795c58c55656cc5ce83f98e5bf132",
|
|
77
|
+
"updated": "2026-10-29T20:57:56Z"
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
"file": "inventory-and-catalogs.md",
|
|
@@ -128,13 +128,13 @@
|
|
|
128
128
|
},
|
|
129
129
|
{
|
|
130
130
|
"file": "saving-player-progress.md",
|
|
131
|
-
"sha256": "
|
|
132
|
-
"updated": "2026-10-
|
|
131
|
+
"sha256": "78210e1fec9590a0b8051c16d6adcf685391aa32175d8abe80614f840358c7ec",
|
|
132
|
+
"updated": "2026-10-29T17:51:52Z"
|
|
133
133
|
},
|
|
134
134
|
{
|
|
135
135
|
"file": "server-bundles.md",
|
|
136
|
-
"sha256": "
|
|
137
|
-
"updated": "2026-10-
|
|
136
|
+
"sha256": "3d628942136f6dbf2dc0dd25a825f4520fe9b54ffc88a62e2d9fed11039a5151",
|
|
137
|
+
"updated": "2026-10-29T20:57:56Z"
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
"file": "serverless-functions-js.md",
|
|
@@ -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, {
|
|
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.
|
|
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
|
|
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
|
package/docs/server-bundles.md
CHANGED
|
@@ -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.
|
|
84
|
-
curl -fsSL https://downloads.gemarcade.com/sdk/v16.
|
|
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
|
|