@indiegems/gem-web-sdk 21.0.0 → 21.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "21.0.0"
53
+ "@indiegems/gem-web-sdk": "21.1.1"
54
54
  }
55
55
  ```
56
56
 
@@ -294,21 +294,33 @@ parameters the link should open your game with:
294
294
  shareButton.hidden = !gem.share.available;
295
295
  shareButton.addEventListener('click', () => {
296
296
  if (!gem.share.available) return;
297
- gem.share.request({ params: { jar: 'JAR-K7P3QXM', n: '2', beat: '812' } });
297
+ gem.share.request({ params: { jar: 'JAR-K7P3QXM', n: '2', beat: '812' } }).catch((err) => {
298
+ // A ShareRefusal: the arcade refused this link. err.reason and err.key say
299
+ // what to fix. Log it for yourself; show the player nothing.
300
+ console.warn(err);
301
+ });
298
302
  });
299
303
  ```
300
304
 
301
305
  The arcade builds the link, which unfurls with your game's own card, and shares it through the
302
- player's share sheet, a copy, or its own Share panel. Your game is told nothing back — not
303
- whether the player shared, cancelled, or closed the panel — and `request` returns nothing.
304
- Where the arcade handles it, `gem.share.available` is true; outside the arcade, before the
305
- bridge is up, and anywhere the arcade does not share your game, it is false.
306
+ player's share sheet, a copy, or its own Share panel. Nothing about the player comes back — not
307
+ whether they shared, cancelled, or closed the panel. Where the arcade handles it,
308
+ `gem.share.available` is true; outside the arcade, before the bridge is up, and anywhere the
309
+ arcade does not share your game, it is false.
306
310
 
307
311
  `request` checks every parameter against the rules below **before** anything is sent, and
308
312
  throws on the first one the arcade would refuse, naming it: more than 16, a key that breaks the
309
- key rule or is one of the arcade's own, or a value that is not a string, is too long, or holds
310
- a character a link cannot carry. A mistake shows up at your desk, not as a Share button that
311
- does nothing for players — try it in the SDK's `examples/dev-harness/` with **share** ticked.
313
+ key rule or is one the arcade will not carry, or a value that is not a string, is too long, or
314
+ holds a character a link cannot carry. A mistake shows up at your desk, not as a Share button
315
+ that does nothing for players — try it in the SDK's `examples/dev-harness/` with **share**
316
+ ticked.
317
+
318
+ `request` also returns a promise. It **rejects with a `ShareRefusal`** when the arcade refuses
319
+ the link anyway, which only it can judge — the whole link over 2 KB, say — with `err.reason`
320
+ (`reserved_key`, `round_trip`, `too_long`, or a code newer than your SDK) and `err.key`, the
321
+ parameter that caused it when one did. Otherwise it resolves, and resolving says only that the
322
+ arcade took the request, never that a player shared. An older arcade does not answer, so there
323
+ it resolves at once. Catch it: an uncaught refusal is an unhandled rejection in your console.
312
324
 
313
325
  **Where `available` is false, build the link yourself.** Use the `/games/<id>` page with
314
326
  `play=1`, which opens straight into your game and unfurls with its card, and hand it to the
@@ -343,32 +355,43 @@ const jarIndex = Number.isInteger(n) && n >= 1 && n <= JAR_COUNT ? n : null;
343
355
 
344
356
  That is the whole contract for your own keys. The bridge carries no launch message, and the SDK
345
357
  has no API for them: the arcade does the forwarding, so a build you published on any SDK version
346
- gets it without a code change. Two keys are not yours, and they are the platform's (below):
347
- `gemChallenge`, which belongs to [friend challenges](friends.md#friend-challenges), and
358
+ gets it without a code change. Three keys are not yours, and they are the platform's (below):
359
+ `gemChallenge`, which belongs to [friend challenges](friends.md#friend-challenges),
348
360
  `gemPlayWith`, an opaque ticket the arcade adds when it opens your game to play with a friend or a
349
- party mate, which names nobody your game can recognise. Never define either, and never parse
350
- either yourself. The SDK reads them for you —
351
- `gem.friends.launchedChallenge()` answers the challenge `gemChallenge` names, or `null`, and every
361
+ party mate, which names nobody your game can recognise, and `gemGraphics`, which a developer adds
362
+ to force [the graphics route](graphics-route.md#testing-the-gemgraphics-launch-key) while
363
+ testing. Never define any of them, and never parse them yourself. The SDK reads them for you —
364
+ `gem.friends.launchedChallenge()` answers the challenge `gemChallenge` names, or `null`, every
352
365
  `gem.rooms.quickJoin()` passes `gemPlayWith` on
353
- ([Landing in a friend's room](rooms-and-matches.md#5e-landing-in-a-friends-room-play-with)) — and
354
- they are the only launch keys it reads. Each is read once, when `createGem` runs, so cleaning your
355
- URL afterwards does not lose it. Call `createGem` before you clean the URL, or leave both keys in
366
+ ([Landing in a friend's room](rooms-and-matches.md#5e-landing-in-a-friends-room-play-with)), and
367
+ `gem.graphics.preferredRoute()` answers what `gemGraphics` forces — and they are the only launch
368
+ keys it reads. Each is read once, when `createGem` runs, so cleaning your
369
+ URL afterwards does not lose it. Call `createGem` before you clean the URL, or leave these keys in
356
370
  place: a key removed before `createGem` runs is never seen, so `launchedChallenge()` resolves
357
371
  `null` with no error and a quick-join plays with nobody.
358
372
 
359
- What reaches the frame:
373
+ What reaches the frame. These rules are gem-api's published launch-query rules
374
+ (`launch-query-rules.json`), the one source the arcade's website and the SDK both read; this page
375
+ and `gem.share.request` are checked against that file, so the keys and limits below are the ones
376
+ the arcade applies:
360
377
 
361
378
  - **The query only.** A fragment (`#…`) is never forwarded.
362
379
  - **Not the arcade's own keys.** These are stripped and never reach the game, and they are the
363
380
  only keys the arcade keeps for itself; it never interprets yours:
364
381
  - Store and play-page switches: `play`, `channel`, `purchase`, and `build`, which pins one
365
382
  of your own builds when you play it from your Builds page.
366
- - Platform switches: `devtools`, `devLocale`, and `gem_test_ads`, the mode of the arcade's
367
- rewarded TEST ads.
368
- - The arcade's own sign-in return marker: `social`.
369
- - Attribution and campaign keys, which the arcade keeps out of every game: `_s`, `utm_*`
370
- (every key with that prefix, matched case-sensitively, so UTM_X and utmost are yours),
371
- `fbclid`, `ttclid`, `gclid`, `msclkid`.
383
+ - Platform switches: `devtools`, `devLocale`, `gem_test_ads`, the mode of the arcade's
384
+ rewarded TEST ads, and `moments` and `ads`, the dev host's test moment and ad modes.
385
+ - The arcade's own sign-up and sign-in keys: `invite`, a friend's invite code, and `social`,
386
+ the sign-in return marker.
387
+ - Attribution and campaign keys: `_s`, `utm_*` (every key with that prefix, matched
388
+ case-sensitively, so UTM_X and utmost are yours), `fbclid`, `ttclid`, `gclid`, `gbraid`,
389
+ `wbraid`, `msclkid`. The arcade strips the visitor's own campaign and click ids, but a link
390
+ a game shares, or one anyone writes by hand, can still put `utm_*` and the click ids into
391
+ your frame, restored from their escaped form.
392
+
393
+ A link your game shares can still carry most of them: see
394
+ [Sharing a reserved key](#sharing-a-reserved-key) below.
372
395
  - `embedder`, which stays what
373
396
  [the embedder allowlist](what-a-game-must-do.md#1-the-embedder-allowlist) says: never read
374
397
  from the URL — a link cannot even try.
@@ -376,16 +399,40 @@ What reaches the frame:
376
399
  A key starting with `_` can never be one of yours: the key rule below requires a leading
377
400
  letter, so the arcade keeps that space for itself.
378
401
 
379
- - **Two platform keys.** `gemChallenge` and `gemPlayWith` do reach the frame — the arcade adds the
380
- first when a player opens a friend challenge, and the second when it opens your game to play
381
- with someone — but they are the platform's, not yours: read the challenge with
382
- `gem.friends.launchedChallenge()`, let `gem.rooms.quickJoin()` carry `gemPlayWith`, and never
383
- read either from the URL. Do not define a key of either name, and treat every key beginning
384
- with `gem` as reserved: a friend-challenge template refuses a param named that way.
402
+ - **Three platform keys.** `gemChallenge`, `gemPlayWith` and `gemGraphics` do reach the frame —
403
+ the arcade adds the first when a player opens a friend challenge, and the second when it opens
404
+ your game to play with someone, and you add the third yourself to test the graphics route — but
405
+ they are the platform's, not yours: read the challenge with `gem.friends.launchedChallenge()`,
406
+ let `gem.rooms.quickJoin()` carry `gemPlayWith`, ask `gem.graphics.preferredRoute()` rather than
407
+ reading `gemGraphics`, and never read any of them from the URL. Do not define a key of any of
408
+ these names, and treat every key beginning with `gem` as reserved: a friend-challenge template
409
+ refuses a param named that way.
385
410
  - **Bounded.** At most 16 parameters. A key is a letter followed by up to 63 letters, digits,
386
411
  `_` or `-`. A value is at most 512 characters with no control or format characters. An entry
387
412
  that breaks a rule is dropped on its own; the rest of the link still arrives.
388
413
 
414
+ #### Sharing a reserved key
415
+
416
+ A current arcade carries the arcade's own keys in a link your game shares, so you never need to
417
+ rename a parameter for it. It writes each one into the link under an escaped name it never reads
418
+ itself, and puts the original name back before your game reads its URL: share
419
+ `{ island: '7', invite: 'K2' }` and the frame that link opens reads `island=7&invite=K2`. The
420
+ arcade's own uses of those keys are unaffected — an invite it put in a link itself still signs a
421
+ friend up, and still never reaches your game.
422
+
423
+ A restored click id or `utm_` value is whatever the link says. Anyone who edits a share link can
424
+ forge one, and third-party tags in your frame (analytics, ads) will read it as attribution. Never
425
+ trust it as attribution.
426
+
427
+ Two keys it never carries: `embedder`, and `_s`, which breaks the key rule anyway. A key it has
428
+ to escape must also stay a valid key once escaped, so keep a reserved one (a `utm_` key, say) to
429
+ 57 characters. `gem.share.request` throws on each of these before it sends.
430
+
431
+ An older arcade, or a development host that has not caught up, refuses the whole share on any
432
+ reserved key and does not say why. So where the arcade has not said it carries them,
433
+ `gem.share.request` throws on every reserved key before it sends, naming the key — rename the
434
+ parameter there, or wait for the arcade.
435
+
389
436
  Four things to hold onto when you share:
390
437
 
391
438
  **Call `request` synchronously from your own Share button's click handler.** Never from a
@@ -393,7 +440,8 @@ timer, after an `await`, or on a game event such as a win: the arcade only share
393
440
  player's own click, and anywhere else it opens its Share panel instead, if it does anything.
394
441
 
395
442
  **Never gate progress or a reward on sharing.** You cannot tell whether a player shared — the
396
- arcade does not say, by design — so a reward for sharing is a reward for pressing a button.
443
+ arcade does not say, by design, and a resolved `request` does not mean it — so a reward for
444
+ sharing is a reward for pressing a button.
397
445
 
398
446
  **Put no personal identifiers in the parameters.** No player id, name, email or anything that
399
447
  leads back to a person: the link is public, and it is handed to whoever the player sends it to.
@@ -461,6 +509,53 @@ disagree. Running it twice is safe — it replaces what it finds.
461
509
  **Your bundler does not matter.** It is a command rather than a plugin, so anything that ends
462
510
  with a directory you upload can run it, including a build you assembled by hand.
463
511
 
512
+ ### Keep dev-only code on the dev channel
513
+
514
+ A cheat panel, a level skipper or a debug menu is worth having in the builds you test, and must
515
+ never reach a player. **Mark it, and the platform keeps it off the test and release channels for
516
+ you.** Import the SDK's dev-tools mark from your dev-only code, and from nowhere else:
517
+
518
+ ```ts
519
+ // src/dev/index.ts — everything dev-only lives under src/dev/
520
+ import '@indiegems/gem-web-sdk/dev-tools';
521
+
522
+ export function mountDevPanel() {
523
+ /* ... */
524
+ }
525
+ ```
526
+
527
+ A build that carries that code carries the mark. It uploads and plays on `dev` as usual, and a
528
+ promote to `test` or `release` is refused, naming the build.
529
+
530
+ Load the dev code behind one switch your bundler sets at build time, so a release build leaves
531
+ it out entirely, mark included:
532
+
533
+ ```ts
534
+ // src/main.ts
535
+ declare const __DEV_TOOLS__: boolean;
536
+ if (__DEV_TOOLS__) void import('./dev').then((m) => m.mountDevPanel());
537
+ ```
538
+
539
+ ```ts
540
+ // vite.config.ts
541
+ export default defineConfig(({ mode }) => ({
542
+ define: { __DEV_TOOLS__: JSON.stringify(mode !== 'release') },
543
+ }));
544
+ ```
545
+
546
+ ```jsonc
547
+ // package.json
548
+ "scripts": {
549
+ "build": "vite build && gem-sdk-stamp dist",
550
+ "build:release": "vite build --mode release && gem-sdk-stamp dist"
551
+ }
552
+ ```
553
+
554
+ `build` is the one you play and put on `dev`; `build:release` is the one you upload for `test`
555
+ and `release`. **Use a build-time switch, never one read at run time** (a URL parameter, a key
556
+ combination, the channel the game finds itself on): those leave the code in the files every
557
+ player downloads, for anyone to read and switch on.
558
+
464
559
  ### Then upload and promote
465
560
 
466
561
  ```bash
@@ -0,0 +1,188 @@
1
+ # WebGL or WebGPU: ask before you make your renderer
2
+
3
+ Some phones crash under WebGL. On the Pixel 10 (its PowerVR D-Series GPU), the phone's OpenGL ES
4
+ graphics driver fails under WebGL. In Chrome a three.js game there shows a white board while its
5
+ HTML still draws. In the Gem Arcade Android app it is worse: the app's WebView draws on the same
6
+ driver, inside the app's own process, so the crash closes the whole app. WebGPU on the same phone
7
+ runs on a different driver and draws cleanly, in Chrome and in the app.
8
+
9
+ So the SDK tells your game which renderer to make. Ask it once, just before you make your first
10
+ renderer, and follow the answer:
11
+
12
+ ```js
13
+ const { route, listed } = await gem.graphics.preferredRoute();
14
+ // route: 'webgpu' or 'webgl'. listed: whether this device is listed as broken on WebGL.
15
+ ```
16
+
17
+ **WebGL is the answer almost everywhere.** WebGPU generally performs worse out of the box, so
18
+ `route` is `webgpu` only on a device the platform lists as broken on WebGL, and only when WebGPU is
19
+ available there. Do not detect devices yourself: the list is the platform's, and it changes without
20
+ your game shipping a build.
21
+
22
+ **Every game that renders with WebGL asks, before its first draw.** In the app the first crash
23
+ closes the app, so a reload after a lost context cannot help there: only a choice made before
24
+ anything draws does. A game that does not use WebGL at all has nothing to ask.
25
+
26
+ ## The three answers
27
+
28
+ | `route` | `listed` | What your game draws |
29
+ | -------- | -------- | -------------------------------------------------------------------------- |
30
+ | `webgpu` | `true` | Your WebGPU renderer. If it fails to start, your **safe WebGL recipe**. |
31
+ | `webgl` | `true` | Your **safe WebGL recipe**: a listed device with no WebGPU to move to. |
32
+ | `webgl` | `false` | Your normal WebGL renderer. This is almost every player. |
33
+
34
+ **The safe WebGL recipe** is your game drawn without what crashes a listed device's driver. For
35
+ every listed device today (the PowerVR D-Series) that is **no shadow maps**. The crash follows
36
+ shader makeup, so test your safe recipe on the device if you can, and turn off whatever else your
37
+ testing shows. On a listed device, never fall back to three.js's own automatic WebGL 2 backend (the
38
+ check below): it runs on the same broken driver.
39
+
40
+ **Follow the answer for every canvas on the page**, menus, HUDs and 2D-looking WebGL plates
41
+ included. One GPU process serves every canvas (in the app, the app's own process), so when one
42
+ WebGL canvas crashes the driver, every context on the page goes with it. A game whose 3D board
43
+ draws on WebGPU but whose menu plates stay on WebGL keeps those plates on the safe recipe. Ask once
44
+ and keep the answer: it is the same for the whole page.
45
+
46
+ ## What `preferredRoute()` promises
47
+
48
+ - **It is quick, and it never blocks your first frame.** It is a bounded async call: it resolves
49
+ within `timeoutMs` (default 1000 ms, at most 5000), and normally at once after
50
+ `await gem.awaitBridge()`. When the time runs out it answers from what it knows by then.
51
+ - **It never rejects.** Anything that goes wrong inside it answers `webgl`, not listed: the
52
+ default.
53
+ - **`webgpu` means WebGPU is there.** The SDK checks that the browser hands out a WebGPU adapter
54
+ before it answers `webgpu`. Your game still checks that its WebGPU renderer really started (the
55
+ pattern below).
56
+
57
+ ```js
58
+ const { route, listed } = await gem.graphics.preferredRoute({ timeoutMs: 500 });
59
+ ```
60
+
61
+ ## three.js
62
+
63
+ Make `WebGLRenderer` by default, and `WebGPURenderer` only when the answer is `webgpu`. Load the
64
+ WebGPU build with a dynamic `import()`, so it is a separate chunk that only those players download:
65
+
66
+ ```js
67
+ import * as THREE from 'three';
68
+
69
+ // `gem` is what createGem() returned. Call createGem before you make any renderer.
70
+ await gem.awaitBridge();
71
+ const { route, listed } = await gem.graphics.preferredRoute();
72
+
73
+ async function createRenderer() {
74
+ if (route === 'webgpu') {
75
+ try {
76
+ const { WebGPURenderer } = await import('three/webgpu');
77
+ const renderer = new WebGPURenderer({ antialias: true });
78
+ await renderer.init();
79
+ // WebGPURenderer quietly falls back to its own WebGL 2 backend when it cannot
80
+ // get a WebGPU device. On a listed device that backend is the broken driver,
81
+ // and everywhere it is slower than WebGLRenderer: never keep it.
82
+ if (renderer.backend.isWebGPUBackend) return { renderer, safe: false };
83
+ renderer.dispose();
84
+ } catch (error) {
85
+ console.warn('WebGPU renderer failed; drawing the safe WebGL recipe', error);
86
+ }
87
+ }
88
+ return { renderer: new THREE.WebGLRenderer({ antialias: true }), safe: listed };
89
+ }
90
+
91
+ const { renderer, safe } = await createRenderer();
92
+ // The safe WebGL recipe on a listed device: for the PowerVR D-Series, no shadow maps.
93
+ renderer.shadowMap.enabled = !safe;
94
+ document.body.appendChild(renderer.domElement);
95
+ renderer.setAnimationLoop(() => renderer.render(scene, camera));
96
+ ```
97
+
98
+ Every other `WebGLRenderer` the page makes (a menu, a preview, a second view) follows `listed` the
99
+ same way: on a listed device it draws the safe recipe.
100
+
101
+ Things to know about that pattern:
102
+
103
+ - **Keep the backend check.** After `await renderer.init()`, `renderer.backend.isWebGPUBackend`
104
+ says whether WebGPU really started. Without it, a WebGPU failure on a listed device lands on
105
+ three.js's WebGL 2 backend, on the very driver the answer was steering you away from.
106
+ - **Let each renderer make its own canvas** (`renderer.domElement`). A canvas that has handed out a
107
+ WebGPU context cannot hand out a WebGL one, so the fallback needs a fresh canvas.
108
+ - **Use three.js r171 or later**, where `three` and `three/webgpu` share one core, so the same scene,
109
+ meshes and built-in materials draw with either renderer. Pin three.js to an exact version: what
110
+ `WebGPURenderer` draws differently from `WebGLRenderer` moves between releases.
111
+
112
+ Other engines take the same shape: ask, try WebGPU when `route` is `webgpu`, check it really
113
+ started, and on a listed device make your WebGL renderer with the safe recipe.
114
+
115
+ ## What supporting both costs
116
+
117
+ **"Both" means `WebGLRenderer` by default, plus `WebGPURenderer` on real WebGPU for listed
118
+ devices.** It never means `WebGPURenderer` everywhere: on its WebGL 2 backend it is about 40%
119
+ slower than `WebGLRenderer` ([as three.js measured it](https://github.com/mrdoob/three.js/issues/34585)).
120
+ On real WebGPU it keeps up: one of our games drew its board in 4.05 ms against 4.81 ms on desktop,
121
+ and at 61 fps with shadows on the Pixel 10.
122
+
123
+ - **Custom shaders need a TSL port.** `WebGPURenderer` runs no GLSL and no `onBeforeCompile`. A
124
+ `ShaderMaterial`, `RawShaderMaterial`, an `onBeforeCompile` patch or an `EffectComposer` pass is
125
+ WebGL-only; under `WebGPURenderer` it needs a TSL (node material) equivalent.
126
+ - **The look can differ.** Colour blending, clipping, clear coat and ambient light have each needed
127
+ a fix in one game to look the same on both renderers. Compare the two side by side (testing,
128
+ below).
129
+ - **The WebGPU build is big, so load it only when chosen.** One game's WebGPU chunk is 662 KB
130
+ (188 KB gzipped). With `await import('three/webgpu')` behind `route === 'webgpu'`, as above,
131
+ players on WebGL never download it.
132
+
133
+ ## Lost WebGPU devices are counted for you
134
+
135
+ The SDK counts every lost WebGL context, per device, across every game (the
136
+ `gem_graphics_context_lost` event in [analytics](analytics.md)). It counts a lost WebGPU device
137
+ the same way, with `route` set to `webgpu`, whether or not your game recovers. A device your game
138
+ destroys itself is not counted. Your game still handles a loss itself: three.js reports it through
139
+ `renderer.onDeviceLost`.
140
+
141
+ ## Where the list comes from
142
+
143
+ The SDK ships with a built-in list. Today it has one entry: the PowerVR D-Series GPU on **every
144
+ route but Vulkan**. Chrome reports this GPU on its OpenGL ES route (`angle_gles`), and the Arcade
145
+ app's WebView names the bare chip with no ANGLE (`native_gl`); both crash, and both are listed. The
146
+ same GPU on the Vulkan route (`angle_vulkan`) draws fine on WebGL and is not listed, so when Chrome
147
+ moves this GPU to Vulkan, Chrome players there go back to WebGL on their own. No other device is
148
+ listed.
149
+
150
+ The Arcade can send its current list to your game when it connects, and that list replaces the
151
+ built-in one. That is how a new entry reaches your game while it is live, with no new build from
152
+ you: the next time a player opens your game, the Arcade's list applies.
153
+
154
+ When the Arcade sends no list, or one the SDK cannot read, your game uses the built-in list. It
155
+ never crashes or hangs on a missing or broken list.
156
+
157
+ ## Testing: the `gemGraphics` launch key
158
+
159
+ Add `gemGraphics` to your game's address in the Arcade to force one of the three answers:
160
+
161
+ | Launch key | Answer | Stands for |
162
+ | --------------------- | ---------------------------------------- | ---------------------------------- |
163
+ | `gemGraphics=webgpu` | `{ route: 'webgpu', listed: true }` | a listed device with WebGPU |
164
+ | `gemGraphics=safe` | `{ route: 'webgl', listed: true }` | a listed device without WebGPU |
165
+ | `gemGraphics=webgl` | `{ route: 'webgl', listed: false }` | any other device |
166
+
167
+ The Arcade forwards it to your game like any [launch key](getting-started.md), and the SDK reads it
168
+ once, when `createGem` runs. To check your game on every route:
169
+
170
+ 1. Open your game in a browser with WebGPU (desktop Chrome or Edge) with `gemGraphics=webgpu` added
171
+ to the address, for example
172
+ `https://gemarcade.com/games/<gameId>?play=1&gemGraphics=webgpu`.
173
+ 2. The browser console shows `[gem] graphics route forced to webgpu by the gemGraphics launch key`.
174
+ 3. Play through your game's scenes and check that everything draws. With three.js,
175
+ `renderer.backend.isWebGPUBackend` is `true` on this route.
176
+ 4. Open it again with `gemGraphics=safe` and check the safe recipe draws, on every canvas.
177
+ 5. Open it once more with `gemGraphics=webgl` and check your normal WebGL renderer.
178
+
179
+ A browser without WebGPU answers `{ route: 'webgl', listed: true }` for `gemGraphics=webgpu`, as a
180
+ listed device without WebGPU would, and the console says so.
181
+
182
+ ## What the SDK reads, and what it sends
183
+
184
+ To match the list, the SDK makes one throwaway WebGL context on an offscreen canvas of its own,
185
+ never on one of yours, reads which GPU and graphics route the browser uses, and releases it at
186
+ once. It does this once a page, before your renderer exists. Nothing about the answer is sent
187
+ anywhere: the SDK does not report which answer it gave. What it does send is the count of lost
188
+ contexts and devices above.
@@ -654,18 +654,25 @@ subject. [brand-art.md](brand-art.md) has both, how to draw them, and the rest o
654
654
 
655
655
  ### Being paid in one
656
656
 
657
- Every game can choose between two purchase owners for the same published platform price:
658
-
659
- | Call | Who owns confirmation | Transport | Result |
660
- | -------------------------------- | --------------------- | ------------------------------------------------------------- | ---------------------------------------- |
661
- | `gem.purchases.request(...)` | the platform | postMessage bridge; platform sheet always opens | `ok`, `declined`, or `unavailable` |
662
- | `gem.me.mutations.purchase(...)` | your game | direct authenticated SDK request; no bridge or platform sheet | an order-style result or a `GemApiError` |
663
-
664
- There is no approval step. Both paths are available to every game.
657
+ **Spend platform currency with `gem.purchases.request`.** Every game sells for Gems, Coins or any
658
+ other platform currency through the arcade's buy sheet: the platform confirms the purchase with the
659
+ player, offers a guest an account, and processes the order. Build your shop's Buy button on it.
660
+
661
+ | Call | Who owns confirmation | Transport | Use it for |
662
+ | -------------------------------- | --------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
663
+ | `gem.purchases.request(...)` | the platform | postMessage bridge; platform sheet always opens | **every platform-currency purchase** — answers `ok`, `declined` or `unavailable` |
664
+ | `gem.me.mutations.purchase(...)` | your game | direct authenticated SDK request; no bridge or platform sheet | recipes paid in your own game currency or items (`payment: { kind: 'configured' }`) |
665
+
666
+ `gem.me.mutations.purchase` with a `platform_currency` payment is **not for spending on
667
+ gemarcade.com**. A web guest has no account the platform lets spend, so gem-api refuses them
668
+ `guest_upgrade_required`; the SDK then hands the attempt to the arcade as a safety net (see
669
+ [Game-owned direct purchase](#game-owned-direct-purchase)), but that is a fallback for games that
670
+ have not moved yet, not a flow to design for. A game that spends directly today should move its Buy
671
+ button to `gem.purchases.request`.
665
672
 
666
673
  #### Platform-owned purchase sheet
667
674
 
668
- Use the bridge path when you want the arcade to confirm and process the purchase:
675
+ This is how a game spends platform currency. The arcade confirms and processes the purchase:
669
676
 
670
677
  ```js
671
678
  const config = await gem.gameConfig.load();
@@ -705,8 +712,8 @@ button's handler, never from a timer or on load: without one the answer is `decl
705
712
  `busy`, and nothing is shown. After a player cancels, the arcade answers further asks from your
706
713
  game `busy` for a short, growing interval — do not re-ask on `cancelled`.
707
714
 
708
- Hide this specific affordance when `gem.frame.parentSupports('purchases')` is false. That check does
709
- not apply to the direct SDK path.
715
+ Hide your Buy button when `gem.frame.parentSupports('purchases')` is false: that parent cannot sell
716
+ for platform currency.
710
717
 
711
718
  The sheet shows the item's or recipe's picture from its `icon_url`. You never write that field: it
712
719
  is the `icon:` file you upload with your config (`icon: icons/extra-life.png` in the YAML, uploaded
@@ -731,7 +738,12 @@ backend it answers `undefined` by design: there, your placeholder is what you se
731
738
 
732
739
  #### Game-owned direct purchase
733
740
 
734
- Use the direct path when your game provides its own purchase UI and does not want the platform sheet:
741
+ The direct path submits an order from your game with no platform sheet. Its job is recipes paid in
742
+ your own game currency or items (`payment: { kind: 'configured' }`, below). It also accepts a
743
+ `platform_currency` payment, but **do not spend platform currency this way on gemarcade.com**: a
744
+ web guest is refused (below), and every game spends Gems and Coins through
745
+ [the sheet](#platform-owned-purchase-sheet). The form is documented for games that have not moved
746
+ yet:
735
747
 
736
748
  ```js
737
749
  const result = await gem.me.mutations.purchase({
@@ -809,6 +821,35 @@ try {
809
821
  Do not retry an unsafe write after a timeout; re-read inventory or use the returned order/purchase
810
822
  ids for support.
811
823
 
824
+ **A guest is refused `guest_upgrade_required`, and you draw nothing.** The platform lets only a
825
+ player with an account spend Gems or Coins — which is why platform currency is spent through
826
+ [the sheet](#platform-owned-purchase-sheet), where the arcade handles a guest itself. This is the
827
+ safety net for a game that still spends directly. When a guest's direct purchase is refused, the SDK
828
+ hands the attempt to the arcade, which offers the player an account over your game, and your call
829
+ rejects at once with a `GemApiError` whose `code` is `guest_upgrade_required`. Treat it exactly
830
+ like a decline: return to the shop silently, with no sign-up, "log in to buy" or error screen of
831
+ your own — the arcade's is already showing. The `default` arm of the switch above must not draw
832
+ anything for it either, so give it a quiet case of its own:
833
+
834
+ ```js
835
+ case 'guest_upgrade_required':
836
+ return; // the arcade is offering the player an account; draw nothing
837
+ ```
838
+
839
+ If the player makes an account and buys the item from the arcade's sheet, the purchase completes
840
+ after your call has answered — sometimes after a reload. Hear it with `gem.purchases.onSettled`:
841
+
842
+ ```js
843
+ gem.purchases.onSettled(({ kind, targetId, quantity }) => {
844
+ showGrantFeedback(targetId, quantity); // then re-read; the SDK already refreshed gem.me
845
+ });
846
+ ```
847
+
848
+ The SDK confirms the purchase with the platform before it calls you, and refreshes `gem.me`, so
849
+ `gem.me.inventory.onChanged` fires too. It hears nothing on an arcade that does not support it, and
850
+ never for a purchase an `ok` already reported. This needs SDK 21.1.0 or later; an older build shows
851
+ the guest your `default` arm instead of the arcade's offer.
852
+
812
853
  **`targetId` is the UUID, never the slug.** `config.item(...)` accepts either because it is your
813
854
  local lookup. The purchase catalog is keyed by ids. Recipes also require `catalogId`.
814
855
 
@@ -817,21 +858,22 @@ local lookup. The purchase catalog is keyed by ids. Recipes also require `catalo
817
858
  One vocabulary covers both paths: the sheet's `res.reason` and the direct path's `err.code` are
818
859
  the same strings.
819
860
 
820
- | Code | What happened | What to do |
821
- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
822
- | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
823
- | `currency_not_in_game_payable` | the currency exists but cannot pay for game items | price in a currency `gemctl platform config` lists under `platform_prices` |
824
- | `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought | send the id (not the slug); promote the config version that prices it |
825
- | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
826
- | `busy` | a purchase is already in flight, or a sheet is open | ask again only from a fresh player action |
827
- | `cancelled` | the player dismissed the sheet | nothing — return to the shop |
828
- | `insufficient_funds` | the player cannot afford it | say so in your shop's words; never draw a top-up screen |
829
- | `price_mismatch`, `config_version_stale` | the price or config changed since your catalog read; nothing was charged | re-read the catalog and re-render |
830
- | `platform_currency_unavailable` | the currency is not available to this purchase right now | show the store as unavailable |
831
- | `not_available` | the recipe is outside its availability window, or inactive; nothing was charged | hide or disable the row until its window opens |
832
- | `recipe_locked` | the recipe's prerequisites are not met; nothing was charged | show the lock from `recipeAvailability().lockReasons` |
833
- | `recipe_on_cooldown`, `recipe_max_uses_exceeded`, `item_max_stack_exceeded`, `recipe_costs_unmet`, `quantity_invalid` | the recipe or item refused this purchase before any charge | reflect it on the row; `recipeAvailability` has the detail |
834
- | `error` | anything else, or no reliable answer | one neutral "store unavailable"; after `unavailable`, re-read inventory |
861
+ | Code | What happened | What to do |
862
+ | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
863
+ | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
864
+ | `currency_not_in_game_payable` | the currency exists but cannot pay for game items | price in a currency `gemctl platform config` lists under `platform_prices` |
865
+ | `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought | send the id (not the slug); promote the config version that prices it |
866
+ | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
867
+ | `busy` | a purchase is in flight, a sheet is open, or the arcade is offering a guest an account | ask again only from a fresh player action |
868
+ | `cancelled` | the player dismissed the sheet | nothing — return to the shop |
869
+ | `insufficient_funds` | the player cannot afford it | say so in your shop's words; never draw a top-up screen |
870
+ | `guest_upgrade_required` | the player is a guest; only an account may spend. Nothing was charged | nothing — the arcade offers them an account; draw no sign-up screen of your own |
871
+ | `price_mismatch`, `config_version_stale` | the price or config changed since your catalog read; nothing was charged | re-read the catalog and re-render |
872
+ | `platform_currency_unavailable` | the currency is not available to this purchase right now | show the store as unavailable |
873
+ | `not_available` | the recipe is outside its availability window, or inactive; nothing was charged | hide or disable the row until its window opens |
874
+ | `recipe_locked` | the recipe's prerequisites are not met; nothing was charged | show the lock from `recipeAvailability().lockReasons` |
875
+ | `recipe_on_cooldown`, `recipe_max_uses_exceeded`, `item_max_stack_exceeded`, `recipe_costs_unmet`, `quantity_invalid` | the recipe or item refused this purchase before any charge | reflect it on the row; `recipeAvailability` has the detail |
876
+ | `error` | anything else, or no reliable answer | one neutral "store unavailable"; after `unavailable`, re-read inventory |
835
877
 
836
878
  Reasons are for your shop's state and your logs. Branch money on `outcome` and on a re-read of
837
879
  inventory, never on a reason, and do not retry because of one.