@indiegems/gem-web-sdk 16.1.0 → 16.3.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 +26 -0
- package/README.md +2 -1
- package/dist/function.d.ts +27 -3
- package/dist/game.d.ts +77 -4
- 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 +79 -4
- 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 +594 -5
- package/docs/api/api-reference-server.md +1 -1
- package/docs/api/reference.json +761 -17
- package/docs/arcade-and-store.md +6 -0
- package/docs/brand-art.md +14 -3
- package/docs/branding.md +160 -0
- package/docs/changelog.md +26 -0
- package/docs/getting-started.md +1 -1
- package/docs/manifest.json +17 -12
- package/docs/saving-player-progress.md +171 -5
- package/docs/server-bundles.md +2 -2
- package/package.json +1 -1
package/docs/arcade-and-store.md
CHANGED
|
@@ -194,6 +194,12 @@ talks to the Arcade: `gemctl serve` and the Project Editor too, when they are si
|
|
|
194
194
|
**Name each host exactly. Never allow `https://*.blob.core.windows.net`**: that wildcard is every
|
|
195
195
|
Azure customer's storage, not the Arcade's.
|
|
196
196
|
|
|
197
|
+
**The platform's branding** (the logo, the wordmark and the currency marks `gem.branding` hands
|
|
198
|
+
you) is served from `https://gemarcade.com`, in every environment. Allow it in `connect-src`, for
|
|
199
|
+
the branding manifest, and in each kind's own directive for the files you draw: `img-src` for
|
|
200
|
+
images, `media-src` for audio, `font-src` for fonts
|
|
201
|
+
([branding.md](branding.md#5-your-pages-content-security-policy)).
|
|
202
|
+
|
|
197
203
|
A non-production backend serves its art from hosts of its own. These hosts are infrastructure, and
|
|
198
204
|
they can change: art that will not load is the sign, and the browser's console names the host your
|
|
199
205
|
policy refused. Allow that host rather than a wildcard.
|
package/docs/brand-art.md
CHANGED
|
@@ -5,6 +5,13 @@ The SDK ships the platform's own art for its two currencies, **Gems** and **Coin
|
|
|
5
5
|
§1 before you pick a file: each currency has two kinds of art, and which one you use depends on what
|
|
6
6
|
the screen is for.
|
|
7
7
|
|
|
8
|
+
> **Draw the platform's current marks with `gem.branding`, and keep these files as the fallback.**
|
|
9
|
+
> `gem.branding.currencyMark('GEMS', { size: 20 })` and `gem.branding.trophy()` hand you the same
|
|
10
|
+
> renders, served by the platform, so a changed mark reaches your game with no SDK update and no
|
|
11
|
+
> republish ([branding.md](branding.md)). The files in this package are the marks as they were when
|
|
12
|
+
> this SDK version was released: draw them only when a lookup answers `unavailable`. Every size,
|
|
13
|
+
> role and rule on this page applies to both.
|
|
14
|
+
|
|
8
15
|
**Currency display belongs to the arcade.** The arcade draws the player's Gems and Coins totals in
|
|
9
16
|
its own chrome while your game runs, and it animates every payout of either into its total. Your game
|
|
10
17
|
draws Gems or Coins only in the three places §5 allows — an amount on offer or just paid, a reward
|
|
@@ -142,6 +149,9 @@ and the art softens as it is stretched. Below 64 px, use the icon (§2).
|
|
|
142
149
|
|
|
143
150
|
## 4. Loading and drawing the art
|
|
144
151
|
|
|
152
|
+
The platform serves every one of these files through `gem.branding` ([branding.md](branding.md)),
|
|
153
|
+
which is what to draw first. The copies below are the fallback for when it answers `unavailable`.
|
|
154
|
+
|
|
145
155
|
All of it is in the package you already install, under `brand/`, and published beside it on the CDN —
|
|
146
156
|
the same bytes either way, for the version you are on.
|
|
147
157
|
|
|
@@ -264,9 +274,10 @@ The CDN lets any origin read these files, so a canvas or a WebGL texture can use
|
|
|
264
274
|
path never changes once published, so a URL you ship keeps working, and `artifact-manifest.json` in
|
|
265
275
|
the same version folder lists each file's `sha384` hash under `brand`.
|
|
266
276
|
|
|
267
|
-
**Prefer
|
|
268
|
-
|
|
269
|
-
|
|
277
|
+
**Prefer `gem.branding` over a fixed URL here.** A URL in this folder is the art of the version you
|
|
278
|
+
pinned, forever; the platform's is the art as it is now. Either way, a load at play time can be slow
|
|
279
|
+
or fail, so lay the screen out so it reads correctly without the image, and keep a bundled copy for
|
|
280
|
+
`unavailable`.
|
|
270
281
|
|
|
271
282
|
### The platform currency's `icon_url`
|
|
272
283
|
|
package/docs/branding.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# The platform's branding: the logo, the wordmark and the platform marks
|
|
2
|
+
|
|
3
|
+
Show the Indie Gems logo on your main menu, the Gem Arcade wordmark on a credits screen, or the Gems
|
|
4
|
+
mark beside a price, with the files **the platform serves now**. `gem.branding` reads them from the
|
|
5
|
+
platform's branding manifest, so when a mark changes, your game draws the new one on its next
|
|
6
|
+
session. You do not update the SDK or republish.
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
const logo = await gem.branding.logo();
|
|
10
|
+
if (logo.outcome === 'ok') {
|
|
11
|
+
menuLogo.src = logo.url;
|
|
12
|
+
} else {
|
|
13
|
+
menuLogo.hidden = true; // the platform's branding could not be read: draw no logo
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- [1. What you can ask for](#1-what-you-can-ask-for)
|
|
18
|
+
- [2. What a lookup answers](#2-what-a-lookup-answers)
|
|
19
|
+
- [3. Picking a file](#3-picking-a-file)
|
|
20
|
+
- [4. When the manifest cannot be read: the bundled copy](#4-when-the-manifest-cannot-be-read-the-bundled-copy)
|
|
21
|
+
- [5. Your page's Content-Security-Policy](#5-your-pages-content-security-policy)
|
|
22
|
+
- [6. Using the logo and the wordmark](#6-using-the-logo-and-the-wordmark)
|
|
23
|
+
|
|
24
|
+
## 1. What you can ask for
|
|
25
|
+
|
|
26
|
+
| Call | Asset id | What it is |
|
|
27
|
+
| --------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------- |
|
|
28
|
+
| `gem.branding.logo()` | `gem-logo` | The Indie Gems logo, the brand gem. Scalable (SVG). |
|
|
29
|
+
| `gem.branding.wordmark()` | `gem-wordmark` | The Gem Arcade wordmark, 336 and 672 px wide. |
|
|
30
|
+
| `gem.branding.currencyMark('GEMS')` | `gems-mark` | The Gems mark: the 20 and 32 CSS px icons at 1, 1.5, 2 and 3x, and hero renders 128 to 768 px wide. |
|
|
31
|
+
| `gem.branding.currencyMark('ARCADE_TICKETS')` | `ticket-mark` | The Coins mark, in the same sizes. |
|
|
32
|
+
| `gem.branding.trophy()` | `trophy-mark` | The trophy: 20, 28, 56 and 72 CSS px renders and hero renders 128 to 768 px wide. |
|
|
33
|
+
|
|
34
|
+
**Any asset, by id and kind:** `gem.branding.get(id, { kind })`. The named calls are `get` with the
|
|
35
|
+
id filled in. When the platform adds an asset (another image, a sound, a font), you reach it with
|
|
36
|
+
`get` from the SDK you already have:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
// 'arcade-sting' is an example of an id the platform could add; today it answers not_found.
|
|
40
|
+
const sting = await gem.branding.get('arcade-sting', { kind: 'audio' });
|
|
41
|
+
if (sting.outcome === 'ok') new Audio(sting.url).play();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`gem.branding.list()` resolves every asset the platform serves today, each with its `id`, `kind`
|
|
45
|
+
and every file (`variants`). Compare `kind` rather than switching on every value: kinds other than
|
|
46
|
+
`image`, `audio` and `font` may appear.
|
|
47
|
+
|
|
48
|
+
The currency marks follow the rules in [brand-art.md](brand-art.md): **the icon** (`size: 20` or
|
|
49
|
+
`32`) beside an amount, **the hero** (no size, or a size of 64 CSS px or more) on a reward screen,
|
|
50
|
+
and never a balance of your own.
|
|
51
|
+
|
|
52
|
+
## 2. What a lookup answers
|
|
53
|
+
|
|
54
|
+
Every call resolves. None rejects, except for an argument that is not one: an empty id, a `size`
|
|
55
|
+
or `density` that is not a positive number, a `theme` other than `'light'` or `'dark'`, or a
|
|
56
|
+
currency other than `'GEMS'` or `'ARCADE_TICKETS'`.
|
|
57
|
+
|
|
58
|
+
| `outcome` | Means | Do |
|
|
59
|
+
| ------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
60
|
+
| `ok` | The platform has it. `url` is the file picked for you, `variant` describes it, and `asset` holds every file. | Draw `url`. |
|
|
61
|
+
| `not_found` | The manifest was read, and has no asset with that id and kind, or none in the theme you asked for. | Draw nothing for it. |
|
|
62
|
+
| `unavailable` | The manifest could not be read: the player is offline, the request failed, or your page's policy refused it (§5). | Draw your bundled copy (§4), or nothing. |
|
|
63
|
+
|
|
64
|
+
An id or a kind the SDK does not know is `not_found`, never an error, so a newer platform cannot
|
|
65
|
+
break an older game.
|
|
66
|
+
|
|
67
|
+
**Every URL is on the platform's own host**, absolute, and content-hashed: a changed mark is a new
|
|
68
|
+
URL. Use it as it is in an `<img src>`, a CSS `background-image` or a texture loader. Look it up
|
|
69
|
+
each session; do not store one in a save or your build.
|
|
70
|
+
|
|
71
|
+
The SDK reads the manifest once, on your first `gem.branding` call, and every later call shares
|
|
72
|
+
that read. A game that never calls it makes no request. If the read fails, calls answer
|
|
73
|
+
`unavailable` for a minute, then the next call tries again. Call `gem.branding.list()` during
|
|
74
|
+
start-up to have it ready before your menu draws.
|
|
75
|
+
|
|
76
|
+
## 3. Picking a file
|
|
77
|
+
|
|
78
|
+
Pass the box you draw in, in CSS px, as `size`. The SDK picks:
|
|
79
|
+
|
|
80
|
+
1. the file **made for that box**, at the nearest density at or above the screen's
|
|
81
|
+
`devicePixelRatio` (else the densest there is);
|
|
82
|
+
2. else a **scalable** file (the logo's SVG);
|
|
83
|
+
3. else the **narrowest** file at least `size × density` pixels wide, else the widest.
|
|
84
|
+
|
|
85
|
+
With no `size`, you get the scalable file, or else the widest. Pass `density` to override the
|
|
86
|
+
screen's ratio, for example `density: 3` for a canvas or a texture you scale down yourself. Pass
|
|
87
|
+
`theme: 'light'` or `'dark'` to prefer a file drawn for that theme; files for the other theme are
|
|
88
|
+
never picked, and a file rendered for your exact `size` still beats one drawn for your theme at
|
|
89
|
+
another size. Every file today serves both.
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// A 20 CSS px Gems icon in an <img>, with every density for the browser to choose from.
|
|
93
|
+
const gems = await gem.branding.currencyMark('GEMS', { size: 20 });
|
|
94
|
+
if (gems.outcome === 'ok') {
|
|
95
|
+
const files = gems.asset.variants.filter((v) => v.size === 20);
|
|
96
|
+
icon.src = gems.url;
|
|
97
|
+
icon.srcset = files.map((v) => `${v.url} ${v.width}w`).join(', ');
|
|
98
|
+
icon.sizes = '20px';
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
// The wordmark on a canvas or a WebGL texture: the platform's host allows any origin to read it.
|
|
104
|
+
const wordmark = await gem.branding.wordmark({ size: 168, density: 3 });
|
|
105
|
+
if (wordmark.outcome === 'ok') {
|
|
106
|
+
const img = new Image();
|
|
107
|
+
img.crossOrigin = 'anonymous'; // before src, or the canvas is tainted
|
|
108
|
+
img.src = wordmark.url;
|
|
109
|
+
await img.decode();
|
|
110
|
+
ctx.drawImage(img, x, y, 168, 88);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## 4. When the manifest cannot be read: the bundled copy
|
|
115
|
+
|
|
116
|
+
The SDK package still ships the Gems, Coins and trophy marks under `brand/`
|
|
117
|
+
([brand-art.md](brand-art.md) §4). **They are the fallback for `unavailable`**, not the art to draw
|
|
118
|
+
first: a file bundled in your build is the mark as it was when you built, and the platform's is the
|
|
119
|
+
mark as it is now.
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
import gemsIcon from '@indiegems/gem-web-sdk/brand/gem-icon-20-2x.png';
|
|
123
|
+
|
|
124
|
+
const gems = await gem.branding.currencyMark('GEMS', { size: 20 });
|
|
125
|
+
if (gems.outcome === 'ok') icon.src = gems.url;
|
|
126
|
+
else if (gems.outcome === 'unavailable') icon.src = gemsIcon; // the platform could not be reached
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
There is no bundled logo or wordmark. When `logo()` or `wordmark()` is `unavailable`, lay the screen
|
|
130
|
+
out so it reads correctly without it.
|
|
131
|
+
|
|
132
|
+
## 5. Your page's Content-Security-Policy
|
|
133
|
+
|
|
134
|
+
The platform does not restrict what your game fetches. If your own page sets a
|
|
135
|
+
Content-Security-Policy (a `<meta>` tag in your `index.html`), allow `https://gemarcade.com`,
|
|
136
|
+
where the manifest and every file it lists are served:
|
|
137
|
+
|
|
138
|
+
- in **`connect-src`**, for the manifest. Without it every lookup is `unavailable`, and the SDK says
|
|
139
|
+
so once on the console;
|
|
140
|
+
- in each kind's own directive for the files you draw: **`img-src`** for images, **`media-src`**
|
|
141
|
+
for audio, **`font-src`** for fonts.
|
|
142
|
+
|
|
143
|
+
[arcade-and-store.md](arcade-and-store.md#the-media-hosts) lists the hosts the Arcade's card art comes
|
|
144
|
+
from, beside this one.
|
|
145
|
+
|
|
146
|
+
## 6. Using the logo and the wordmark
|
|
147
|
+
|
|
148
|
+
- **Draw them as served:** never recoloured, stretched, rotated, cropped, outlined or given effects,
|
|
149
|
+
and never rebuilt from another font or file.
|
|
150
|
+
- **Keep clear space** around each, at least a quarter of its height, free of text, buttons and
|
|
151
|
+
other art.
|
|
152
|
+
- **Not smaller than the Arcade draws them:** the wordmark 60 CSS px wide, the gem logo 32 CSS px.
|
|
153
|
+
Below that the gem's facets blur together.
|
|
154
|
+
- **On a busy background,** put them on a plain panel rather than over art.
|
|
155
|
+
- **The wordmark is the product's name written as a logo.** Never set "Gem Arcade" in a text face
|
|
156
|
+
beside the gem in its place.
|
|
157
|
+
- **The currency marks stand for the platform's currencies only:** the Gems mark beside a Gems
|
|
158
|
+
amount, never for your own currency ([brand-art.md](brand-art.md) §5).
|
|
159
|
+
- **Never inside a panel dressed as the Arcade's own screens.** A player must always be able to tell
|
|
160
|
+
your game from the platform.
|
package/docs/changelog.md
CHANGED
|
@@ -16,6 +16,32 @@ 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.3.0
|
|
20
|
+
|
|
21
|
+
Upgrading from 16.2.0. Impact: none.
|
|
22
|
+
|
|
23
|
+
### New capability
|
|
24
|
+
|
|
25
|
+
- Games can now read the platform's branding manifest at run time through Gem.branding. A game can show the platform logo, the Arcade wordmark, the currency marks and the trophy, and a branding change on the platform reaches the game with no SDK update or republish. The manifest is read once on the first call and all callers share that read. A lookup by id and kind resolves to one of three outcomes: ok, with the URL, the chosen variant and the whole asset; not_found, when the manifest does not list that id or kind; or unavailable, when the manifest could not be read. In the unavailable case, draw your bundled brand copy instead. Only a bad argument rejects. A list call resolves every asset. You can pass the size you will draw and an optional theme, and the SDK picks the best-matching variant. Nothing is required of existing games.
|
|
26
|
+
|
|
27
|
+
## 16.2.0
|
|
28
|
+
|
|
29
|
+
Upgrading from 16.1.0. Impact: required.
|
|
30
|
+
|
|
31
|
+
### Must change
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
35
|
+
### Rebuild
|
|
36
|
+
|
|
37
|
+
- 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.
|
|
38
|
+
|
|
39
|
+
### New capability
|
|
40
|
+
|
|
41
|
+
- 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.
|
|
42
|
+
- 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.
|
|
43
|
+
- 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.
|
|
44
|
+
|
|
19
45
|
## 16.1.0
|
|
20
46
|
|
|
21
47
|
Upgrading from 16.0.1. Impact: required.
|
package/docs/getting-started.md
CHANGED
package/docs/manifest.json
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"file": "arcade-and-store.md",
|
|
21
|
-
"sha256": "
|
|
22
|
-
"updated": "2026-10-
|
|
21
|
+
"sha256": "d792225c078f9488e3c67f2fe4af1128f7f742ee829173dcd014b03e11eb32f0",
|
|
22
|
+
"updated": "2026-10-29T20:35:07Z"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"file": "arcade-avatars.md",
|
|
@@ -38,13 +38,18 @@
|
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
"file": "brand-art.md",
|
|
41
|
-
"sha256": "
|
|
42
|
-
"updated": "2026-10-
|
|
41
|
+
"sha256": "99ef1d5703bca12128b38db8352a102e11444de82903a73973942f074da4fc74",
|
|
42
|
+
"updated": "2026-10-29T20:35:07Z"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"file": "branding.md",
|
|
46
|
+
"sha256": "f22bd82245e6c4a74025884bfda54e35405e990661c84e5857d5e424e182e61d",
|
|
47
|
+
"updated": "2026-10-29T20:56:53Z"
|
|
43
48
|
},
|
|
44
49
|
{
|
|
45
50
|
"file": "changelog.md",
|
|
46
|
-
"sha256": "
|
|
47
|
-
"updated": "2026-10-
|
|
51
|
+
"sha256": "600e6a0c79c48d4fa82e0993eac822df6629ab737ec6053eafab19c743ea19b8",
|
|
52
|
+
"updated": "2026-10-29T21:40:20Z"
|
|
48
53
|
},
|
|
49
54
|
{
|
|
50
55
|
"file": "daily-challenges.md",
|
|
@@ -73,8 +78,8 @@
|
|
|
73
78
|
},
|
|
74
79
|
{
|
|
75
80
|
"file": "getting-started.md",
|
|
76
|
-
"sha256": "
|
|
77
|
-
"updated": "2026-10-
|
|
81
|
+
"sha256": "50f47be4de5a38a04e49cf1e864f66f8eab4739d8658d6984bb85c61400a0893",
|
|
82
|
+
"updated": "2026-10-29T21:40:20Z"
|
|
78
83
|
},
|
|
79
84
|
{
|
|
80
85
|
"file": "inventory-and-catalogs.md",
|
|
@@ -128,13 +133,13 @@
|
|
|
128
133
|
},
|
|
129
134
|
{
|
|
130
135
|
"file": "saving-player-progress.md",
|
|
131
|
-
"sha256": "
|
|
132
|
-
"updated": "2026-10-
|
|
136
|
+
"sha256": "78210e1fec9590a0b8051c16d6adcf685391aa32175d8abe80614f840358c7ec",
|
|
137
|
+
"updated": "2026-10-29T17:51:52Z"
|
|
133
138
|
},
|
|
134
139
|
{
|
|
135
140
|
"file": "server-bundles.md",
|
|
136
|
-
"sha256": "
|
|
137
|
-
"updated": "2026-10-
|
|
141
|
+
"sha256": "721aeb9ac64b9564477a5dffb3b5cc70d6d0c1b222d79ab7090413a0f9b4ef5a",
|
|
142
|
+
"updated": "2026-10-29T21:40:20Z"
|
|
138
143
|
},
|
|
139
144
|
{
|
|
140
145
|
"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.3.0/gem-web-sdk.tgz
|
|
84
|
+
curl -fsSL https://downloads.gemarcade.com/sdk/v16.3.0/gem-web-sdk.tgz.sri
|
|
85
85
|
openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
|
|
86
86
|
```
|
|
87
87
|
|