@indiegems/gem-web-sdk 7.8.1 → 7.9.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.
package/docs/changelog.md CHANGED
@@ -16,6 +16,75 @@ Its entries are grouped under up to five headings:
16
16
  - **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
17
17
  - **New capability**: something a game can now do. Nothing changes unless you use it.
18
18
 
19
+ ## 7.9.1
20
+
21
+ Upgrading from 7.9.0. Impact: none.
22
+
23
+ Nothing in this release asks anything of a game.
24
+
25
+ ## 7.9.0
26
+
27
+ Upgrading from 7.8.1. Impact: required.
28
+
29
+ ### Must change
30
+
31
+ - A link your game shares can no longer carry a parameter named build. The Arcade now reads that key for itself and removes it before your game starts, so the share request refuses it with a TypeError that names the key. Rename the parameter if your game shares one by that name.
32
+
33
+ ### Rebuild
34
+
35
+ - GemCapability gained gameMenu, and the bridge's message lists and unions in both directions gained the two new menu messages. A switch that assumes it knows every GemCapability value must handle the new one. Other code that compiles today keeps compiling.
36
+
37
+ ### New capability
38
+
39
+ - gem.setMenu adds to the Arcade's menu, the one control the Arcade keeps over a game in its top right corner. Give onSettings and the menu gains a settings entry that opens your own settings screen, and give items for up to four entries of your own, each with an id, a label you have already translated and an onSelect handler. Each call is the whole answer and replaces the last, so call it again when the entries change. It returns true when the Arcade was told. After awaitBridge has settled, draw a settings button of your own only when it returns false. The Arcade pauses your game while its menu is open and resumes it before your handler runs.
40
+
41
+ ## 7.8.1
42
+
43
+ Upgrading from 7.8.0. Impact: none.
44
+
45
+ Nothing in this release asks anything of a game.
46
+
47
+ ## 7.8.0
48
+
49
+ Upgrading from 7.7.0. Impact: recommended.
50
+
51
+ ### May stop
52
+
53
+ - The SDK now stops accidental text selection, the long-press copy menu and the tap highlight across your game's document, so a game can drop its own rules for that. Your own style rules always win, and inputs, text areas and editable content stay typeable and selectable. Text a player could select and copy before no longer is, unless your own styles make it selectable. Pass selectableText as true to createGem only if your game is mostly text a player should be able to copy.
54
+
55
+ ### New capability
56
+
57
+ - A room says whether it is self-hosted in selfHosted: its dedicated server runs on the machine of the player who created it, as it does in the Gem editor. The SDK asks for such a room on your behalf where the embedder hosts locally, and there is no option for a game to set. It is false on every room a player in the Arcade is in.
58
+
59
+ ## 7.7.0
60
+
61
+ Upgrading from 7.6.0. Impact: required.
62
+
63
+ ### Must change
64
+
65
+ - In the server API types, occurred_at on a leaderboard row and on an entity leaderboard row is now optional and may be null. Code that assumes it is always a number must handle it missing. LeaderboardResponse and EntityLeaderboardResponse are now written as the shared LeaderboardPeriodFields joined with their own fields, so every field they had is still there.
66
+
67
+ ### Rebuild
68
+
69
+ - Server API unions gained values. OrderSource has earning_reset, and InventoryChange has a new earning_limits_reset change. A switch that assumes it knows every OrderSource value or every InventoryChange type must handle the new one. Other code that compiles today keeps compiling.
70
+ - The server API reads getLeaderboard and getEntityLeaderboard accept three more options, periods_back, period and player_id, and the option types GetLeaderboardOptions and EntityLeaderboardOptions now extend the new PeriodOptions. Code that compiles today keeps compiling.
71
+
72
+ ### New capability
73
+
74
+ - gem.leaderboards can read a board scoped to one period of a challenge, such as today's board or this week's. Every read takes periodsBack, where 0 is the current period and 1 is the one before, or period, the id of a period from an earlier result. Name at most one of them. A result from such a board carries period, which says which period it is, when it opens and closes, and whether it is final. A serverless function or a dedicated server passes playerId so the board answers for that player's own day. On a board that is not scoped to periods, both selectors are refused rather than ignored.
75
+
76
+ ## 7.6.0
77
+
78
+ Upgrading from 7.5.0. Impact: required.
79
+
80
+ ### Must change
81
+
82
+ - createGem now throws GemUnframedError when the page is opened on its own instead of inside a frame. Before, such a game constructed and then waited for a bridge that never came, with no player, no saves and no config. Run your game where it is framed and handed an identity: the Arcade, the Gem editor or the Gem dev host. A game that is only ever played there changes nothing. When the framing page is not one the game allows, the refusal is now final: awaitBridge, awaitIdentity, awaitPlayer and awaitCohorts answer at once instead of timing out.
83
+
84
+ ### New capability
85
+
86
+ - A serverless function written in JavaScript can use the SDK through a new function entry of the package. createGem there returns the same platform-backed calls a browser game makes, bound to the player who called the function. Nothing is retried, a conditional save is refused before it is sent, and gem.functions and gem.gameConfig throw because a function has neither. This entry is in the package only.
87
+
19
88
  ## 7.5.0
20
89
 
21
90
  Upgrading from 7.4.2. Impact: required.
@@ -246,14 +246,13 @@ you almost certainly do not want to:
246
246
  | Storefront marker, My Games | yes | yes |
247
247
  | Rewards, platform grants | yes | yes |
248
248
  | The platform-wide **streak** | yes | **no** — a streak of its own, on your store page |
249
- | The player's history calendar| yes | no |
250
249
  | Label in the arcade | localised in every language the arcade speaks | generic |
251
250
  | Period length | a day, a week, a month | must be between **1 and 31 days** |
252
251
 
253
252
  A streak spans games, which only works when every game agrees on what the player's "today" is. A
254
253
  custom cron period is yours alone and cannot be compared with anything, so a cron challenge is
255
254
  **game-local**: it shows progress, pays and keeps its own streak, and it never joins the
256
- platform-wide streak or a history.
255
+ platform-wide streak.
257
256
 
258
257
  **If you wrote `0 0 * * *`, write `daily`.** The upload stores it as `daily` either way — which puts
259
258
  it on each player's own calendar, not on UTC midnight. Reach for a custom cron only when the period
@@ -542,10 +541,11 @@ Three things to know before you build a mechanic on it:
542
541
  - **One completion is a day.** A player who finishes three of your challenges advances their streak
543
542
  once, the same as a player who finishes one. A streak measures days the player showed up, not how
544
543
  much a game offered.
545
- - **A streak is the platform's, and it is shown on platform surfaces.** Your game reports progress
546
- and does not read the streak back — see below.
547
- - **Whether a player's streak is shown at all can depend on who they are.** Some players do not see
548
- streak surfaces. Do not design a mechanic whose fairness depends on every player seeing one.
544
+ - **A streak is the platform's.** Your game reports progress and does not read the streak back —
545
+ see below.
546
+ - **The arcade does not display the platform-wide streak.** The platform counts it, but no arcade
547
+ surface shows it to the player today. Do not tell players about it, and do not design a mechanic
548
+ that depends on a player seeing it.
549
549
 
550
550
  Challenges on a custom cron are game-local: each keeps a streak of its own, shown on your game's
551
551
  store page, and none advances the platform-wide one ([section 5a](#5a-use-daily-weekly-or-monthly-avoid-cron-for-a-challenge)).
@@ -576,8 +576,9 @@ counts for the day it was played. [A board for each day](leaderboards.md#a-board
576
576
  is the page.
577
577
 
578
578
  **Their per-period history and their streak — no.** There is no history read and no streak read in
579
- the SDK. The arcade is what displays those — the marker on your game's card, and the player's own
580
- progress, history and streak on platform surfaces. Your game does not have to render any of it.
579
+ the SDK, and the arcade has no history calendar either. What the arcade displays is the marker on
580
+ your game's card and the player's progress in the current period — plus, for a cron challenge, its
581
+ own streak on your store page. Your game does not have to render any of it.
581
582
 
582
583
  ## 11. When an upload is refused
583
584
 
@@ -209,8 +209,9 @@ the one you attempted, rather than resending the purchase.
209
209
 
210
210
  Your shop can also sell for the arcade's own currencies, and the platform draws one line through
211
211
  them: **Tickets buy cosmetics, Gems buy anything.** Each platform currency has a spend scope — Tickets'
212
- is `cosmetic_only`, Gems' is `any`. Mark a cosmetic in the
213
- config, and price it in `ARCADE_TICKETS`:
212
+ is `cosmetic_only`, Gems' is `any`. Which currencies your game may price in, and what each may buy,
213
+ is `gemctl platform config`'s answer. Mark a cosmetic in the config, and price it in
214
+ `ARCADE_TICKETS`:
214
215
 
215
216
  ```yaml
216
217
  items:
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "7.8.1"
53
+ "@indiegems/gem-web-sdk": "7.9.1"
54
54
  }
55
55
  ```
56
56
 
@@ -175,7 +175,10 @@ gem.frame.progress(loaded, total); // while assets load
175
175
  gem.frame.ready(); // once the player can actually play
176
176
  ```
177
177
 
178
- And give players a way out — a visible control that calls `gem.exit()`. A game a player
178
+ And tell the Arcade where your settings are — `gem.setMenu({ onSettings })` puts them in the
179
+ Arcade's own menu, top-right, beside Return to the Arcade
180
+ ([what a game must do](what-a-game-must-do.md#the-arcades-menu-your-settings-live-in-it)). Outside
181
+ the Arcade there is no such menu, so also give players a way out — a control that calls `gem.exit()`. A game a player
179
182
  cannot leave is one they leave by closing the tab.
180
183
 
181
184
  ## 6. Play it
@@ -347,8 +350,9 @@ What reaches the frame:
347
350
  - **The query only.** A fragment (`#…`) is never forwarded.
348
351
  - **Not the arcade's own keys.** These are stripped and never reach the game, and they are the
349
352
  only keys the arcade keeps for itself; it never interprets yours:
350
- - Store and play-page switches: `play`, `channel`, `purchase`.
351
- - Platform switches: `devtools`, `devLocale`, `playOverlay`.
353
+ - Store and play-page switches: `play`, `channel`, `purchase`, and `build`, which pins one
354
+ of your own builds when you play it from your Builds page.
355
+ - Platform switches: `devtools`, `devLocale`.
352
356
  - The arcade's own sign-in return marker: `social`.
353
357
  - Attribution and campaign keys, which the arcade keeps out of every game: `_s`, `utm_*`
354
358
  (every key with that prefix, matched case-sensitively, so UTM_X and utmost are yours),
@@ -8,8 +8,8 @@
8
8
  "documents": [
9
9
  {
10
10
  "file": "achievements.md",
11
- "sha256": "2bb4790db96a48171826a9cb3ea8e517d9128f07e55b1fd71b072a491021a15b",
12
- "updated": "2026-09-28T01:10:05Z"
11
+ "sha256": "e67bc5d0e6abd9ff842be46fa82bf5117fce74b2eb9d28a3954919ddc8a11c73",
12
+ "updated": "2026-10-02T23:09:37Z"
13
13
  },
14
14
  {
15
15
  "file": "analytics.md",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  {
35
35
  "file": "changelog.md",
36
- "sha256": "3398bf6f55c503520167dc9cfbee06da7f0552125a4b2050d7e508096c8e256f",
37
- "updated": "2026-10-02T21:36:48Z"
36
+ "sha256": "8a2e021addc2980433e722ba6ca2c7f5fa81cb8eac921e0bda28e44e3484d09d",
37
+ "updated": "2026-10-05T17:22:40Z"
38
38
  },
39
39
  {
40
40
  "file": "cross-promotion.md",
@@ -43,8 +43,8 @@
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
46
- "sha256": "7ba296ba3056407ca3c8c9757a3351f3a8062a943335ee52b4ee92e1b2721d3b",
47
- "updated": "2026-10-02T18:52:17Z"
46
+ "sha256": "c198868e2cb1f1b5d9164bb5b1d23bf3f502987e45e10163c8b048a8eba24667",
47
+ "updated": "2026-10-02T23:01:00Z"
48
48
  },
49
49
  {
50
50
  "file": "dedicated-server-flow.md",
@@ -63,13 +63,13 @@
63
63
  },
64
64
  {
65
65
  "file": "game-economy-tutorial.md",
66
- "sha256": "7c94ec70c9e2490bdef23b92743b48409a3c6e22d9f26aff8c09fd86af84eae1",
67
- "updated": "2026-10-01T19:42:50Z"
66
+ "sha256": "08588571cd6529d81bd064f65bc3158997418aeb88ce00ae281735c8b44bff1f",
67
+ "updated": "2026-10-02T19:05:02Z"
68
68
  },
69
69
  {
70
70
  "file": "getting-started.md",
71
- "sha256": "3281ee225785781d234a7d0dab275ef6f2af499846c5376737d87aa3f3234fe5",
72
- "updated": "2026-10-02T21:43:45Z"
71
+ "sha256": "3c38e5b14c78b70b107d5e5b5499fa9340975d602642a8eb91f2dd969d1b605b",
72
+ "updated": "2026-10-05T17:22:40Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -118,8 +118,8 @@
118
118
  },
119
119
  {
120
120
  "file": "server-bundles.md",
121
- "sha256": "e85e1d041b12870fac5bb3fa20fab33737749e177e55b831b38fb6c3b6a930ad",
122
- "updated": "2026-10-02T21:43:45Z"
121
+ "sha256": "13f5846ad8ee3d27e83916b8d95b4d3fdfcbeb85f94e095cc0c04e14dc2577b2",
122
+ "updated": "2026-10-05T17:22:40Z"
123
123
  },
124
124
  {
125
125
  "file": "serverless-functions-js.md",
@@ -138,8 +138,8 @@
138
138
  },
139
139
  {
140
140
  "file": "what-a-game-must-do.md",
141
- "sha256": "4bd46c937788cb11aa188c7bb120399ddc7dd89c23fa5ea2955db9dd4e9ea08e",
142
- "updated": "2026-10-02T21:36:48Z"
141
+ "sha256": "f44c1fd73678b1b569c59bc94bb55cfa2bdb957d798f5985ea5b3024865ed4a6",
142
+ "updated": "2026-10-04T19:51:47Z"
143
143
  }
144
144
  ]
145
145
  }
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.8.1/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v7.8.1/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.9.1/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v7.9.1/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -353,6 +353,33 @@ one refused orientation, in fullscreen, once they are already the right way roun
353
353
  That is best effort (iOS does not implement it, and the native app's webview declines
354
354
  it), which is why the panel exists and why it always offers a way past.
355
355
 
356
+ ## An achievement unlock — celebrate it yourself, or declare the Arcade's
357
+
358
+ By default the Arcade draws no card when a player unlocks one of your game's
359
+ achievements: **the celebration is your game's**, in its own art and sound. If your
360
+ game has no celebration of its own, declare the Arcade's card on your store page
361
+ instead:
362
+
363
+ ```yaml
364
+ unlock_celebration: arcade
365
+ ```
366
+
367
+ `game` is the default, and it is what an absent line means.
368
+
369
+ - **Opt in when** your game has no unlock celebration of its own, so its players
370
+ still see their unlocks.
371
+ - **Do not opt in when** your game draws its own. The Arcade's card then lands on top
372
+ of yours, and the player sees two.
373
+ - **Platform milestones** — the Achievement Score tiers, which belong to no game —
374
+ always get the Arcade's card, whatever you declare.
375
+ - **Only the store page on `release` is read**, and it takes effect when that page is
376
+ promoted. It is display-only, and it needs no `schema_version` change.
377
+ - **Gems or Tickets paid in the same moment** still go through `gem.cueReward`, with
378
+ no flight of your own.
379
+
380
+ How your game learns an unlock happened, and the field's full rules, are in
381
+ [achievements.md §5](achievements.md#5-celebrating-an-unlock-is-your-games-job).
382
+
356
383
  ## A television, if a player puts you on one
357
384
 
358
385
  The Arcade runs in TV browsers and on TV hardware, so a game can find itself ten feet from its
@@ -435,7 +462,9 @@ there is no route by which the frame could request one. `createGem` forwards all
435
462
  arcade cannot release yours from outside the frame. Keep it captured and the player cannot click
436
463
  the dialog sitting on top of your game.
437
464
 
438
- **Save on pause.** When the player taps the Arcade's Back tab, your game is **parked**, not closed:
465
+ **Save on pause.** When a player on a phone chooses Return to the Arcade in
466
+ [the Arcade's menu](#the-arcades-menu-your-settings-live-in-it), or presses the device's Back, your
467
+ game is **parked**, not closed:
439
468
  it receives `onPause(true)` and stays loaded while the player browses. They may come back and
440
469
  press Continue — `onPause(false)`, and play resumes where it stopped — or the parked game may be
441
470
  ended up to five minutes later. That end usually comes with `onWillTerminate` first, **but not
@@ -542,66 +571,117 @@ the `var()` above, or read `gem.viewport`. The release checklist looks for the b
542
571
 
543
572
  ### The arcade's own controls: reserved areas
544
573
 
545
- On a phone your game is full-bleed, and the arcade keeps one row of its own across the top of it,
546
- with the middle left open for a centred camera:
574
+ On a phone your game is full-bleed, and the arcade keeps one row of its own over the top of it:
575
+
576
+ - **The menu button**, in the **top-right corner**, always there. It opens
577
+ [the Arcade's menu](#the-arcades-menu-your-settings-live-in-it): Return to the Arcade, your
578
+ settings, mute and the rest.
579
+ - **The player's wallets** (Gems, Tickets, their achievement score), in the **middle of the row**,
580
+ [always there](#the-arcades-wallet-always-shown-never-drawn), during play as well as on your
581
+ menus. They are translucent, so your game shows through them — but they are still the Arcade's,
582
+ and still taps the Arcade takes.
547
583
 
548
- - **Top-left: the Back tab** — the Gem Arcade wordmark, always there.
549
- - **Top-right: the player's wallets** (Gems, Tickets, their achievement score) while your screen
550
- [shows them](#the-arcades-wallet-show-it-never-draw-it). Put your own settings, pause or menu
551
- button top-right **below** them, at `top: var(--gem-reserved-tr-height, …)` — and when gameplay
552
- hides the wallets with `gem.setWalletVisible(false)`, that rectangle shrinks to nothing and your
553
- button moves up into the corner on its own.
584
+ **The top of the screen has one layout, in every game:**
585
+
586
+ | Where | Whose | What goes there |
587
+ | -------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
588
+ | **Top-left** | yours | Your **Home / Back** buttons, drawn when a screen needs them, in the space to the left of the wallets. They move around _inside_ your game — back to your level select or title screen — and never leave it. |
589
+ | **Middle and top-right** | the Arcade's | Its wallets and its menu button. Nothing of yours. |
590
+ | **Centre and right, below the Arcade's row** | yours | Your **information**: score, moves, a timer, a level name. It starts under the bottom of the Arcade's row, never beside it or under it. |
591
+
592
+ Keep the left for Home / Back even on a screen that has neither today: a player learns where "back"
593
+ is once, across every game, and information that sits there on one screen teaches them the wrong
594
+ thing on the next.
595
+
596
+ **Put no settings, pause-menu or burger button of your own top-right**: your settings open from the
597
+ Arcade's menu, and a second button under the Arcade's would mean something else in every game.
598
+
599
+ **How much room the top-left has.** The menu button is about 48px across — the size of a Home or
600
+ Back button. With every wallet shown, the narrowest phones (360px wide) leave roughly 68px to the
601
+ left of the wallets: room for **one** button. Two buttons, or a wide one, go on their own row below
602
+ the Arcade's. Do not copy those numbers — read the space, as below.
554
603
 
555
604
  Those rectangles of your frame are **reserved**: keep essential UI and every touch target out of
556
605
  them. **Never hard-code their size or height.** They differ by phone, and in native fullscreen the
557
606
  whole row may rise into the camera band — read them, as below.
558
607
 
559
- An inset cannot say that — as a top inset, a tab a few dozen pixels wide would take your whole top
608
+ An inset cannot say that — as a top inset, a button a few dozen pixels wide would take your whole top
560
609
  strip. So `gem.viewport` also carries the rectangles, in the same CSS pixels of your frame, measured
561
- from its top-left corner:
610
+ from its top-left corner — one per wallet shown, and one for the menu button:
562
611
 
563
612
  ```json
564
613
  {
565
614
  "insets": { "top": 47, "right": 0, "bottom": 34, "left": 0 },
566
- "reserved": [{ "x": 8, "y": 51, "width": 104, "height": 34 }]
615
+ "reserved": [
616
+ { "x": 68, "y": 51, "width": 72, "height": 28 },
617
+ { "x": 144, "y": 51, "width": 72, "height": 28 },
618
+ { "x": 220, "y": 51, "width": 72, "height": 28 },
619
+ { "x": 304, "y": 51, "width": 48, "height": 48 }
620
+ ]
567
621
  }
568
622
  ```
569
623
 
570
624
  **The SDK turns them into one pair of custom properties per corner**, written beside
571
625
  `--gem-safe-*` before anything else hears the push: `--gem-reserved-tl-width` and
572
626
  `--gem-reserved-tl-height`, and the same pair for `tr`, `bl` and `br` — how far that corner's
573
- reserved rectangles reach in from its two edges. Push the corner's UI clear of it:
627
+ reserved rectangles reach in from its two edges. A rectangle belongs to the corner its centre lies
628
+ nearest to.
629
+
630
+ ⚠️ **The wallets sit in the middle, so they count toward BOTH top corners.** A wallet left of
631
+ centre is a top-left rectangle, one right of centre a top-right one. While wallets are shown,
632
+ `--gem-reserved-tl-height` is the bottom of the wallets and `--gem-reserved-tl-width` reaches to the
633
+ middle of the screen — even though the corner itself, left of the first wallet, is clear. So:
574
634
 
575
635
  ```css
576
- /* settings in the top-right corner, below the wallets — and up in the corner when they are hidden */
577
- #settings {
578
- top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tr-height, 0px));
636
+ /* your information — score, moves, a timer — below the WHOLE of the arcade's row: the taller of
637
+ the two top corners. It moves up by itself when gameplay hides the wallets. */
638
+ #info {
639
+ top: max(
640
+ var(--gem-safe-top, env(safe-area-inset-top, 0px)),
641
+ var(--gem-reserved-tl-height, 0px),
642
+ var(--gem-reserved-tr-height, 0px)
643
+ );
579
644
  right: var(--gem-safe-right, 0px);
580
645
  }
581
- /* a score in the top-left corner, below the tab */
582
- #score {
583
- top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tl-height, 0px));
646
+ /* ONE Home or Back button, level with the arcade's row, in the clear corner to the wallets' left */
647
+ #back {
648
+ top: var(--gem-safe-top, env(safe-area-inset-top, 0px));
584
649
  left: var(--gem-safe-left, 0px);
585
650
  }
586
- /* or a row along the top, between the two */
587
- #top-bar {
588
- padding-left: var(--gem-reserved-tl-width, 0px);
589
- padding-right: var(--gem-reserved-tr-width, 0px);
651
+ /* a ROW of your own buttons does not fit beside the wallets: start it below the arcade's row,
652
+ like your information */
653
+ #nav {
654
+ top: max(
655
+ var(--gem-safe-top, env(safe-area-inset-top, 0px)),
656
+ var(--gem-reserved-tl-height, 0px),
657
+ var(--gem-reserved-tr-height, 0px)
658
+ );
590
659
  }
591
660
  ```
592
661
 
593
- All eight are written on every push, `0px` for a clear corner, so a rotation never leaves a stale
594
- value; they are never set under an embedder that sends no viewport, which is what the `0px`
595
- fallback is for. A rectangle belongs to the corner its centre lies nearest to. For a HUD laid out
596
- from script, read `gem.viewport.reserved` — `[]` when nothing is covered.
662
+ The per-corner properties cannot say how wide the clear corner is — only that the corner's
663
+ rectangles reach in to the middle. To know whether your button fits beside the wallets, read the
664
+ rectangles from script: the clear width at the top-left is the smallest `x` among
665
+ `gem.viewport.reserved` (the whole width when the list is empty).
666
+
667
+ ```js
668
+ const reserved = gem.viewport?.reserved ?? [];
669
+ const clearLeft = reserved.length ? Math.min(...reserved.map((r) => r.x)) : window.innerWidth;
670
+ nav.classList.toggle('below-arcade-row', clearLeft < nav.offsetWidth + 8);
671
+ ```
597
672
 
598
- **Lay out against it whenever it changes.** Shown wallets reserve the top-right corner; hiding them
599
- shrinks it to nothing, and only the tab's top-left rectangle is left. A wallet the arcade pops up for
600
- a moment while they are hidden never reserves anything — it floats over your game and moves nothing.
673
+ All eight properties are written on every push, `0px` for a clear corner, so a rotation never leaves
674
+ a stale value; they are never set under an embedder that sends no viewport, which is what the `0px`
675
+ fallback is for. `gem.viewport.reserved` is `[]` when nothing is covered.
676
+
677
+ **Lay out against it whenever it changes.** The wallets' rectangles are there for the whole
678
+ session on the Arcade's phone layout, and their number and width can change as the player's
679
+ balances and currencies do. Read every corner, not only the ones this page names today: a layout that honours
680
+ all four is right under any embedder.
601
681
 
602
682
  **Older SDKs get it folded into the top inset.** A game on an SDK from before reserved areas cannot
603
683
  read the rectangles, so the arcade sends it none and instead raises `insets.top` — and so
604
- `--gem-safe-top` — past the bottom of the tab. That game keeps working, at the cost of a full-width
684
+ `--gem-safe-top` — past the bottom of the row. That game keeps working, at the cost of a full-width
605
685
  band; upgrading gives the band back.
606
686
 
607
687
  ## Which store-page experiment brought this player in: cohorts
@@ -693,18 +773,62 @@ and anything that must follow them, is a save or `gem.me.settings`:
693
773
  draws the line in full. Never block your first frame on it either: apply defaults, then apply what
694
774
  arrives.
695
775
 
776
+ ## The Arcade's menu: your settings live in it
777
+
778
+ The Arcade keeps one menu button over every game, top-right, on every device. Its menu is how a
779
+ player leaves (**Return to the Arcade**), mutes, goes fullscreen and reports a problem — and how they
780
+ open **your settings**, once you say you have some:
781
+
782
+ ```js
783
+ const inArcadeMenu = gem.setMenu({
784
+ onSettings: () => settingsScreen.open(),
785
+ items: [{ id: 'how-to-play', label: t('How to play'), onSelect: () => help.open() }],
786
+ });
787
+ ```
788
+
789
+ - **`onSettings` puts a Settings entry in the menu.** The Arcade closes its menu, resumes your game,
790
+ and calls it; you draw your own settings screen, as you always did. Leave it out and the menu has
791
+ no Settings entry.
792
+ - **`items` are up to four entries of your own**, shown in a group below the Arcade's. `id` is your
793
+ name for the entry (1 to 32 characters); `label` is what the player reads, **already in their
794
+ language** (`gem.prefs.locale`) — the Arcade does not translate it, cuts it at 40 characters, and
795
+ may shorten it further. A fifth entry is left out.
796
+ - **It is screen state, not a toggle**, like the wallets: each call is the whole answer and replaces
797
+ the last. Call it again when the language changes or a screen has different options;
798
+ `gem.setMenu({})` takes everything of yours back out. Before the Arcade connects the menu is held
799
+ and sent when it does, and again after any reconnect.
800
+ - **The Arcade pauses your game while its menu is open** — `onPause(true)`, then `onPause(false)`
801
+ before a handler of yours runs. Nothing else is needed from you.
802
+ - **You cannot remove or reorder the Arcade's entries**, and Return to the Arcade is always there.
803
+ On a phone it **parks** your game, as above; on a desktop, a tablet and a television it ends the
804
+ session, with `onWillTerminate` first. Either way your game heard `onPause(true)` when the menu
805
+ opened: save there — it is the edge you are sure to get.
806
+
807
+ **Do not draw a settings button of your own while `gem.setMenu` returns `true`.** One menu, in one
808
+ place, in every game is the point. Draw your own **only when it returns `false` after the bridge has
809
+ settled** — an embedder with no menu, or your game running outside any embedder:
810
+
811
+ ```js
812
+ await gem.awaitBridge();
813
+ ownSettingsButton.hidden = gem.setMenu({ onSettings: () => settingsScreen.open() });
814
+ ```
815
+
816
+ Your own **back or home** button — the one that moves around inside your game — goes **top-left**,
817
+ which the Arcade [leaves clear](#the-arcades-own-controls-reserved-areas). It never leaves the game:
818
+ leaving is the Arcade's menu, so a player always knows which control does which.
819
+
696
820
  ## Sound: your own mute control, and the Arcade's
697
821
 
698
- **Every game needs its own mute control**, in its settings or its pause menu, because on a phone
699
- the Arcade shows **no** mute button: your game fills the screen and the Arcade's only control over
700
- it is the Back tab. A player on a phone who wants silence has your control and nothing else.
822
+ **Every game needs its own mute control**, in its settings screen. The Arcade's menu has a mute
823
+ entry on every device, but a player who opens your settings expects sound to be there too — and
824
+ under an embedder with no menu, yours is the only one there is.
701
825
 
702
826
  Wire it both ways, so the player has one mute rather than two that disagree:
703
827
 
704
828
  ```js
705
829
  const gem = createGem({
706
830
  // ...
707
- // The Arcade's mute changed (desktop, tablet, TV): follow it.
831
+ // The Arcade's mute changed: follow it.
708
832
  onPrefs: (prefs) => sound.setMuted(prefs.muted),
709
833
  });
710
834
 
@@ -724,14 +848,14 @@ sent when the Arcade connects; an embedder with no mute button is simply not tol
724
848
  button saying one thing while the game does the other; a game that follows `prefs.muted` but never
725
849
  reports leaves the Arcade's button stale after the player uses yours.
726
850
 
727
- ### The Arcade's mute button, where there is one
851
+ ### The Arcade's mute entry
728
852
 
729
- On a desktop, a tablet and a television the Arcade does keep a mute button beside your game, for
730
- games whose SDK can honour it — an SDK that silences your game says so when it connects, and a game
731
- built on an older one gets no button rather than one that does nothing. When the player presses it,
853
+ The Arcade's menu carries a mute entry on every device, for games whose SDK can honour it — an SDK
854
+ that silences your game says so when it connects, and a game built on an older one gets no entry
855
+ rather than one that does nothing. When the player presses it,
732
856
  `gem.prefs.muted` arrives as `true` and the SDK silences your page itself — every Web Audio graph and
733
857
  every `<audio>`/`<video>` element — then gives the sound back when they press it again. With the two
734
- calls above, the Arcade's button and yours stay in step whichever the player uses.
858
+ calls above, the Arcade's entry and yours stay in step whichever the player uses.
735
859
 
736
860
  - **It turns the volume down; it does not stop your clock.** Your `AudioContext` keeps running, so
737
861
  anything scheduled against `currentTime` stays in time. Do not answer a mute with `ctx.suspend()` in
@@ -774,38 +898,25 @@ its music out than cut it, say. The Arcade still shows its button for your game
774
898
  so this is a promise: your `onPrefs` handler must go quiet on `muted: true` and come back on `false`,
775
899
  and the release checklist checks that it does.
776
900
 
777
- ## The Arcade's wallet: show it, never draw it
901
+ ## The Arcade's wallet: always shown, never drawn
778
902
 
779
903
  The player's platform balances — Gems, Tickets (`ARCADE_TICKETS`) and their achievement score —
780
904
  belong to the Arcade. **Your game never draws one**, and never shows a pending state for one: the
781
- Arcade takes every figure from the platform and draws the wallets itself, in the top-right corner
782
- of your frame on a phone.
783
-
784
- **They are shown unless your game hides them.** A game that does nothing has every wallet on show
785
- the whole time. The one rule: **hide them during gameplay, show them on your menus.**
786
-
787
- ```js
788
- level.onStart = () => gem.setWalletVisible(false);
789
- pauseMenu.onOpen = () => gem.setWalletVisible(true);
790
- ```
791
-
792
- - **While hidden, the Arcade still pops a wallet up** for a moment when something happens to it —
793
- a reward you cued with `gem.cueReward`, a spend, a landing, a daily-maximum note, a score change
794
- — then hides it again. Your state never cancels that, so a reward mid-level still shows.
795
- - **It is screen state, not a toggle.** Each call is the whole answer and replaces the last. Call it
796
- as often as you like: before the Arcade connects the state is held and sent when it does, and
797
- again after any reconnect; a repeat of the current state is not re-sent.
798
- - **Shown wallets take space; a pop does not.** Shown, they reserve the top-right
799
- [corner](#the-arcades-own-controls-reserved-areas), so lay your menu out against
800
- `--gem-reserved-tr-*` — your settings button sits below them; hidden, the corner is yours again and
801
- the same `var()` moves the button up. A pop floats over your game and never moves its layout.
802
- - **Narrowing is optional.** On a screen that sells in one currency — a ticket store — you may show
803
- only that one: `gem.setWalletVisible(true, { only: ['ARCADE_TICKETS'] })`, with `'score'` for the
804
- achievement score. Use it only there; leaving it out shows every wallet, which is always safe.
805
- - **Where the Arcade docks its wallets beside your frame** — a desktop page — they are always on
806
- show, and this changes nothing. Call it anyway; the same code is right everywhere.
807
- - **`false` means nobody is listening** — no connection yet, or an embedder that draws no wallet.
808
- Do nothing in its place.
905
+ Arcade takes every figure from the platform and draws the wallets itself, in the middle of the row
906
+ across the top of your frame on a phone, with its menu button at the right end.
907
+
908
+ **The Arcade shows every wallet the whole time, during play too** (since 2026-10-04). They are
909
+ small, translucent chips in the middle of the top row, and they are
910
+ [reserved rectangles](#the-arcades-own-controls-reserved-areas): lay every screen out below both
911
+ `--gem-reserved-tl-height` and `--gem-reserved-tr-height`, gameplay included. There is nothing for
912
+ your game to call.
913
+
914
+ ⚠️ **`gem.setWalletVisible` no longer hides anything in the Arcade.** It was how a game hid the
915
+ wallets for gameplay and showed them on its menus, and narrowed them to one currency on a store
916
+ screen. The Arcade still accepts the call — it returns `true`, and existing games need no change —
917
+ but the wallets stay up whatever it is told. **Do not design a screen around the top row being
918
+ clear during play**: it is not. Another embedder may still honour the call, so leaving it in is
919
+ harmless; new code does not need it.
809
920
 
810
921
  ## No accidental text selection
811
922
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "7.8.1",
3
+ "version": "7.9.1",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",