@indiegems/gem-web-sdk 21.0.0 → 21.1.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 +23 -0
- package/README.md +1 -0
- package/dist/conformance.d.ts +1 -1
- package/dist/dev-tools.d.ts +1 -0
- package/dist/function.d.ts +10 -2
- package/dist/game.d.ts +78 -11
- package/dist/gem-web-sdk.bridge.js +2 -2
- package/dist/gem-web-sdk.conformance.js +3 -3
- package/dist/gem-web-sdk.dev-tools.js +2 -0
- 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 +103 -48
- package/dist/sdk.d.ts +78 -11
- package/docs/analytics.md +3 -2
- package/docs/api/api-reference-function.md +11 -4
- package/docs/api/api-reference-game.md +354 -23
- package/docs/api/api-reference-server.md +1 -1
- package/docs/api/reference.json +411 -14
- package/docs/arcade-economy.md +2 -2
- package/docs/changelog.md +23 -0
- package/docs/game-economy-tutorial.md +6 -3
- package/docs/getting-started.md +122 -30
- package/docs/graphics-route.md +188 -0
- package/docs/inventory-and-catalogs.md +69 -27
- package/docs/manifest.json +21 -16
- package/docs/server-bundles.md +3 -3
- package/docs/what-a-game-must-do.md +33 -2
- package/package.json +5 -1
- package/schema/bridge.v1.json +263 -12
- package/schema/capabilities.public.json +26 -0
package/docs/getting-started.md
CHANGED
|
@@ -50,7 +50,7 @@ either way.
|
|
|
50
50
|
```jsonc
|
|
51
51
|
// package.json
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@indiegems/gem-web-sdk": "21.
|
|
53
|
+
"@indiegems/gem-web-sdk": "21.1.0"
|
|
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.
|
|
303
|
-
whether
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
310
|
-
a character a link cannot carry. A mistake shows up at your desk, not as a Share button
|
|
311
|
-
does nothing for players — try it in the SDK's `examples/dev-harness/` with **share**
|
|
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,16 +355,18 @@ 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.
|
|
347
|
-
`gemChallenge`, which belongs to [friend challenges](friends.md#friend-challenges),
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
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))
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
|
@@ -363,12 +377,18 @@ What reaches the frame:
|
|
|
363
377
|
only keys the arcade keeps for itself; it never interprets yours:
|
|
364
378
|
- Store and play-page switches: `play`, `channel`, `purchase`, and `build`, which pins one
|
|
365
379
|
of your own builds when you play it from your Builds page.
|
|
366
|
-
- Platform switches: `devtools`, `devLocale`,
|
|
367
|
-
rewarded TEST ads.
|
|
368
|
-
- The arcade's own sign-in
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
`fbclid`, `ttclid`, `gclid`, `
|
|
380
|
+
- Platform switches: `devtools`, `devLocale`, `gem_test_ads`, the mode of the arcade's
|
|
381
|
+
rewarded TEST ads, and `moments` and `ads`, the dev host's test moment and ad modes.
|
|
382
|
+
- The arcade's own sign-up and sign-in keys: `invite`, a friend's invite code, and `social`,
|
|
383
|
+
the sign-in return marker.
|
|
384
|
+
- Attribution and campaign keys: `_s`, `utm_*` (every key with that prefix, matched
|
|
385
|
+
case-sensitively, so UTM_X and utmost are yours), `fbclid`, `ttclid`, `gclid`, `gbraid`,
|
|
386
|
+
`wbraid`, `msclkid`. The arcade strips the visitor's own campaign and click ids, but a link
|
|
387
|
+
a game shares, or one anyone writes by hand, can still put `utm_*` and the click ids into
|
|
388
|
+
your frame, restored from their escaped form.
|
|
389
|
+
|
|
390
|
+
A link your game shares can still carry most of them: see
|
|
391
|
+
[Sharing a reserved key](#sharing-a-reserved-key) below.
|
|
372
392
|
- `embedder`, which stays what
|
|
373
393
|
[the embedder allowlist](what-a-game-must-do.md#1-the-embedder-allowlist) says: never read
|
|
374
394
|
from the URL — a link cannot even try.
|
|
@@ -376,16 +396,40 @@ What reaches the frame:
|
|
|
376
396
|
A key starting with `_` can never be one of yours: the key rule below requires a leading
|
|
377
397
|
letter, so the arcade keeps that space for itself.
|
|
378
398
|
|
|
379
|
-
- **
|
|
380
|
-
first when a player opens a friend challenge, and the second when it opens
|
|
381
|
-
with someone
|
|
382
|
-
`gem.friends.launchedChallenge()`,
|
|
383
|
-
|
|
384
|
-
|
|
399
|
+
- **Three platform keys.** `gemChallenge`, `gemPlayWith` and `gemGraphics` do reach the frame —
|
|
400
|
+
the arcade adds the first when a player opens a friend challenge, and the second when it opens
|
|
401
|
+
your game to play with someone, and you add the third yourself to test the graphics route — but
|
|
402
|
+
they are the platform's, not yours: read the challenge with `gem.friends.launchedChallenge()`,
|
|
403
|
+
let `gem.rooms.quickJoin()` carry `gemPlayWith`, ask `gem.graphics.preferredRoute()` rather than
|
|
404
|
+
reading `gemGraphics`, and never read any of them from the URL. Do not define a key of any of
|
|
405
|
+
these names, and treat every key beginning with `gem` as reserved: a friend-challenge template
|
|
406
|
+
refuses a param named that way.
|
|
385
407
|
- **Bounded.** At most 16 parameters. A key is a letter followed by up to 63 letters, digits,
|
|
386
408
|
`_` or `-`. A value is at most 512 characters with no control or format characters. An entry
|
|
387
409
|
that breaks a rule is dropped on its own; the rest of the link still arrives.
|
|
388
410
|
|
|
411
|
+
#### Sharing a reserved key
|
|
412
|
+
|
|
413
|
+
A current arcade carries the arcade's own keys in a link your game shares, so you never need to
|
|
414
|
+
rename a parameter for it. It writes each one into the link under an escaped name it never reads
|
|
415
|
+
itself, and puts the original name back before your game reads its URL: share
|
|
416
|
+
`{ island: '7', invite: 'K2' }` and the frame that link opens reads `island=7&invite=K2`. The
|
|
417
|
+
arcade's own uses of those keys are unaffected — an invite it put in a link itself still signs a
|
|
418
|
+
friend up, and still never reaches your game.
|
|
419
|
+
|
|
420
|
+
A restored click id or `utm_` value is whatever the link says. Anyone who edits a share link can
|
|
421
|
+
forge one, and third-party tags in your frame (analytics, ads) will read it as attribution. Never
|
|
422
|
+
trust it as attribution.
|
|
423
|
+
|
|
424
|
+
Two keys it never carries: `embedder`, and `_s`, which breaks the key rule anyway. A key it has
|
|
425
|
+
to escape must also stay a valid key once escaped, so keep a reserved one (a `utm_` key, say) to
|
|
426
|
+
57 characters. `gem.share.request` throws on each of these before it sends.
|
|
427
|
+
|
|
428
|
+
An older arcade, or a development host that has not caught up, refuses the whole share on any
|
|
429
|
+
reserved key and does not say why. So where the arcade has not said it carries them,
|
|
430
|
+
`gem.share.request` throws on every reserved key before it sends, naming the key — rename the
|
|
431
|
+
parameter there, or wait for the arcade.
|
|
432
|
+
|
|
389
433
|
Four things to hold onto when you share:
|
|
390
434
|
|
|
391
435
|
**Call `request` synchronously from your own Share button's click handler.** Never from a
|
|
@@ -393,7 +437,8 @@ timer, after an `await`, or on a game event such as a win: the arcade only share
|
|
|
393
437
|
player's own click, and anywhere else it opens its Share panel instead, if it does anything.
|
|
394
438
|
|
|
395
439
|
**Never gate progress or a reward on sharing.** You cannot tell whether a player shared — the
|
|
396
|
-
arcade does not say, by design
|
|
440
|
+
arcade does not say, by design, and a resolved `request` does not mean it — so a reward for
|
|
441
|
+
sharing is a reward for pressing a button.
|
|
397
442
|
|
|
398
443
|
**Put no personal identifiers in the parameters.** No player id, name, email or anything that
|
|
399
444
|
leads back to a person: the link is public, and it is handed to whoever the player sends it to.
|
|
@@ -461,6 +506,53 @@ disagree. Running it twice is safe — it replaces what it finds.
|
|
|
461
506
|
**Your bundler does not matter.** It is a command rather than a plugin, so anything that ends
|
|
462
507
|
with a directory you upload can run it, including a build you assembled by hand.
|
|
463
508
|
|
|
509
|
+
### Keep dev-only code on the dev channel
|
|
510
|
+
|
|
511
|
+
A cheat panel, a level skipper or a debug menu is worth having in the builds you test, and must
|
|
512
|
+
never reach a player. **Mark it, and the platform keeps it off the test and release channels for
|
|
513
|
+
you.** Import the SDK's dev-tools mark from your dev-only code, and from nowhere else:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
// src/dev/index.ts — everything dev-only lives under src/dev/
|
|
517
|
+
import '@indiegems/gem-web-sdk/dev-tools';
|
|
518
|
+
|
|
519
|
+
export function mountDevPanel() {
|
|
520
|
+
/* ... */
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
A build that carries that code carries the mark. It uploads and plays on `dev` as usual, and a
|
|
525
|
+
promote to `test` or `release` is refused, naming the build.
|
|
526
|
+
|
|
527
|
+
Load the dev code behind one switch your bundler sets at build time, so a release build leaves
|
|
528
|
+
it out entirely, mark included:
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
// src/main.ts
|
|
532
|
+
declare const __DEV_TOOLS__: boolean;
|
|
533
|
+
if (__DEV_TOOLS__) void import('./dev').then((m) => m.mountDevPanel());
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
// vite.config.ts
|
|
538
|
+
export default defineConfig(({ mode }) => ({
|
|
539
|
+
define: { __DEV_TOOLS__: JSON.stringify(mode !== 'release') },
|
|
540
|
+
}));
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
```jsonc
|
|
544
|
+
// package.json
|
|
545
|
+
"scripts": {
|
|
546
|
+
"build": "vite build && gem-sdk-stamp dist",
|
|
547
|
+
"build:release": "vite build --mode release && gem-sdk-stamp dist"
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
`build` is the one you play and put on `dev`; `build:release` is the one you upload for `test`
|
|
552
|
+
and `release`. **Use a build-time switch, never one read at run time** (a URL parameter, a key
|
|
553
|
+
combination, the channel the game finds itself on): those leave the code in the files every
|
|
554
|
+
player downloads, for anyone to read and switch on.
|
|
555
|
+
|
|
464
556
|
### Then upload and promote
|
|
465
557
|
|
|
466
558
|
```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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
|
662
|
-
|
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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
|
|
709
|
-
|
|
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
|
-
|
|
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
|
|
821
|
-
| --------------------------------------------------------------------------------------------------------------------- |
|
|
822
|
-
| `unknown_currency` | the slug is not an active platform currency
|
|
823
|
-
| `currency_not_in_game_payable` | the currency exists but cannot pay for game items
|
|
824
|
-
| `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought
|
|
825
|
-
| `no_price_in_currency` | the target has no price in that currency
|
|
826
|
-
| `busy` | a purchase is
|
|
827
|
-
| `cancelled` | the player dismissed the sheet
|
|
828
|
-
| `insufficient_funds` | the player cannot afford it
|
|
829
|
-
| `
|
|
830
|
-
| `
|
|
831
|
-
| `
|
|
832
|
-
| `
|
|
833
|
-
| `
|
|
834
|
-
| `
|
|
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.
|
package/docs/manifest.json
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"file": "analytics.md",
|
|
16
|
-
"sha256": "
|
|
17
|
-
"updated": "2026-11-
|
|
16
|
+
"sha256": "5814655859e3d6b223f94b71f1b064783b0f242d409c3ec3078089cb4b7c8ec5",
|
|
17
|
+
"updated": "2026-11-04T17:17:14Z"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"file": "arcade-and-store.md",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
"file": "arcade-economy.md",
|
|
31
|
-
"sha256": "
|
|
32
|
-
"updated": "2026-
|
|
31
|
+
"sha256": "c0f25495a536d5facdbb63cbcd89acf558e2fe79d44f3a99d35284de1937fc42",
|
|
32
|
+
"updated": "2026-11-04T17:09:56Z"
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"file": "async-opponents.md",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"file": "changelog.md",
|
|
51
|
-
"sha256": "
|
|
52
|
-
"updated": "2026-11-
|
|
51
|
+
"sha256": "31164cb6810097240ce18733c76b45b0b3e5ba9cda6738bfce7e27503cd90ad1",
|
|
52
|
+
"updated": "2026-11-04T19:04:00Z"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"file": "daily-challenges.md",
|
|
@@ -73,18 +73,23 @@
|
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"file": "game-economy-tutorial.md",
|
|
76
|
-
"sha256": "
|
|
77
|
-
"updated": "2026-
|
|
76
|
+
"sha256": "0ab25a9d69cf5e40351624355da5ef84e1431bf8a8a420e2c4d7350e20a5b1c5",
|
|
77
|
+
"updated": "2026-11-04T16:44:33Z"
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
"file": "getting-started.md",
|
|
81
|
-
"sha256": "
|
|
82
|
-
"updated": "2026-11-
|
|
81
|
+
"sha256": "4cb0f2fec9438cd03a2a785ad6247fe60b32d95c9a9fdcbf0e2cc4390c9f767a",
|
|
82
|
+
"updated": "2026-11-04T19:04:00Z"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"file": "graphics-route.md",
|
|
86
|
+
"sha256": "c71c826502b415a89dd069f79d22b82f7a9fe49c9da7f5e7a1052763c826f41f",
|
|
87
|
+
"updated": "2026-11-04T17:45:35Z"
|
|
83
88
|
},
|
|
84
89
|
{
|
|
85
90
|
"file": "inventory-and-catalogs.md",
|
|
86
|
-
"sha256": "
|
|
87
|
-
"updated": "2026-11-
|
|
91
|
+
"sha256": "2094c555eb5a1a3d817a597ac07b2b193077f92176eb9596c2ba3ed461363bb6",
|
|
92
|
+
"updated": "2026-11-04T16:43:14Z"
|
|
88
93
|
},
|
|
89
94
|
{
|
|
90
95
|
"file": "leaderboards.md",
|
|
@@ -138,8 +143,8 @@
|
|
|
138
143
|
},
|
|
139
144
|
{
|
|
140
145
|
"file": "server-bundles.md",
|
|
141
|
-
"sha256": "
|
|
142
|
-
"updated": "2026-11-
|
|
146
|
+
"sha256": "a0df89891432d799fe7ad59534c68265937fe5096739afa5fbe230de97837af3",
|
|
147
|
+
"updated": "2026-11-04T19:04:00Z"
|
|
143
148
|
},
|
|
144
149
|
{
|
|
145
150
|
"file": "serverless-functions-js.md",
|
|
@@ -163,8 +168,8 @@
|
|
|
163
168
|
},
|
|
164
169
|
{
|
|
165
170
|
"file": "what-a-game-must-do.md",
|
|
166
|
-
"sha256": "
|
|
167
|
-
"updated": "2026-11-
|
|
171
|
+
"sha256": "523886cf799570ab3631bbc9db11e03554d2ad403a1083d94038a48acd4fb611",
|
|
172
|
+
"updated": "2026-11-04T18:23:48Z"
|
|
168
173
|
}
|
|
169
174
|
]
|
|
170
175
|
}
|