@indiegems/gem-web-sdk 7.4.0 → 7.4.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.
Files changed (38) hide show
  1. package/README.md +54 -0
  2. package/dist/gem-web-sdk.esm.js +1 -1
  3. package/dist/gem-web-sdk.game.js +1 -1
  4. package/dist/gem-web-sdk.min.js +1 -1
  5. package/dist/gem-web-sdk.sdk.js +1 -1
  6. package/dist/gem-web-sdk.server.js +1 -1
  7. package/docs/achievements.md +232 -0
  8. package/docs/analytics.md +271 -0
  9. package/docs/api/api-reference-game.md +15308 -0
  10. package/docs/api/api-reference-server.md +4397 -0
  11. package/docs/api/reference.json +24130 -0
  12. package/docs/arcade-economy.md +263 -0
  13. package/docs/async-opponents.md +396 -0
  14. package/docs/brand-art.md +334 -0
  15. package/docs/cross-promotion.md +137 -0
  16. package/docs/daily-challenges.md +530 -0
  17. package/docs/dedicated-server-flow.md +879 -0
  18. package/docs/deprecation-policy.md +124 -0
  19. package/docs/friends.md +233 -0
  20. package/docs/game-economy-tutorial.md +326 -0
  21. package/docs/getting-started.md +552 -0
  22. package/docs/inventory-and-catalogs.md +1035 -0
  23. package/docs/leaderboards.md +277 -0
  24. package/docs/manifest.json +135 -0
  25. package/docs/match-session.md +288 -0
  26. package/docs/p2p-and-parties.md +783 -0
  27. package/docs/platform-rules.md +372 -0
  28. package/docs/player-sign-in.md +89 -0
  29. package/docs/room-shapes.md +195 -0
  30. package/docs/rooms-and-matches.md +779 -0
  31. package/docs/saving-player-progress.md +457 -0
  32. package/docs/server-bundles.md +1041 -0
  33. package/docs/serverless-functions.md +748 -0
  34. package/docs/trading-and-gifting.md +98 -0
  35. package/docs/what-a-game-must-do.md +1231 -0
  36. package/package.json +7 -2
  37. package/schema/bridge.v1.json +5 -5
  38. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,1231 @@
1
+ # What a game still has to do for itself
2
+
3
+ The SDK's promise is that a browser game reaches a dedicated multiplayer match without implementing
4
+ WebRTC, server allocation, gem-api auth, or the platform's own handshakes. This page is the other
5
+ half of that promise: **what is left**, stated plainly, so nobody has to discover it during an
6
+ integration.
7
+
8
+ It is written from two audits, one from each direction. The first game to complete this path in
9
+ production hand-rolled the whole platform layer in a single 854-line file; what that file did which
10
+ every game needs now lives in the SDK. A second game then audited its own platform layer against
11
+ this SDK **row by row, measured in a browser**, and two more things moved here because of it: the
12
+ early-handshake capture, and the backend routing. That audit's "checked and clean" list — what a game
13
+ correctly does _not_ delegate — is the last section of this page, because the half of an ownership
14
+ audit that is easy to skip is the half that says where the line was drawn correctly.
15
+
16
+ Its companion is [`platform-rules.md`](platform-rules.md): what the _platform_ does that no client-side
17
+ signature states — how a match is sized, when a room disappears, where a room's seats come from and why
18
+ a create cannot ask for its own, how the compatibility line derives from your version number. This page
19
+ is what you own; that one is what is done to you.
20
+
21
+ ## The shortest correct integration
22
+
23
+ Nothing goes in your HTML. The SDK is a dependency your game bundles — see
24
+ [getting-started](getting-started.md#3-add-the-sdk), which owns the install.
25
+
26
+ ```ts
27
+ import { createGem, text } from '@indiegems/gem-web-sdk/sdk';
28
+ import server from './server.js'; // your server bundle's default export — the one you publish
29
+
30
+ const gem = createGem({
31
+ onPlayer: (player) => showName(text.toPlain(player.displayName)),
32
+ });
33
+
34
+ gem.frame.ready(); // held until the bridge is up, then delivered
35
+ await gem.awaitIdentity(); // the play token, which everything is scoped by
36
+ await gem.rooms.quickJoin({ templateSlug: 'skirmish' });
37
+ await gem.match.serve(server); // your server bundle's default export; runs HERE only if this seat hosts
38
+ const link = await gem.match.connect(); // one link to the authority, whoever the template made that
39
+ // The server has already accepted this player. Start playing. `connect()` dials the
40
+ // transports the room template lists, in its order — no transport is named here;
41
+ // `gem.dedicatedServer` and `gem.p2p` stay underneath for a game that wants their
42
+ // specifics — dedicated-server-flow.md, p2p-and-parties.md.
43
+ ```
44
+
45
+ No `baseUrl`. The Arcade names the backend on every token and the SDK routes there — see
46
+ "which backend" below.
47
+
48
+ ## The five things that are yours
49
+
50
+ ### 1. The embedder allowlist
51
+
52
+ **This one is done for you, and most games write nothing.** A page may embed your game and hand it
53
+ tokens only if its origin is allowed, and the pages that matter are always allowed without a word
54
+ from you:
55
+
56
+ - **the Gem Arcade website and app** — the SDK carries their origins as `ARCADE_EMBEDDERS`, on every
57
+ platform the app runs on;
58
+ - **a dev host on your own machine** — `gemctl serve` and the Gem Editor frame a locally-served game
59
+ from a loopback origin whose port changes every run, and the SDK admits that shape by rule, only
60
+ while your game itself is served from loopback. Once published, nothing about the rule applies.
61
+
62
+ `allowedEmbedders` is for a page **beyond** those, and what you list is **added** to the arcade's
63
+ origins — you cannot drop the arcade by writing your own list:
64
+
65
+ ```ts
66
+ // Only when a site of your own frames your game.
67
+ const gem = createGem({ allowedEmbedders: ['https://my-own-site.example'] });
68
+ ```
69
+
70
+ Name the whole origin — scheme, host and optional port, with nothing after it. `createGem` throws on
71
+ an entry that is not one (a trailing slash, a path, a bare hostname), and on `undefined`, which is
72
+ what `[import.meta.env.VITE_EMBEDDER]` becomes when nothing is set. A well-formed origin that is
73
+ simply wrong fails at the handshake instead, silently on the wire by design: the refusal reaches
74
+ `onRefused` and the SDK warns on the console after eight seconds naming this as one of three causes.
75
+
76
+ **Bake entries in. Never read them from the URL**: `?embedder=` hands the frame the allowlist that was
77
+ supposed to constrain it. Do not add a `localhost:<port>` entry for a dev host either; the rule above
78
+ already covers it, and an older SDK that refuses that page is fixed by upgrading, not by an entry.
79
+
80
+ ### 2. Your version number, which you no longer declare
81
+
82
+ **Which number you publish under is yours. Telling the SDK what it is stopped being.** The arcade
83
+ routed this player to one of your builds, so the arcade knows its version, and it now says so over
84
+ the bridge. Every room and match call reports that answer. Pass nothing.
85
+
86
+ What is still yours is deciding when to bump it, because it is what keeps incompatible builds out of
87
+ one another's matches: bump when you ship something that changes what a match expects.
88
+
89
+ `clientVersion.gameVersion` is **deprecated** and still accepted. It is the only answer available
90
+ from an arcade too old to send one, so a build that has to run under an older arcade may still pass
91
+ it — and where both exist, the platform's wins and nothing in your running game says so.
92
+ [The deprecation policy](deprecation-policy.md) is how long it has.
93
+
94
+ Why it went: the number in your source had to be kept in step by hand with the number the build was
95
+ uploaded under, and a live game shipped with the two disagreeing. Every room call was refused for a
96
+ version that named no published build, the refusal landed somewhere that only warns, and for three
97
+ days players earned nothing while the win screen kept promising rewards that were never granted.
98
+ Both halves of the upload agreed with each other, so everything that checks an upload looked correct.
99
+
100
+ Do not supply `engineVersion`. A browser game is not an engine export and has none; the SDK reports
101
+ one, and a plausible-looking value derives an engine line that matches no fleet and fails at
102
+ allocation — long after the call that decided it, and as a capacity refusal that will never clear.
103
+ [`dedicated-server-flow.md`](dedicated-server-flow.md#two-version-lines-two-derivation-rules-and-a-refusal-that-names-neither)
104
+ is how to tell that one from a fleet that is genuinely full.
105
+
106
+ Publishing a new version also starts a clock on the one it replaces, and retiring a bad build is
107
+ something you do rather than something the SDK does.
108
+ [`rooms-and-matches.md`](rooms-and-matches.md#what-ages-a-version-out-and-the-three-levers-the-games-owner-has)
109
+ has the expiry window, the blacklist, how a rollback is performed, and what a player's client is
110
+ told when their build ages out.
111
+
112
+ ### 3. The handshake is caught, not awaited
113
+
114
+ The handshake is **parent-initiated**, and `postMessage` queues for nobody: an offer is delivered to
115
+ the listeners that exist at that instant. **The Arcade re-offers on a steady cadence — one offer
116
+ every two seconds — for as long as the page holds your frame, and never gives up.** So a game that
117
+ boots through a module graph attaches its listener after some offers have passed and connects on the
118
+ next one: usually well under a second, and up to about two in the worst case. That wait is normal
119
+ and is not a retry.
120
+
121
+ An embedder that is not the Arcade need not behave this way — one that posts a single offer and
122
+ stops is a parent a late listener can miss entirely. `gem-web-sdk.capture.js`, loaded first in the
123
+ document, holds offers that arrive before your bundle does; it is an **optional latency
124
+ optimisation** against the Arcade and insurance against a one-shot parent. It is not part of any
125
+ install, and you do not need it to ship. Where one is used it holds offers untouched until
126
+ `createGem` adopts it — newest first, because an old offer's port may already be superseded and
127
+ answering it wedges the frame.
128
+
129
+ Either way `createGem` can be called whenever your code is ready, and `ready()` can go wherever it
130
+ reads best; it is held and delivered on connect.
131
+
132
+ Once it does connect, [the developer panel](getting-started.md#the-developer-panel) will confirm
133
+ it from your side — the bridge's state, the negotiated protocol version and what the parent
134
+ advertised. It cannot help you while the handshake is failing, though: the panel is opened by a
135
+ token grant, and there is no grant without a bridge. A frame that never connects has nothing to
136
+ open.
137
+
138
+ **What you own is the boot phase, below.** With the parent re-offering, keeping the event loop free
139
+ is the whole of what decides how fast your game connects — a main thread blocked through boot delays
140
+ delivery of an offer that already arrived, and every second of that is a second the player spends
141
+ watching the Arcade's loading state.
142
+
143
+ ### 4. Everything on the channel after the welcome
144
+
145
+ The platform's envelope is two message types and it is finished before `connect()` resolves. What
146
+ goes on the channel afterwards — JSON, binary, a schema of your own — is opaque to everything above
147
+ the transport, and nothing above the transport bounds its size either, which is a reason for your
148
+ server to bound what it will accept. See [`server-bundles.md`](server-bundles.md).
149
+
150
+ ### 5. What to do about the failures the SDK reports
151
+
152
+ The SDK reports; it does not decide. A stale build (`versionUpdateAvailable`), a match that failed to
153
+ allocate, a token the parent refused — what a game does about each is a game decision, and a platform
154
+ layer that acted would be making it for you.
155
+
156
+ ## The boot phase: settle the bridge before the heavy bundle
157
+
158
+ The invariant, stated plainly, because it is the hardest-won lesson in this document:
159
+
160
+ > **Offers are delivered, not polled — and delivery needs the event loop.** Registering a listener
161
+ > early is worthless if the main thread then executes your application bundle. The offer sits queued
162
+ > behind that work, and by the time it dispatches, the port it carried may already be dead. The
163
+ > parent's re-offer solves the _listening_ half — it keeps offering until you answer. The _delivery_
164
+ > half is yours, and with the capture gone from the default path it is the whole of what you own
165
+ > here: keep the event loop idle until the bridge settles.
166
+
167
+ The pattern is a two-file boot. The real entry stays small — `createGem` and your splash, nothing
168
+ heavy — and everything expensive sits behind a dynamic import that is not issued until the bridge
169
+ has settled:
170
+
171
+ ```ts
172
+ // boot.ts — the real entry point. Do not add imports here without asking what they pull in.
173
+ import { createGem } from '@indiegems/gem-web-sdk/sdk';
174
+
175
+ const gem = createGem();
176
+
177
+ try {
178
+ await gem.awaitBridge(); // the loop is idle through the offer window
179
+ gem.frame?.progress(1, 4);
180
+ const { runApp } = await import('./app.ts'); // NOW fetch, parse and execute the megabytes
181
+ gem.frame?.progress(2, 4);
182
+ await runApp(gem);
183
+ } finally {
184
+ gem.frame?.ready(); // even on failure — see the loading contract
185
+ }
186
+ ```
187
+
188
+ `awaitBridge()` resolves when the bridge leaves `waiting` — connected, refused or closed — or at a
189
+ bounded timeout (default 10s), with the state it settled on. **Proceed on every outcome.**
190
+ `connected` is the Arcade; anything else is a boot without a parent, which your game must survive
191
+ anyway. The wait costs nothing when the handshake is quick: it settles the moment the frame
192
+ connects. Unframed it is never answered, because no parent ever pushes one.
193
+
194
+ The measured stakes, from the first game to hit this on an iPhone: listener up at ~300ms, then a
195
+ main-thread block — the parent's offer, queued behind the block, was delivered twenty seconds later
196
+ into a port closed ten seconds earlier. Same page, connect or time out, decided by nothing but
197
+ whether the loop was idle. The entry split also buys the listening half for free: a boot module of
198
+ "params + SDK" registers at ~300ms where a full bundle takes seconds.
199
+
200
+ ## The loading contract
201
+
202
+ The parent shows its loading state until your frame says otherwise. The whole contract is four
203
+ sentences:
204
+
205
+ - **`ready()` means "there is something worth looking at"** — your boot screen, not your
206
+ world-ready. Measured on the Arcade: the parent's loading state is a plain page it abandons after
207
+ ~10s regardless, so holding `ready` until world-ready hides your own, more informative screen and
208
+ buys nothing.
209
+ - **`ready()` is sent exactly once per session, and the SDK enforces it.** It is held while the
210
+ bridge is down and delivered on connect, so call order cannot lose it; a call after one has gone
211
+ out is suppressed and reported through `onDropped`. This is what makes the `finally` backstop
212
+ above safe to write.
213
+ - **Send it even when the boot failed.** A frame that never says ready sits behind the parent's
214
+ loading state forever, with your error message underneath where nobody can read it. The `finally`
215
+ is not decoration.
216
+ - **`progress(n, total)` at coarse milestones** — bridge, app module, engine, world is plenty. It is
217
+ rate-limited per message type like everything else on the bridge; a per-asset progress loop will
218
+ be throttled, and a progress call after `ready` is meaningless to a parent that has already
219
+ unveiled you.
220
+
221
+ ## Boot diagnostics: where the seconds went
222
+
223
+ `gem.boot` is a timeline of named marks, one greppable line out of `gem.boot.timeline()`:
224
+
225
+ ```
226
+ document parsed@294ms → sdk created@336ms (+42ms) → bridge connected@388ms (+52ms) →
227
+ token requested@389ms (+1ms) → token ready@913ms (+524ms) → room joined@1109ms (+196ms) → …
228
+ ```
229
+
230
+ The SDK marks what it owns — `document parsed`, `sdk created`, `bridge connected`,
231
+ `token requested`, `token ready`/`token lost`, `room created`/`room joined`, `allocation started`,
232
+ `match ready`/`match failed`, `welcome` — so the platform's share of a slow boot is individually
233
+ attributable in any player's console capture. Mark the steps you own (`gem.boot.mark('world
234
+ ready')`) and both halves land in the same line. A name marks once; boot happens once.
235
+
236
+ Two lines print themselves: at the eight-second mark the SDK logs the boot report — connected or
237
+ not — because **field captures attach to a frame's console late, and boot-time logs never appear in
238
+ them**; and when a connection completes — the dedicated-server welcome, or a P2P peer reaching a
239
+ usable state — the finished line prints once. Both respect `log: null`. Every timing mystery in the
240
+ incident that motivated this instrument was solved the day the numbers existed; when a player
241
+ capture lands on your desk, this line is the first thing to grep for.
242
+
243
+ The arcade will also render this timeline for you, over a running game, without a capture and
244
+ without any code — see
245
+ [the developer panel](getting-started.md#the-developer-panel).
246
+
247
+ ### The two multiplayer journeys report differently, because they are different
248
+
249
+ Everything above the match is shared. After it the paths have nothing in common — there is no
250
+ allocation to wait for on a P2P match, no server the platform vouches for, and "ready" means
251
+ something different at each end of the star — so the marks name what actually happens rather than
252
+ being forced into one vocabulary:
253
+
254
+ ```
255
+ … → match ready@1250ms (+50ms) → turn leased@1402ms (+152ms) → signaling joined@1421ms (+19ms) →
256
+ signaling hello@1502ms (+81ms) → offer sent@1530ms (+28ms) → answer received@1655ms (+125ms) →
257
+ credential ready@3702ms (+2047ms) → host verified@3703ms (+1ms) → channel open@3980ms (+277ms) →
258
+ admitted@4020ms (+40ms)
259
+ ```
260
+
261
+ - **`turn leased`, `turn lease failed` or `turn skipped`** — whether this peer got a relay. The
262
+ connection proceeds either way, which is why this is worth a mark: without one, peers on the same
263
+ network connect fine and peers behind a symmetric NAT never do. A network that blocks direct
264
+ connections outright is a known gap, and this is what makes it legible in a capture.
265
+ - **`signaling joined`, then `signaling hello`** — the join went to the parent, and the platform
266
+ answered with who this peer is, who the host is and at what epoch. A line that ends at the first
267
+ is a parent not putting frames on the socket, which is a different problem from a connection that
268
+ failed to form.
269
+ - **A joiner** then marks `offer sent`, `answer received`, `credential ready`, `host verified`,
270
+ `channel open` and `admitted`. The answer and the credential race by design, in either order, and
271
+ `host verified` waits on whichever lands second — the first credential request is refused until
272
+ the host has answered somebody, so a multi-second gap before `credential ready` is that refusal
273
+ being retried, not a slow network.
274
+ - **A host** marks `host listening` and stops: it is usable before anyone has arrived. What follows
275
+ — `offer received`, `answer sent`, `channel open`, `peer admitted` — is the first joiner's
276
+ arrival, which may be minutes later and is the only thing in a host's own capture that says
277
+ somebody got through.
278
+
279
+ A line that stops at `host verified` with nothing after it is ICE and DTLS never completing: the
280
+ peers could not reach each other directly, and either no relay was leased or the relay could not
281
+ help. That is the shape a corporate or campus network produces.
282
+
283
+ ## The environment your game actually runs in
284
+
285
+ Facts about the Arcade's runtime that are invisible locally and expensive to discover in
286
+ production:
287
+
288
+ - **A game frame is never cross-origin isolated**, so `performance.now()` is coarsened to about a
289
+ millisecond. Anything that spin-waits on clock transitions multiplies its cost by the coarsening:
290
+ the probe that motivated this list demanded 20,000 transitions, cost 100ms on an isolated page,
291
+ and froze the Arcade's main thread for **twenty seconds** — which blocked delivery of every
292
+ handshake offer and was the root cause of the outage the boot-phase section exists to prevent.
293
+ Naive timer-granularity probes and some engine FPS limiters have this shape.
294
+ Bound such loops by wall-clock time, never by transition count alone.
295
+ - **WebKit persists no compiled-WASM cache.** Your engine recompiles on every load, warm or cold.
296
+ Keep WASM modules small; do not attribute the cost to the network.
297
+ - **The edge serves the whole bundle with revalidation** (`Cache-Control: no-cache` semantics). Warm
298
+ loads still pay a conditional request per asset — fine, but hash your assets so those requests
299
+ can answer 304.
300
+ - **Game origins carry no `script-src` policy**, so build the page the ordinary way: inline
301
+ `<script>`, import maps, a CDN-hosted engine and `new Worker(blobURL)` all run. Worth stating
302
+ because a `script-src` of `'self'` refuses `<script type="importmap">` for **being** inline script
303
+ whatever URLs it names, which leaves an entry module resolving no imports and nothing rendering —
304
+ so a game that assumes one and works around it is working around a rule that is not there. What
305
+ _is_ enforced is `frame-ancestors`, and the containment the whole decision rests on is that **every
306
+ game is served on its own origin**. The SDK's embedder allowlist is the frame's side of that same
307
+ header; [the embedder allowlist](#1-the-embedder-allowlist) above says what it admits and when you
308
+ would add to it.
309
+
310
+ ## Which way the device is held — declare it, do not draw it
311
+
312
+ A phone and a tablet get held two ways round, and the Arcade tells the player when
313
+ they are holding it a way your game cannot use. **That message is the platform's,
314
+ not yours.** Do not ship a full-screen "please rotate your device" screen of your
315
+ own: it will not be translated into the twelve Arcade locales, it cannot pause the
316
+ Arcade's chrome around it, and it will say something different from every other
317
+ game on the storefront.
318
+
319
+ What you do instead is declare it on your store page, per device, both keys:
320
+
321
+ ```yaml
322
+ orientations:
323
+ phone:
324
+ portrait: full
325
+ landscape: none
326
+ tablet:
327
+ portrait: full
328
+ landscape: partial
329
+ ```
330
+
331
+ | Value | What the player gets |
332
+ | --- | --- |
333
+ | `full` | nothing — the game plays this way round |
334
+ | `partial` | a dismissible hint, once, if the other way is `full` |
335
+ | `none` | a panel over the paused game asking them to rotate, with "play anyway" |
336
+
337
+ Three consequences worth knowing before you hit them:
338
+
339
+ - **The panel reaches your game as `onPause(true)`**, exactly like any other Arcade
340
+ dialog, and unpauses when the player rotates or chooses "play anyway". If you
341
+ already honour pause on both edges — [the pause contract](#what-the-parent-says-about-the-session-prefs-pause-and-terminate) — there is no
342
+ code to write for this at all.
343
+ - **"Play anyway" always exists**, so a `none` declaration is not a guarantee your
344
+ game will never be rendered that way. A player whose device is in a mount or whose
345
+ rotation lock is on can decline, and your game still has to not crash.
346
+ - **Declaring nothing means no message**, which is what every game did before this
347
+ existed. It is not a neutral state so much as an invisible one: a portrait-only
348
+ game with no declaration reads, to a player, exactly like a game that plays fine
349
+ sideways.
350
+
351
+ The Arcade also holds the orientation for the player where the browser allows it —
352
+ one refused orientation, in fullscreen, once they are already the right way round.
353
+ That is best effort (iOS does not implement it, and the native app's webview declines
354
+ it), which is why the panel exists and why it always offers a way past.
355
+
356
+ ## A television, if a player puts you on one
357
+
358
+ The Arcade runs in TV browsers and on TV hardware, so a game can find itself ten feet from its
359
+ player, driven by a remote — and **nothing tells you that it happened**. No form-factor signal
360
+ crosses the bridge; that is an owner decision, not an oversight. Form factor is fungible, so a game
361
+ does the best it can from its own signals — its viewport geometry, `devicePixelRatio`, pointer
362
+ coarseness, and the arrival of gamepad or keyboard-class input — rather than branch on a
363
+ classification the parent declared. The bar below is written to be satisfiable with no parent signal
364
+ at all.
365
+
366
+ It is also the meaning of your store page's **`form_factors.tv`** declaration: `full` claims this
367
+ bar, `partial` claims it with limitations the page says out loud, and `none` says the game is not
368
+ for a television. `form_factors` declares all four of `phone` / `tablet` / `desktop` / `tv`,
369
+ because which devices a game is built for cannot be inferred from anything else you publish. There
370
+ is no floor: `none` is a perfectly good answer, and a deliberate `none` is far better for a player
371
+ than a `full` you did not earn.
372
+
373
+ **Adding it is one block, on any store page you already have.** Every schema version accepts
374
+ `form_factors`; the newest one requires it, which is the whole difference between v2 and v3. So
375
+ there is nothing to migrate and no `schema_version` to change — `gemctl store init` writes that
376
+ field, and it is a fact about the backend you publish to rather than a statement about your game.
377
+
378
+ A page that has not declared it is served as `none` on every factor. That shows **no device
379
+ badges** — it is the platform declining to claim on your behalf, not a statement that your game
380
+ works nowhere — so there is no rush and nothing degrades. It does mean a TV-ready game gets no
381
+ credit for it until you say so.
382
+
383
+ - **Legibility at ten feet.** Effective body and HUD text at least ~24px at 1080p, and nothing
384
+ informational below ~18px effective. The practical shape is a UI driven by rem or a single scale
385
+ factor, so one root multiplier — 1.5x is the number the Arcade's own TV mode uses — rescales the
386
+ whole interface instead of needing a per-screen audit. A 14px HUD that reads fine on a monitor is
387
+ decoration from a couch, and there is no zoom to rescue it.
388
+ - **Gamepad-complete menus, with a focus state visible at distance.** Every menu, pause screen,
389
+ inventory and dialog operable on the standard gamepad mapping with the pointer unused, and a focus
390
+ cue thicker than a hairline — a 1px outline disappears at ten feet. TV remotes arrive as
391
+ **keyboard-class events** (arrows, Enter, Back), so a game that calls itself a keyboard game is a
392
+ D-pad game on a TV whether it planned to be or not.
393
+ - **Title-safe layout.** HUD and critical UI inset at least ~5% of the frame — roughly 48–96px at
394
+ 1080p. `env(safe-area-inset-*)` resolves to **0 on every TV**: overscan is a hardware crop the
395
+ browser cannot see, so no platform can hand you the number and your game must apply its own
396
+ margin. The arcade's safe-area insets (`gem.viewport`, below) are **also 0 on a TV**, by
397
+ design — they carry the cutouts the browser can see, never the overscan allowance, so they do
398
+ not double with the margin you apply here. Keep both: `max(your margin, var(--gem-safe-top))`.
399
+ - **No hover-only affordances, and no pointer-precision dependencies.** There may be no pointer at
400
+ all. Anything that only reveals on hover, or demands accurate pointing, is simply absent on a TV.
401
+ - **Text entry the on-screen keyboard can serve.** Focusing a field summons the TV's OSK, so keep
402
+ entry fields few and ordinary — and never gate first play behind one. A player made to type on a
403
+ remote before they may play mostly does not.
404
+ - **A TV performance budget.** TV-browser SoCs are weak next to any desktop: ship a player-settable
405
+ render-scale option (render at DPR 1 and upscale), bound memory, assume a fixed 16:9 frame — there
406
+ is no `orientationchange` rescue coming — and keep your heights `dvh`-safe.
407
+ - **Reduced motion.** TV browsers rarely expose `prefers-reduced-motion`, so the pushed
408
+ `gem.prefs.reducedMotion` is the only signal a TV player has; honor it. It arrives with
409
+ [the prefs the parent pushes](#what-the-parent-says-about-the-session-prefs-pause-and-terminate)
410
+ below.
411
+
412
+ ## What the parent says about the session: prefs, pause and terminate
413
+
414
+ Five messages arrive that a game cannot ask for — the parent pushes them on its own schedule, and
415
+ there is no route by which the frame could request one. `createGem` forwards all five:
416
+
417
+ - **`onPrefs`** — `reducedMotion`, `muted`, `locale`, `showFrameStats`, sent once after the
418
+ handshake and again on every change. Also **held**, as `gem.prefs`, because a component mounted
419
+ after the push would otherwise have missed it and cannot ask again. Read it where you need it
420
+ rather than caching it — the locale changes mid-session. `muted` is the Arcade's mute: the SDK
421
+ silences your page itself, and your own mute control follows it — see
422
+ [sound](#sound-your-own-mute-control-and-the-arcades) below. `showFrameStats` you need not act
423
+ on: the SDK counts your frames itself — see [the Arcade's FPS overlay](#the-arcades-fps-overlay). See the language section for how to combine
424
+ `locale` with your own detection.
425
+ - **`onPause`** — **pause gameplay and release the cursor.** Cooperative; nothing enforces it.
426
+
427
+ ```js
428
+ onPause: (paused) => {
429
+ if (paused) document.exitPointerLock();
430
+ // ...stop your loop
431
+ };
432
+ ```
433
+
434
+ Releasing the cursor is yours to do: pointer lock belongs to the document that took it, so the
435
+ arcade cannot release yours from outside the frame. Keep it captured and the player cannot click
436
+ the dialog sitting on top of your game.
437
+
438
+ **Save on pause.** When the player taps the Arcade's Back tab, your game is **parked**, not closed:
439
+ it receives `onPause(true)` and stays loaded while the player browses. They may come back and
440
+ press Continue — `onPause(false)`, and play resumes where it stopped — or the parked game may be
441
+ ended up to five minutes later. That end usually comes with `onWillTerminate` first, **but not
442
+ always**: a player who leaves the Arcade page while your game is parked takes it down with
443
+ nothing after the pause. So the pause edge is your last guaranteed moment. Write anything the
444
+ player would mind losing there, and never hold it back for a terminate warning that may not come.
445
+
446
+ - **`onWillTerminate`** — persist now. `graceMs` is a promise about how long the parent will wait.
447
+ - **`onViewport`** — where your frame's own edges are under a cutout. **Most games need no
448
+ handler**: the SDK has already written the same numbers to `--gem-safe-top` and friends on your
449
+ document root before this fires. Held as `gem.viewport`. The next section is the whole story.
450
+ - **`onDeviceSettings`** — the settings the Arcade keeps for your game **on this device**, or
451
+ `null` when it keeps none. Sent once per session. Held as `gem.deviceSettings.current`, and
452
+ written back with `gem.deviceSettings.put`. [Device settings](#device-settings-what-this-device-remembers-about-your-game)
453
+ below says what belongs there and, more importantly, what does not.
454
+
455
+ Neither `onPause` nor `onWillTerminate` touches a match. A match whose link dropped while the phone was locked
456
+ comes back through the SDK's own reconnect on the next visible edge, and one that ended meanwhile
457
+ raises `matchEnded` on the same edge — [match-session.md](match-session.md) §7. Pause the loop
458
+ on `onPause`; do not leave the room.
459
+
460
+ 🔴 **The assembly used to drop all three.** `GemFrame` parsed them and `createGem` wired none, so a
461
+ game built the one-call way could not hear them — and could not add a handler afterwards either,
462
+ because the frame's callbacks are fixed at construction and `gem.frame` hands back the one already
463
+ built. A game that wanted them had to assemble the parts itself.
464
+
465
+ ✅ **The Arcade now sends all three.** `gem.prefs` on connect and on every change,
466
+ `gem.pause` on tab visibility and when the player taps Back — both edges, and on connect into an
467
+ already-backgrounded tab — and `gem.willTerminate` before a teardown the page itself decides,
468
+ followed by an actual wait for the grace it promised — including the end of a **parked** game (the
469
+ player tapped Back) when its five minutes run out. Some endings get no warning. A player who leaves
470
+ the Arcade page while your game is parked ends it with nothing after the pause it already had —
471
+ save on that pause, as above. And browser Back, a closing tab and a reload cannot be warned about
472
+ at all: the page hears about them only once the frame is already going, and a port message posted
473
+ then is queued into a document that is destroyed before it can be delivered.
474
+
475
+ ⚠️ **Other embedders may still send none of them.** The native Arcade app has no preferences sender
476
+ today, and a third-party embedder need not have one either. So these handlers must be correct when
477
+ they never fire: a game that blocks on `gem.prefs` renders nothing on those surfaces. Treat every one
478
+ of the three as an upgrade to behaviour that already works without it.
479
+
480
+ ## Where your edges are unsafe: the arcade's safe-area insets
481
+
482
+ A phone has a camera cutout at the top and a home indicator at the bottom, and inside the arcade
483
+ your frame may or may not start where the phone's screen does: on a phone your game fills the
484
+ screen, edge to edge, while on a tablet or a desktop it sits inside the arcade's page. The arcade
485
+ also floats a small control of its own over your game — see
486
+ [reserved areas](#the-arcades-own-controls-reserved-areas) below. The question "how far from my top
487
+ edge is it safe to draw?" has a different answer in each of those layouts, and **your frame cannot
488
+ find it out from the browser.**
489
+
490
+ `env(safe-area-inset-*)` inside an iframe is unspecified and wrong in both directions
491
+ ([w3c/csswg-drafts#4670](https://github.com/w3c/csswg-drafts/issues/4670)): Chrome resolves it to
492
+ **0** in an embedded document, and WebKit hands the frame **the whole phone's values**, unadjusted
493
+ for where the frame sits. So a HUD padded by `env(safe-area-inset-top)` lands under the cutout on
494
+ Android and, wherever the arcade's page already cleared the notch above your frame, sits **twice**
495
+ as far from it as it should. Every game that followed the old advice had exactly this bug, and none
496
+ could fix it locally.
497
+
498
+ **The arcade measures your frame and tells you.** `gem.viewport` arrives once after the handshake
499
+ and again on every change — a rotation, a fullscreen transition, the controls hiding or showing —
500
+ with four integers in CSS pixels, measured from **your frame's** edges:
501
+
502
+ ```json
503
+ { "insets": { "top": 0, "right": 0, "bottom": 34, "left": 0 } }
504
+ ```
505
+
506
+ **The SDK writes them to your document root as custom properties before anything else hears
507
+ them**, so the whole migration for a stylesheet is one declaration per edge:
508
+
509
+ ```css
510
+ /* before */
511
+ #hud { top: env(safe-area-inset-top, 0px); }
512
+
513
+ /* after — right in the arcade, and unchanged everywhere else */
514
+ #hud { top: var(--gem-safe-top, env(safe-area-inset-top, 0px)); }
515
+ ```
516
+
517
+ The `env()` fallback is not decoration. Under a parent that has not sent the message — an older
518
+ arcade, a third-party embedder, your game opened in a plain tab during development — the
519
+ properties are never set and the browser's own answer stands, which is what your game did before.
520
+ Under a parent that has, the variable wins. One line, correct in both worlds. The four names are
521
+ `--gem-safe-top`, `--gem-safe-right`, `--gem-safe-bottom` and `--gem-safe-left`.
522
+
523
+ **For a HUD you position from script** — a canvas you draw into, an engine's own UI layer — read
524
+ `gem.viewport.insets` at the moment you lay out, or take `onViewport` to re-lay out when it
525
+ changes. It is `null` until the arcade has said, and stays `null` under an embedder that never
526
+ does; treat that as "use `env()`", exactly as the stylesheet fallback does. Do not wait for it
527
+ before drawing the first frame.
528
+
529
+ **What it is not.** It is not the TV overscan margin — `env()` is 0 on every television and so is
530
+ this; the title-safe margin in the section above is still yours to apply, and the two compose with
531
+ `max()`. It carries no form factor, no orientation and nothing about what the arcade's chrome is
532
+ doing: you learn that part of your box is unusable, not why. And it does not move anything — where
533
+ your HUD sits is still your business; this is the arcade telling you where the edge is, not
534
+ drawing a border.
535
+
536
+ **Never read `env(safe-area-inset-*)` on its own in a game that runs in the arcade.** Wrap it in
537
+ the `var()` above, or read `gem.viewport`. The release checklist looks for the bare form.
538
+
539
+ ### The arcade's own controls: reserved areas
540
+
541
+ On a phone your game is full-bleed, and the arcade keeps one cluster of its own on top of it, in the
542
+ **top-left corner** of your frame: a small **Back** tab — the Gem Arcade wordmark — and, beside it,
543
+ the player's wallets (Gems, Tickets, their achievement score) while they are
544
+ [shown](#the-arcades-wallet-show-it-never-draw-it). The cluster stays in the top-left and runs along
545
+ the top row at most about three quarters of the way across on the narrowest phone — but do not
546
+ hard-code a size: read it, as below. Those rectangles of your frame are **reserved**: keep essential
547
+ UI and every touch target out of them.
548
+
549
+ **The arcade never takes the right side.** Put your own settings, pause or menu button **top-right**
550
+ — that corner is yours. And **do not put a back arrow of your own top-left**: it would sit under the
551
+ arcade's Back tab, which leaves the game, while yours would mean something else.
552
+
553
+ An inset cannot say that — as a top inset, a tab a few dozen pixels wide would take your whole top
554
+ strip. So `gem.viewport` also carries the rectangles, in the same CSS pixels of your frame, measured
555
+ from its top-left corner:
556
+
557
+ ```json
558
+ {
559
+ "insets": { "top": 47, "right": 0, "bottom": 34, "left": 0 },
560
+ "reserved": [{ "x": 8, "y": 51, "width": 104, "height": 34 }]
561
+ }
562
+ ```
563
+
564
+ **The SDK turns them into one pair of custom properties per corner**, written beside
565
+ `--gem-safe-*` before anything else hears the push: `--gem-reserved-tl-width` and
566
+ `--gem-reserved-tl-height`, and the same pair for `tr`, `bl` and `br` — how far that corner's
567
+ reserved rectangles reach in from its two edges. Push the corner's UI clear of it:
568
+
569
+ ```css
570
+ /* a score in the top-left corner, below the cluster */
571
+ #score {
572
+ top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tl-height, 0px));
573
+ left: var(--gem-safe-left, 0px);
574
+ }
575
+ /* or a row along the top, starting after it */
576
+ #top-bar { padding-left: var(--gem-reserved-tl-width, 0px); }
577
+ ```
578
+
579
+ All eight are written on every push, `0px` for a clear corner, so a rotation never leaves a stale
580
+ value; they are never set under an embedder that sends no viewport, which is what the `0px`
581
+ fallback is for. A rectangle belongs to the corner its centre lies nearest to. For a HUD laid out
582
+ from script, read `gem.viewport.reserved` — `[]` when nothing is covered.
583
+
584
+ **Lay out against it whenever it changes.** Shown wallets widen the reserved area beside the tab;
585
+ hiding them shrinks it back to the tab alone. A wallet the arcade pops up for a moment while they
586
+ are hidden never reserves anything — it floats over your game and moves nothing.
587
+
588
+ **Older SDKs get it folded into the top inset.** A game on an SDK from before reserved areas cannot
589
+ read the rectangles, so the arcade sends it none and instead raises `insets.top` — and so
590
+ `--gem-safe-top` — past the bottom of the tab. That game keeps working, at the cost of a full-width
591
+ band; upgrading gives the band back.
592
+
593
+ ## Which store-page experiment brought this player in: cohorts
594
+
595
+ **You need this only if you run an A/B experiment on your store page and want the game to match
596
+ it.** The Arcade can show two versions of a game's store page — a different title, tagline or key
597
+ art — and split new players between them. The game inside never sees the page, so a "cozy farm"
598
+ page can lead straight into a first run that opens on a boss fight. `gem.cohorts` is how the game
599
+ learns which page this player came through.
600
+
601
+ It is a fourth message the parent pushes and the frame cannot ask for, and it follows the same rules
602
+ as the three above. `createGem` forwards it as `onCohorts` and **holds** it as `gem.cohorts`;
603
+ `gem.awaitCohorts()` waits for it, with a bound.
604
+
605
+ ```js
606
+ const gem = createGem({
607
+ onCohorts: (cohorts) => {
608
+ /* on connect, and again if it changes */
609
+ },
610
+ });
611
+
612
+ // At the one point you branch — the first run's opening. Short budget.
613
+ const cohorts = await gem.awaitCohorts(1_500); // GemCohorts | null
614
+ const variant = cohorts?.find((c) => c.experimentId === COZY_EXPERIMENT)?.variantKey;
615
+ startFirstRun(variant === 'cozy' ? 'orchard' : 'arena');
616
+ ```
617
+
618
+ What the value means, and the two absences that are not the same thing:
619
+
620
+ - **Each entry is `{ experimentId, variantKey }`, and both are opaque.** The id is the one the
621
+ store-page tooling showed you when you created the experiment; the key is the variant name you gave
622
+ it (`control`, `cozy`). Compare against the ones you set up, and nothing else — the message does
623
+ not say how the split was made, what the other variants are, or which one is winning.
624
+ - **An empty array is an answer: this player is in no experiment.** The Arcade sends
625
+ `{ cohorts: [] }` on connect whenever that is so.
626
+ - **`null` is not an answer.** `gem.cohorts` is `null` until the message arrives, and stays `null`
627
+ under an embedder that never sends one. `awaitCohorts()` resolves `null` rather than rejecting,
628
+ exactly as `awaitPlayer()` does.
629
+ - **The list converges to empty.** When you conclude an experiment its entry stops arriving — a
630
+ returning player sees `[]` next session, whichever variant they were in. If the variant decided
631
+ something durable, record it yourself at the time (a save, a setting) rather than reading the cohort
632
+ again later and expecting the same answer.
633
+
634
+ ⚠️ **Never block your first frame on it.** Other embedders may send none of these messages, and a
635
+ game that waits for `gem.cohorts` before rendering renders nothing there. Branch at the one point your
636
+ game differs, with a short budget, and let `null` take the default path — a player who missed the
637
+ experiment sees a game, and a player who was in it sees the experiment. **The default branch must be
638
+ a complete game**, because it is what every player outside the experiment plays.
639
+
640
+ ## Device settings: what this device remembers about your game
641
+
642
+ **You need this only if your game has settings that are about the device rather than the player** —
643
+ a volume, a graphics quality, a resolution scale, a dismissed tip. Your game cannot keep those in
644
+ its own browser storage across builds: every build of a web game runs on its own origin, so a value
645
+ in `localStorage` is gone the moment you publish again. The Arcade keeps one small object for your
646
+ game on the device instead, and the bridge carries it both ways.
647
+
648
+ It is a fifth message the parent pushes and the frame cannot ask for, plus one the frame sends.
649
+ `createGem` forwards the push as `onDeviceSettings`, **holds** it as `gem.deviceSettings.current`,
650
+ waits for it with `gem.deviceSettings.wait()`, and writes with `gem.deviceSettings.put()`.
651
+
652
+ ```js
653
+ // On boot. Short budget; `null` means "use your defaults" and is the common case.
654
+ const stored = (await gem.deviceSettings.wait(1_500)) ?? {};
655
+ audio.setMasterVolume(stored.volume ?? 1);
656
+
657
+ // When the player moves the slider: the WHOLE object, every time, debounced.
658
+ await gem.deviceSettings.put({ v: 1, volume: 0.4, quality: 'high' });
659
+ ```
660
+
661
+ What the value means, and what it is not:
662
+
663
+ - **It is a plain JSON object of your own design**, read back after one JSON round trip. Version it
664
+ (`v: 1`) so a newer build reads what an older one wrote, and treat any shape as possible on read.
665
+ - **`null` means "nothing stored" and "the embedder never said" alike.** Both take your defaults.
666
+ `wait()` resolves at once on the first and only at its deadline on the second, and never
667
+ rejects — like `awaitCohorts()`.
668
+ - **You never name the game or the channel.** The Arcade files the object by the game and channel
669
+ it is running, so a `dev` build's settings never reach the `release` build of the same game.
670
+ - **`put` resolves for every outcome** — `stored`, `too_large` (over 16 KiB; nothing stored), or
671
+ `unavailable` (the Arcade could not store it just now; nothing stored). None is an error to a
672
+ game. Keep running on what you hold.
673
+
674
+ ⚠️ **This is not a save, and progression must never go here.** It does not follow the player to
675
+ another device or browser, every account on the device shares it, and it can vanish at any time —
676
+ cleared site data, a private window, the Arcade evicting it. Anything a player would mind losing,
677
+ and anything that must follow them, is a save or `gem.me.settings`:
678
+ [saving-player-progress.md](saving-player-progress.md#device-settings-local-to-this-device-and-only-that)
679
+ draws the line in full. Never block your first frame on it either: apply defaults, then apply what
680
+ arrives.
681
+
682
+ ## Sound: your own mute control, and the Arcade's
683
+
684
+ **Every game needs its own mute control**, in its settings or its pause menu, because on a phone
685
+ the Arcade shows **no** mute button: your game fills the screen and the Arcade's only control over
686
+ it is the Back tab. A player on a phone who wants silence has your control and nothing else.
687
+
688
+ Wire it both ways, so the player has one mute rather than two that disagree:
689
+
690
+ ```js
691
+ const gem = createGem({
692
+ // ...
693
+ // The Arcade's mute changed (desktop, tablet, TV): follow it.
694
+ onPrefs: (prefs) => sound.setMuted(prefs.muted),
695
+ });
696
+
697
+ // Tell the Arcade what you saved, and every change the player makes.
698
+ gem.reportMuted(sound.muted);
699
+ muteButton.onclick = () => {
700
+ sound.setMuted(!sound.muted);
701
+ gem.reportMuted(sound.muted);
702
+ };
703
+ ```
704
+
705
+ `gem.reportMuted` is safe to call at any time. Before the connection exists the value is held and
706
+ sent when the Arcade connects; an embedder with no mute button is simply not told; and calling it from
707
+ `onPrefs` is fine, because a report that matches what the Arcade just said is not sent.
708
+
709
+ **Do both halves.** A game that reports its mute but ignores `prefs.muted` leaves the Arcade's
710
+ button saying one thing while the game does the other; a game that follows `prefs.muted` but never
711
+ reports leaves the Arcade's button stale after the player uses yours.
712
+
713
+ ### The Arcade's mute button, where there is one
714
+
715
+ On a desktop, a tablet and a television the Arcade does keep a mute button beside your game, for
716
+ games whose SDK can honour it — an SDK that silences your game says so when it connects, and a game
717
+ built on an older one gets no button rather than one that does nothing. When the player presses it,
718
+ `gem.prefs.muted` arrives as `true` and the SDK silences your page itself — every Web Audio graph and
719
+ every `<audio>`/`<video>` element — then gives the sound back when they press it again. With the two
720
+ calls above, the Arcade's button and yours stay in step whichever the player uses.
721
+
722
+ - **It turns the volume down; it does not stop your clock.** Your `AudioContext` keeps running, so
723
+ anything scheduled against `currentTime` stays in time. Do not answer a mute with `ctx.suspend()` in
724
+ your own code, for the same reason.
725
+ - **It leaves your own settings alone.** Your own volume is exactly where you left it when the
726
+ Arcade unmutes: the SDK sits after everything you built.
727
+ - **Create your audio after `createGem`.** The SDK reaches sound connected to the speakers after it
728
+ starts, and nothing earlier. The boot order on this page already does that; an `AudioContext` made
729
+ in a module that runs before `createGem` is out of reach.
730
+
731
+ ### Starting silent: your game may start before the player touches it
732
+
733
+ On a desktop the Arcade may start your game on its own information page, before the player has
734
+ clicked anything in it. Browsers keep a frame's audio off until the player interacts with that
735
+ frame, so **your game must cope with starting silent**: an `AudioContext` created then is
736
+ `suspended`, and a `play()` on a media element rejects.
737
+
738
+ ```js
739
+ const resume = () => {
740
+ if (ctx.state === 'suspended') void ctx.resume();
741
+ };
742
+ window.addEventListener('pointerdown', resume);
743
+ window.addEventListener('keydown', resume);
744
+ ```
745
+
746
+ Resume on the first input and carry on — never block the first frame on audio, never show an error
747
+ for it, and do not ask the player to "click to start" just to unlock sound.
748
+
749
+ ### Doing it yourself instead
750
+
751
+ `createGem({ manageAudio: false })` turns the SDK's silencing off — for a game that would rather fade
752
+ its music out than cut it, say. The Arcade still shows its button for your game where it shows one,
753
+ so this is a promise: your `onPrefs` handler must go quiet on `muted: true` and come back on `false`,
754
+ and the release checklist checks that it does.
755
+
756
+ ## The Arcade's wallet: show it, never draw it
757
+
758
+ The player's platform balances — Gems, Tickets (`ARCADE_TICKETS`) and their achievement score —
759
+ belong to the Arcade. **Your game never draws one**, and never shows a pending state for one: the
760
+ Arcade takes every figure from the platform and draws the wallets itself, beside its Back tab.
761
+
762
+ **They are shown unless your game hides them.** A game that does nothing has every wallet on show
763
+ the whole time. The one rule: **hide them during gameplay, show them on your menus.**
764
+
765
+ ```js
766
+ level.onStart = () => gem.setWalletVisible(false);
767
+ pauseMenu.onOpen = () => gem.setWalletVisible(true);
768
+ ```
769
+
770
+ - **While hidden, the Arcade still pops a wallet up** for a moment when something happens to it —
771
+ a reward you cued with `gem.cueReward`, a spend, a landing, a daily-maximum note, a score change
772
+ — then hides it again. Your state never cancels that, so a reward mid-level still shows.
773
+ - **It is screen state, not a toggle.** Each call is the whole answer and replaces the last. Call it
774
+ as often as you like: before the Arcade connects the state is held and sent when it does, and
775
+ again after any reconnect; a repeat of the current state is not re-sent.
776
+ - **Shown wallets take space; a pop does not.** Shown, they widen the
777
+ [reserved area](#the-arcades-own-controls-reserved-areas) beside the Back tab, so lay your menu
778
+ out against `--gem-reserved-tl-*`; hidden, it shrinks back to the tab. A pop floats over your game
779
+ and never moves its layout.
780
+ - **Narrowing is optional.** On a screen that sells in one currency — a ticket store — you may show
781
+ only that one: `gem.setWalletVisible(true, { only: ['ARCADE_TICKETS'] })`, with `'score'` for the
782
+ achievement score. Use it only there; leaving it out shows every wallet, which is always safe.
783
+ - **Where the Arcade docks its wallets beside your frame** — a desktop page — they are always on
784
+ show, and this changes nothing. Call it anyway; the same code is right everywhere.
785
+ - **`false` means nobody is listening** — no connection yet, or an embedder that draws no wallet.
786
+ Do nothing in its place.
787
+
788
+ ## The Arcade's FPS overlay
789
+
790
+ The Arcade has a frame-rate toggle beside your game, and **you do not have to write anything for it
791
+ to work**. When a player switches it on, `gem.prefs` arrives with `showFrameStats: true`, the SDK
792
+ starts counting the frames the browser delivers to your
793
+ document, and about once a second it reports the rate and the 95th-percentile frame time to the
794
+ Arcade, which draws a small readout in its own chrome. **Your game draws nothing.**
795
+
796
+ - **It costs you nothing until a player asks.** No `requestAnimationFrame` is registered and nothing
797
+ is sent until `showFrameStats` arrives `true`; the counter stops when it goes `false`, while the
798
+ Arcade has your game paused (`onPause`), and when the bridge closes. A game whose player never
799
+ presses the toggle never runs any of it.
800
+ - **What the number measures.** The `requestAnimationFrame` ticks the browser delivers to your
801
+ game's document. That is exactly your render rate if you render on that callback — three.js,
802
+ Phaser and Babylon.js do, and so does any engine's web export — and it is measured inside your
803
+ frame on purpose:
804
+ the Arcade page cannot count your frames from outside, because a cross-origin iframe renders on
805
+ its own schedule, and a page-side counter would read a steady 60 over a game hitching at 25.
806
+ - **If you render in a worker.** A game that draws through an `OffscreenCanvas` in a worker is
807
+ timed on its main thread here, which is not its renderer. There is no hook yet for such a game to
808
+ report its own figure; if you need one, ask.
809
+ - **Do not draw an FPS counter of your own over the Arcade's.** Two readouts that disagree — and
810
+ they will, since they count different things — teach a player that one of them is lying. A debug
811
+ overlay in your own builds is fine; keep it off in an Arcade build, or at least off by default.
812
+ - **The toggle appears only for games that support it.** An SDK that can count says so when it
813
+ connects, and a game built on an older one gets no toggle rather than a readout that never moves.
814
+ Upgrading the SDK is the whole of what a game has to do.
815
+
816
+ `showFrameStats` is also on `gem.prefs` for you to read, held like the rest, but the only reason to
817
+ read it is to know the Arcade is already showing one.
818
+
819
+ ## The player's language: prefer what the page sends, detect the rest
820
+
821
+ **`gem.prefs.locale` is the language the Arcade page is RENDERING in.** That is the one fact a frame
822
+ cannot obtain for itself — `<html lang>` inside the frame answers for what your game shipped, and
823
+ nothing in a cross-origin iframe can see the parent's state. Honour it and your strings match the
824
+ page around them; ignore it and a player reading the Arcade in Japanese clicks Play and lands in
825
+ English.
826
+
827
+ **It is not the only source, and it is not always the best one.** `navigator.languages` is readable
828
+ inside a cross-origin iframe exactly as it is on a top-level page, and the Arcade's value is
829
+ **clamped to the dozen languages the Arcade itself ships** — so a game that has a Dutch bundle would
830
+ be told `en`, because the Arcade has no Dutch. Prefer the page's locale when you ship that language;
831
+ otherwise fall back to your own detection.
832
+
833
+ ```js
834
+ const supported = ['en', 'fr', 'ja']; // what YOU ship
835
+ const wanted = gem.prefs?.locale; // what the page is rendering in
836
+ const lng = wanted && supported.includes(wanted) ? wanted : undefined;
837
+ // `undefined` -> let your detector read navigator.languages
838
+ ```
839
+
840
+ **Do not wait for it.** `gem.prefs` is parent-initiated and not every embedder sends one — the native
841
+ Arcade app does not today. A game whose string loading blocks on it renders nothing on those
842
+ surfaces. Start from detection, and re-render when the message arrives.
843
+
844
+ Whichever path you take, set **`supportedLngs`** to the languages you actually ship and
845
+ **`fallbackLng`** to your default. Detection on its own hands you `en-GB`, which matches no bundle if
846
+ you shipped `en` — the single most common locale bug in shipped games, and the reason this sentence
847
+ is here rather than left as an exercise. Do **not** reach for `nonExplicitSupportedLngs` or
848
+ `load: 'languageOnly'` to fix it: measured against i18next 26, both collapse every region- or
849
+ script-qualified locale you ship (`pt-BR`, `zh-Hans`) to the fallback, because they test membership
850
+ on the language part alone. `supportedLngs` already resolves `en-GB` to `en`.
851
+
852
+ ## Showing a player: one identity on every surface
853
+
854
+ Every surface that shows a player hands you **the same fields, under the same names**: the signed-in
855
+ player (`gem.player`), room members, leaderboard rows, friends, profiles, showcase rows — and an
856
+ async opponent you read through the raw client, once you pass the row through `mapIdentity`. One
857
+ renderer serves all of them.
858
+
859
+ | Field | What it is | Type |
860
+ | ----- | ---------- | ---- |
861
+ | `playerId` | The player's id. **Absent on an anonymous row** | `string` |
862
+ | `displayName` | The name the player chose | `PlayerText` |
863
+ | `discriminator` | The digits that tell two same-named players apart — `0001`, no `#` | `string` |
864
+ | `displayTag` | The precomposed `Name#0001` | `PlayerText` |
865
+ | `avatarIconId` | The portrait item's id | `string` |
866
+ | `avatarIconUrl` | The portrait's thumbnail | `string` |
867
+ | `badges` | Equipped badges in display order, each `{ itemId, iconUrl }` — always a list, empty for now | array |
868
+
869
+ **The name and the tag are player-authored, so both are `PlayerText`** on every surface, not only
870
+ some. Convert with `text.toPlain()` where you render, never before.
871
+
872
+ **Show the discriminator beside the name, and never drop it to make a name fit.** Two players may
873
+ choose the same name, and the digits are what keeps one from passing as the other. Truncate the
874
+ name, not the digits.
875
+
876
+ **A row with no `playerId` is an anonymous player** — erased, or on a board that shows no players.
877
+ It keeps its rank, so render it as "Anonymous player" rather than skipping it.
878
+
879
+ **A row with a `playerId` but no name** is a player who has not chosen one, or one whose identity
880
+ is withheld from you (a player who blocked you, say). The id stays because it keys a seat, a
881
+ friendship or an opponent you already hold; the name, tag and portrait are absent. Render it as
882
+ "Player", and never fall back to showing the id.
883
+
884
+ **Put an icon URL through `platformIconUrl` before it reaches an `<img>`.** Loading an image is a
885
+ request that carries your page's address and the moment it happened, so an icon from anywhere but
886
+ the platform's own image host would be a beacon on your player. `platformIconUrl` answers the URL
887
+ only when the platform serves it, and `undefined` otherwise — draw your placeholder, as for a
888
+ player with no portrait.
889
+
890
+ ```js
891
+ import { mapIdentity, platformIconUrl, text } from '@indiegems/gem-web-sdk/sdk';
892
+
893
+ function renderPlayer(el, who) {
894
+ const name = el.querySelector('.name');
895
+ const digits = el.querySelector('.digits');
896
+ name.textContent = who.displayName ? text.toPlain(who.displayName) : 'Anonymous player';
897
+ digits.textContent = who.discriminator ? `#${who.discriminator}` : '';
898
+ const src = platformIconUrl(who.avatarIconUrl);
899
+ if (src) el.querySelector('img').src = src;
900
+ }
901
+
902
+ for (const member of gem.rooms.currentRoom?.members ?? []) renderPlayer(rowFor(member), member);
903
+ for (const row of opponents) renderPlayer(rowFor(row), mapIdentity(row)); // a raw-client row
904
+ ```
905
+
906
+ **What changed, if your game was written before these fields.** `gem.player.displayTag` used to be
907
+ the discriminator digits and is now the whole `Name#0001` as `PlayerText`, the same as a room
908
+ member's; the digits are `gem.player.discriminator`. The names on friends, leaderboard, profile
909
+ and showcase rows used to be plain strings and are now `PlayerText`, as they already were on room
910
+ members. `gem.player.displayHandle` and `gem.player.avatarUrl` still work, and are
911
+ [deprecated](deprecation-policy.md) in favour of `displayTag` and `avatarIconUrl`.
912
+
913
+ ### The signed-in player's Boost: show it, never gate on it
914
+
915
+ `gem.player` also carries **`boost`** — the player's Boost, the Arcade's one real-money product.
916
+ While it lasts the player earns Gems and Tickets faster (never achievement points), and the
917
+ platform raises their earning limits to match. The platform does all of that on its own side when
918
+ it pays; your game does nothing to make a boost work.
919
+
920
+ | Field | What it is | Type |
921
+ | ----- | ---------- | ---- |
922
+ | `boost.active` | Whether any currency earns faster than 1× right now | `boolean` |
923
+ | `boost.multipliers` | One `{ currencyId, currencySlug, multiplier }` per boostable currency — `multiplier` is `2` for 2×, `1` when not boosted; a currency not listed is 1× | array |
924
+ | `boost.expiresInDays` | Whole days until the soonest boosted currency drops back, rounded up and **capped at 7** — `7` means "7 or more"; absent when nothing is boosted | `number` |
925
+
926
+ `boost` is always present on `gem.player`. When the Arcade has not said — an older Arcade, a
927
+ backend without boosts, a read that has not landed yet — it reads as **not boosted**, exactly like a
928
+ player who has no boost.
929
+
930
+ ```js
931
+ const tickets = gem.player?.boost.multipliers.find((m) => m.currencySlug === 'ARCADE_TICKETS');
932
+ if (tickets && tickets.multiplier > 1) rewardScreen.showBadge(`${tickets.multiplier}× Tickets`);
933
+ ```
934
+
935
+ ⚠️ **It is for display, and only for display** — a "2×" badge on a reward screen. Never gate
936
+ content, difficulty, matchmaking, prices or power on it: it is advisory, it may lag (it is pushed on
937
+ connect and when it changes, not on the instant a boost starts or ends), and it can be forged inside
938
+ your own page. It carries a day count and never a date, and nothing about what the player paid.
939
+ This is a platform rule, not advice — see
940
+ [platform rules §8](platform-rules.md#8-a-players-boost-is-for-display-never-a-gate).
941
+
942
+ ## Which backend — no longer yours, and what the override is for
943
+
944
+ The Arcade names the API root on every `gem.token`, and the SDK sends there. Your game never types
945
+ an API address and never has to know which one it is running against; if the token says nothing, the
946
+ SDK uses `https://api.gemarcade.com`.
947
+
948
+ Two things the SDK refuses rather than working around, because both are silent when they go wrong:
949
+
950
+ - **An address it cannot use** — one carrying a query string, a fragment, credentials, or plaintext
951
+ `http` anywhere but your own machine. The whole token is refused and the reason reaches `onError`.
952
+ - **An address that changes mid-session.** The first token of a session fixes where requests go, and
953
+ a later one naming somewhere else is refused. Nothing legitimate moves it, so a game that ever
954
+ sees this has an embedder problem rather than a game problem.
955
+
956
+ `baseUrl` survives as a **developer override** for hosts the Arcade cannot name — a tunnel, a staging
957
+ rig, a LAN address a phone can reach. It pins the transport, and what the token says stops moving it
958
+ (the token's value is still checked, and a bad one still refuses the token). Do not use it to choose
959
+ a backend: that is the Arcade's job, and a hand-configured value is how a game ships pointing at the
960
+ wrong one and reads the result as authentication failures.
961
+
962
+ ## Saving a player's progress — if your game has any
963
+
964
+ Games do not need saves, so this is not on the list above. It is a capability, and it has a page
965
+ of its own: [`saving-player-progress.md`](saving-player-progress.md).
966
+
967
+ Read it if your game has progress worth keeping, because the shape is not the one most games
968
+ assume. There are **two roles**: you declare a save slot once, in your game config, the way you
969
+ declare a room template; then your game's running code writes and reads that player's save with
970
+ `gem.me.saves`. Game code cannot create a slot, and a save against one you never declared is
971
+ refused every time.
972
+
973
+ ## An economy — if your game has one
974
+
975
+ Currencies, items, shops and loot are not on the list above either, for the same reason: a game
976
+ without them is complete. If yours has any, they have their own pages:
977
+ [`inventory-and-catalogs.md`](inventory-and-catalogs.md) for the concepts, and
978
+ [`game-economy-tutorial.md`](game-economy-tutorial.md) to build a shop step by step.
979
+
980
+ The shape rhymes with saves. There are **two roles**: you author the definitions — the currencies,
981
+ items and shops that exist — once, in your game config; then your game's running code reads and
982
+ changes what a player holds. The one thing to carry away before you read further: a **recipe**,
983
+ run on the server, is the only path a modified client cannot rewrite, so anything of value goes
984
+ through one.
985
+
986
+ ## Async opponents — if your game has nobody live to play against
987
+
988
+ Not on the list above either: a game with no competitive mode is complete without one. If yours
989
+ wants one and has no server of its own to run a live match on, the platform will hold a snapshot of
990
+ one player's run — a deck, a build, a ghost lap — and hand it to a different player's game hours
991
+ later, with nothing live between them.
992
+
993
+ The shape is a loop rather than two roles: your game pushes a record after a match, draws a few at
994
+ the next match's start, and fetches the full data for the one your player picks.
995
+ [`async-opponents.md`](async-opponents.md) is the page — read it before you conclude that a game
996
+ running entirely in the browser cannot offer competition, and read
997
+ [its safety rule](async-opponents.md#7-draw-a-set-at-match-start) before you render anything a
998
+ drawn record carries, because those strings were typed by another player.
999
+
1000
+ ## A break in play, if you want one to be worth something
1001
+
1002
+ Not on the list above either, and a game that never asks is never interrupted. If yours wants a
1003
+ break to carry a promotion for another game on the Arcade, `gem.ads.requestAd` asks for one and the
1004
+ Arcade draws it in its own chrome over your paused game.
1005
+
1006
+ Three things before you reach for it: the answer is **usually no**, and no is the ordinary path
1007
+ rather than a failure; nothing rejects, so there is no error branch to write; and `shown` grants
1008
+ nothing, so a reward must never be handed out on the strength of it.
1009
+ [`cross-promotion.md`](cross-promotion.md) is the page.
1010
+
1011
+ ## An account upsell, if your game has something to offer one
1012
+
1013
+ Not on the list above either. When a player reaches for something only an account gives them,
1014
+ `gem.account.requestSignIn` asks the Arcade to sign them in, in its own chrome. Your game draws no
1015
+ form and passes nothing.
1016
+
1017
+ Two things before you reach for it: **save first**, because the Arcade may sign the player in by
1018
+ taking the whole page away, and your game then starts again rather than hearing an answer; and on
1019
+ `signed_in`, **re-read what you loaded**, because the player underneath your game may not be the
1020
+ one who was there a moment ago. [`player-sign-in.md`](player-sign-in.md) is the page.
1021
+
1022
+ ## A Share button, if your game has something worth sending a friend
1023
+
1024
+ Not on the list above either. If a player can hand a friend a link to a puzzle, a board or a
1025
+ level, `gem.share.request` asks the Arcade to share it: the Arcade builds the link, which unfurls
1026
+ with your game's card, and shares it through its own sheet, copy or Share panel. Show the button
1027
+ only where `gem.share.available` is true.
1028
+
1029
+ Two things before you reach for it: **call it from the click**, synchronously, never from a timer
1030
+ or after an `await`; and **never gate a reward on it**, because your game is told nothing back,
1031
+ not even whether the player shared.
1032
+ [Linking to your game](getting-started.md#linking-to-your-game) is the section.
1033
+
1034
+ ## A decision the player must not make, if your game has one
1035
+
1036
+ Not on the list above either: most games trust the browser with everything they do and lose nothing
1037
+ by it. If yours has a decision a player must not be able to fake, such as a run that earns a reward
1038
+ or a claim only your game's rules can judge, and no server of yours running to make it, the platform
1039
+ will run a small piece of your code for it, out of the player's reach, one call at a time.
1040
+
1041
+ Two things before you reach for it. A verdict the function **returns** to the browser is still
1042
+ advice to a client you do not trust, so whatever it decides it must also carry out itself. And
1043
+ spending currency or granting an item is a recipe, which the platform already runs where a client
1044
+ cannot change it. [`serverless-functions.md`](serverless-functions.md) is the page.
1045
+
1046
+ ## Bug reports players send you, and the one WebGL flag that decides them
1047
+
1048
+ The arcade has a **Feedback** control. A player taps it, the platform asks your game for a picture
1049
+ of itself, the player draws on it and writes what happened, and it arrives on your game's feedback
1050
+ list. You get this for nothing — there is no option to set.
1051
+
1052
+ There is one thing to get right, and it is easy to miss because getting it wrong produces no error
1053
+ anywhere.
1054
+
1055
+ ### ⚠️ WebGL games: `preserveDrawingBuffer: true`
1056
+
1057
+ ```js
1058
+ const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true });
1059
+ ```
1060
+
1061
+ Without it the browser is free to clear your drawing buffer once the frame is composited. Read it
1062
+ after that and you get **a fully black image and no error** — `createImageBitmap` resolves, the
1063
+ transfer succeeds, every layer reports success.
1064
+
1065
+ The SDK checks the flag before it captures and **refuses** rather than sending you black. That is
1066
+ deliberate: a black screenshot costs the player their whole report and costs you the time to work
1067
+ out why the platform sent you a rectangle of nothing. The refusal reaches you instead, as
1068
+ `unsupported` in the report's diagnostics.
1069
+
1070
+ The flag has a real cost — it can stop the browser discarding the buffer after compositing — so it
1071
+ is your call. But the default is to get no screenshots, silently, which is rarely what anyone wants.
1072
+
1073
+ 2D canvas games need nothing. `OffscreenCanvas` games need `onScreenshot`, below.
1074
+
1075
+ ### When the default capture is not right
1076
+
1077
+ By default the SDK captures your largest visible canvas. Override it when that cannot work — an
1078
+ `OffscreenCanvas` driven from a worker, several layered canvases you want composited — or when you
1079
+ want a say per moment:
1080
+
1081
+ ```js
1082
+ createGem({
1083
+ onScreenshot: async ({ maxWidth, maxHeight }) => {
1084
+ if (inCutscene) return null; // decline; the report still sends
1085
+ return createImageBitmap(myCanvas);
1086
+ },
1087
+ });
1088
+ ```
1089
+
1090
+ `null` is a normal answer, not an error — for a cutscene, for art you may not redistribute, or to
1091
+ opt out of screenshots entirely. Fit the caps if you can; the SDK downscales if you overshoot.
1092
+
1093
+ ### Attach your own debug state
1094
+
1095
+ This is the part worth spending effort on, because it is the difference between _"the boss is stuck
1096
+ in the wall"_ and a report you can act on without a reproduction:
1097
+
1098
+ ```js
1099
+ createGem({
1100
+ onFeedbackContext: () => ({
1101
+ level: currentLevel.id,
1102
+ seed: currentLevel.seed,
1103
+ build: BUILD_ID,
1104
+ entities: world.entities.length,
1105
+ recentEvents: eventLog.slice(-20),
1106
+ }),
1107
+ });
1108
+ ```
1109
+
1110
+ **Called once, when a player actually files a report** — never on a timer, never in the background.
1111
+ So the expensive gather is fine here: dump what you would actually want to read. May be `async`;
1112
+ return `null` to add nothing.
1113
+
1114
+ **Answer promptly.** You get about a second, then the report is sent without you — a player waiting
1115
+ to file a bug is not made to wait on the game they are filing it about. If your dump is genuinely
1116
+ slow, keep a cheap snapshot updated and return that.
1117
+
1118
+ The platform never tells your game what the report says, what category the player chose, or whether
1119
+ they went on to send it. That is on purpose.
1120
+
1121
+ ### What must not go in it
1122
+
1123
+ ⚠️ **Nothing about a person.** No other player's name, no chat text, no email, no account id.
1124
+
1125
+ The player filing the report does not write this and cannot see it, and both your team and platform
1126
+ support read it, for 180 days. It is also not a telemetry channel: it runs only when a player
1127
+ chooses to send a report, and nothing is uploaded on your behalf at any other time.
1128
+
1129
+ ## Still open, and worth knowing before you hit it
1130
+
1131
+ - **A quick-join that hands back a live room whose match is dead.** The SDK now leaves the room on
1132
+ unload and clears leftover memberships that refuse a quick-join, which removes the common cause.
1133
+ What it does not do is abandon a room it is _in_ and quick-join a fresh one when the connect fails
1134
+ — that spans matchmaking policy the SDK does not own, and the same recovery is wrong for a game
1135
+ with a lobby, where the player chose the room. If you quick-join and you see a match status 404,
1136
+ this is the shape: leave, quick-join again, and do it **once**, and not at all for a room your own
1137
+ attempt just created — a brand-new room cannot fix a backend that will not allocate.
1138
+ - **Who starts the match.** `connect()` allocates when this player is the host and the room shows no
1139
+ match. If your room template sets `auto_start_host`, both are true for the instant after
1140
+ `quickJoin` returns, so the client wins the race — harmlessly, because gem-api's `start_match` is
1141
+ idempotent per room and returns the existing match. If you would rather the template always be the
1142
+ one that starts it, `await rooms.awaitMatch()` before connecting.
1143
+ - **Starting a match can wait for a server to exist.** A Start pressed when no server is available is
1144
+ refused until the platform has one. `startMatch()` and `connect()` absorb that inside a
1145
+ **30-second** budget and report each wait as **`matchStartWaiting`**; render it, or your Start
1146
+ button stalls silently for half a minute. That budget is the longest a player is expected to
1147
+ tolerate rather than the longest the platform might take, so it will sometimes give up on a start that
1148
+ a longer wait would have caught — `retry: { budgetMs }` raises it if your players will wait, and
1149
+ `0` hands the refusal straight to you. If the budget runs out the error is `no_server_ready`, which
1150
+ says how long it waited and is not the same thing as a request gem-api refused. See
1151
+ [`rooms-and-matches.md`](rooms-and-matches.md) §4.4.
1152
+ - **A peer-to-peer room does not wait at all.** There is no server being allocated for it, so a
1153
+ refusal there means the platform has no P2P rather than no capacity, and it arrives immediately as
1154
+ `p2p_unavailable`. Nothing to render and nothing to retry: it is a platform configuration, and the
1155
+ message carries what gem-api said.
1156
+ - **Cluster targets.** The parent measures the regions and the SDK fills them in: a room is created
1157
+ in this player's best cluster, quick-join puts players only in rooms in clusters they can all play
1158
+ well in, and a match starts where its room is. A game that wants to move a room passes
1159
+ `clusterTargetId` to `startMatch()`. The measurement is a browser measurement — the page times the
1160
+ STUN probes gem-api publishes per region for exactly that purpose — and it stays on the page for
1161
+ three reasons: `GET /v1/regions` takes an account-scoped token the frame does not hold; the page
1162
+ measures once per player session and every game it loads inherits the answer, where the frame would
1163
+ repeat it per game and per reload; and the per-region round-trip vector is coarse geolocation plus a
1164
+ network fingerprint, so what crosses the bridge is at most three ids, never a timing. See
1165
+ [`dedicated-server-flow.md`](dedicated-server-flow.md) §5.
1166
+ - **An automatic start can be refused, and the host's SDK then starts it.** A template that starts
1167
+ the match when the room fills tells the host's game through `matchAutoStartFailed` when the platform
1168
+ could not, and the room stays open. For a region the platform stopped running, or no server free,
1169
+ the SDK starts the match from the host's game itself — once per room until a match begins in it —
1170
+ and the event says `handled: true`. `handled: false` is yours to act on, and
1171
+ `createGem({ retryAutoStart: false })` leaves every one to you; calling `startMatch()` from the
1172
+ handler is then the usual answer. The SDK's 5.11.0 and 5.12.0 releases started nothing, so a game
1173
+ written for them that holds the start back for the host to decide needs that switch.
1174
+ [rooms-and-matches.md](rooms-and-matches.md) §4.4.
1175
+ - **Reconnect.** Since the SDK's 4.0.0 release (2026-09-10) a dropped link is the SDK's to get
1176
+ back: it suspends the match, raises `matchSuspended`, and retries on its own — bounded, backed
1177
+ off, parked while the page is hidden — with `matchReconnecting` and `matchReconnected` as the
1178
+ render hooks. `connect()` again on a suspended session _rejoins the held match_; it never
1179
+ reallocates. A new match is allocated only after `matchEnded`, and only by the host. What the
1180
+ player sees while it happens is the game's; whether to reconnect is yours only if you turn the
1181
+ policy off (`autoReconnect: false`). [match-session.md](match-session.md) §4.
1182
+ - **Letting go when the match ends.** The SDK closes the peer connection when the platform says the
1183
+ match is over, and does _not_ suspend the match — there is nothing to reconnect to. It has to: a
1184
+ browser sitting on a finished match keeps answering ICE consent checks, which the server cannot
1185
+ distinguish from a player still there, and a tab nobody closed used to hold its game-server pod for
1186
+ two hours. What the game decides is what the player sees, via the room's `matchEnded` event or
1187
+ `connect({ onMatchEnded })` — on `gem.match` and `gem.dedicatedServer` alike, on every seat —
1188
+ the distinction those carry is "the match ended" versus "the connection dropped", which are the
1189
+ same `onClose` otherwise. The reason vocabulary and what each means to the player:
1190
+ [match-session.md](match-session.md) §3.
1191
+
1192
+ ## What the SDK will not do, on purpose
1193
+
1194
+ - **Guess when you have disconnected.** `visibilitychange` fires when a player alt-tabs to read a
1195
+ wiki. Only the data channel closing is unambiguous, and only that is wired.
1196
+ - **Act on a stale-build push.** It is forwarded and nothing else, which is also what a native game
1197
+ observes for the same event.
1198
+ - **Invent a display name.** `awaitPlayer()` resolves null rather than
1199
+ burning your timeout: there is no parent, so `gem.player` can never arrive.
1200
+
1201
+ ## What a game correctly keeps — the other half of the audit
1202
+
1203
+ The game that audited its platform layer row by row also recorded what it deliberately does **not**
1204
+ delegate, so nobody spends that trip again. Each of these looked like possible duplication of the
1205
+ SDK and is not:
1206
+
1207
+ - **Your per-tick wire budget.** A budget your game puts on its own data-channel traffic is yours
1208
+ and does not conflict with the platform's 16 KB cap on a **signaling** frame — different paths,
1209
+ different owners. Do not copy the platform's number into game code; hold your own.
1210
+ - **Player saves are the exception on this list, and an earlier version of it said otherwise.**
1211
+ Progress a player would be upset to lose does not belong in browser storage, which they lose by
1212
+ changing device or browser; the platform holds it, and
1213
+ [`saving-player-progress.md`](saving-player-progress.md) is how. Something genuinely local still
1214
+ belongs in the browser — a keybinding, which device this is, a cached asset.
1215
+ - **`openExternal` and fullscreen.** The bridge carries both requests, and the parent decides. If
1216
+ your game opens no links and never goes fullscreen, there is nothing to route — absence of these
1217
+ calls in a game is not a gap.
1218
+ - **An offline stub, if you want one.** The SDK no longer ships a seeded mock — it was a quarter
1219
+ of the bundle every player downloaded, and a game must be framed to reach
1220
+ the platform at all. A game may keep its own offline
1221
+ rooms implementation — the audited game argues a stub that is merely convenient lets the game
1222
+ pass offline and fail online — but that is a choice about test honesty, and it drifts with the
1223
+ SDK's room semantics; know that you are signing up to track them.
1224
+ - **Debug affordances in your own vocabulary.** Per-step logging, a `?cluster=` override aimed at
1225
+ one region — a developer override outranks a platform-measured value _because a person typed it_,
1226
+ and those belong to the game. Ship nothing with one set.
1227
+ - **Capability advertising is not yours to tune.** `createGem` advertises what the SDK
1228
+ _implements_, not what your game intends to use — an unused capability costs nothing, because the
1229
+ parent only relays what its socket delivers. A game assembling the parts by hand instead inherits
1230
+ the duty to advertise only what it wired: a capability advertised and not wired makes the page do
1231
+ its half and drop the answer, silently, one document above you.