@indiegems/gem-web-sdk 7.8.0 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "7.8.0"
53
+ "@indiegems/gem-web-sdk": "7.9.0"
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": "02083c9ed1d62baed5a297ca71b602608afa90320afb73a21ff03187da11346f",
37
- "updated": "2026-10-02T04:41:46Z"
36
+ "sha256": "3398bf6f55c503520167dc9cfbee06da7f0552125a4b2050d7e508096c8e256f",
37
+ "updated": "2026-10-02T21:36:48Z"
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": "d2179480be49085d828c9afbd89d1d72c00914c5607105831503f0652e521285",
72
- "updated": "2026-10-02T21:18:29Z"
71
+ "sha256": "63a367fdcf79abee6a7978057cb1b7065f0365cd6fa5a895cbd2b18f76d29701",
72
+ "updated": "2026-10-03T22:38:04Z"
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": "fed144c0d7b9f8be10fc3896a7325ab19aa660441c24038f39bc02b5fdf60cb3",
122
- "updated": "2026-10-02T21:18:29Z"
121
+ "sha256": "419a1587827f494f638f331571a6385f4de080efb062e83656a9d2c50ee3473b",
122
+ "updated": "2026-10-03T22:38:04Z"
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": "d7b66e2c04f5cea169ae4cecd3926e350e272232af32752e60a7a388dfd8c461",
142
- "updated": "2026-10-02T21:01:44Z"
141
+ "sha256": "e5d0e2622077d4e41d1f1786c834e6280356f525146193285d062c4209ac33bd",
142
+ "updated": "2026-10-03T22:11:20Z"
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.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v7.8.0/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.9.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v7.9.0/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,17 +571,20 @@ 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 cluster of its own over it, in the
575
+ **top-right corner**:
576
+
577
+ - **The menu button**, in the corner itself, always there. It opens
578
+ [the Arcade's menu](#the-arcades-menu-your-settings-live-in-it): Return to the Arcade, your
579
+ settings, mute and the rest.
580
+ - **The player's wallets** (Gems, Tickets, their achievement score), to the left of it, while your
581
+ screen [shows them](#the-arcades-wallet-show-it-never-draw-it). When gameplay hides them with
582
+ `gem.setWalletVisible(false)`, the cluster shrinks to the menu button alone.
547
583
 
548
- - **Top-left: the Back tab** — the Gem Arcade wordmark, always there. **Do not put a back arrow of
549
- your own top-left**: it would sit under the arcade's tab, which leaves the game, while yours would
550
- mean something else.
551
- - **Top-right: the player's wallets** (Gems, Tickets, their achievement score) while your screen
552
- [shows them](#the-arcades-wallet-show-it-never-draw-it). Put your own settings, pause or menu
553
- button top-right **below** them, at `top: var(--gem-reserved-tr-height, …)` — and when gameplay
554
- hides the wallets with `gem.setWalletVisible(false)`, that rectangle shrinks to nothing and your
555
- button moves up into the corner on its own.
584
+ **The top-left corner is yours.** Put your own back or home button there — the one that moves around
585
+ _inside_ your game, back to your level select or your title screen. **Put no settings, pause-menu or
586
+ burger button of your own top-right**: your settings open from the Arcade's menu, and a second
587
+ button under the Arcade's would mean something else in every game.
556
588
 
557
589
  Those rectangles of your frame are **reserved**: keep essential UI and every touch target out of
558
590
  them. **Never hard-code their size or height.** They differ by phone, and in native fullscreen the
@@ -575,17 +607,17 @@ from its top-left corner:
575
607
  reserved rectangles reach in from its two edges. Push the corner's UI clear of it:
576
608
 
577
609
  ```css
578
- /* settings in the top-right corner, below the wallets — and up in the corner when they are hidden */
579
- #settings {
580
- top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tr-height, 0px));
581
- right: var(--gem-safe-right, 0px);
610
+ /* your back button in the top-left corner, which the arcade leaves clear */
611
+ #back {
612
+ top: var(--gem-safe-top, env(safe-area-inset-top, 0px));
613
+ left: var(--gem-safe-left, 0px);
582
614
  }
583
- /* a score in the top-left corner, below the tab */
615
+ /* a score in the top-right corner, below the arcade's cluster */
584
616
  #score {
585
- top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tl-height, 0px));
586
- left: var(--gem-safe-left, 0px);
617
+ top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tr-height, 0px));
618
+ right: var(--gem-safe-right, 0px);
587
619
  }
588
- /* or a row along the top, between the two */
620
+ /* or a row along the top, stopping short of it */
589
621
  #top-bar {
590
622
  padding-left: var(--gem-reserved-tl-width, 0px);
591
623
  padding-right: var(--gem-reserved-tr-width, 0px);
@@ -597,13 +629,15 @@ value; they are never set under an embedder that sends no viewport, which is wha
597
629
  fallback is for. A rectangle belongs to the corner its centre lies nearest to. For a HUD laid out
598
630
  from script, read `gem.viewport.reserved` — `[]` when nothing is covered.
599
631
 
600
- **Lay out against it whenever it changes.** Shown wallets reserve the top-right corner; hiding them
601
- shrinks it to nothing, and only the tab's top-left rectangle is left. A wallet the arcade pops up for
602
- a moment while they are hidden never reserves anything — it floats over your game and moves nothing.
632
+ **Lay out against it whenever it changes.** Shown wallets widen the top-right rectangle; hiding them
633
+ shrinks it to the menu button alone. A wallet the arcade pops up for a moment while they are hidden
634
+ never reserves anything — it floats over your game and moves nothing. Read every corner, not only the
635
+ one this page names today: `--gem-reserved-tl-*` is `0px` now, and a layout that honours it anyway
636
+ is right under any embedder.
603
637
 
604
638
  **Older SDKs get it folded into the top inset.** A game on an SDK from before reserved areas cannot
605
639
  read the rectangles, so the arcade sends it none and instead raises `insets.top` — and so
606
- `--gem-safe-top` — past the bottom of the tab. That game keeps working, at the cost of a full-width
640
+ `--gem-safe-top` — past the bottom of the cluster. That game keeps working, at the cost of a full-width
607
641
  band; upgrading gives the band back.
608
642
 
609
643
  ## Which store-page experiment brought this player in: cohorts
@@ -695,18 +729,62 @@ and anything that must follow them, is a save or `gem.me.settings`:
695
729
  draws the line in full. Never block your first frame on it either: apply defaults, then apply what
696
730
  arrives.
697
731
 
732
+ ## The Arcade's menu: your settings live in it
733
+
734
+ The Arcade keeps one menu button over every game, top-right, on every device. Its menu is how a
735
+ player leaves (**Return to the Arcade**), mutes, goes fullscreen and reports a problem — and how they
736
+ open **your settings**, once you say you have some:
737
+
738
+ ```js
739
+ const inArcadeMenu = gem.setMenu({
740
+ onSettings: () => settingsScreen.open(),
741
+ items: [{ id: 'how-to-play', label: t('How to play'), onSelect: () => help.open() }],
742
+ });
743
+ ```
744
+
745
+ - **`onSettings` puts a Settings entry in the menu.** The Arcade closes its menu, resumes your game,
746
+ and calls it; you draw your own settings screen, as you always did. Leave it out and the menu has
747
+ no Settings entry.
748
+ - **`items` are up to four entries of your own**, shown in a group below the Arcade's. `id` is your
749
+ name for the entry (1 to 32 characters); `label` is what the player reads, **already in their
750
+ language** (`gem.prefs.locale`) — the Arcade does not translate it, cuts it at 40 characters, and
751
+ may shorten it further. A fifth entry is left out.
752
+ - **It is screen state, not a toggle**, like the wallets: each call is the whole answer and replaces
753
+ the last. Call it again when the language changes or a screen has different options;
754
+ `gem.setMenu({})` takes everything of yours back out. Before the Arcade connects the menu is held
755
+ and sent when it does, and again after any reconnect.
756
+ - **The Arcade pauses your game while its menu is open** — `onPause(true)`, then `onPause(false)`
757
+ before a handler of yours runs. Nothing else is needed from you.
758
+ - **You cannot remove or reorder the Arcade's entries**, and Return to the Arcade is always there.
759
+ On a phone it **parks** your game, as above; on a desktop, a tablet and a television it ends the
760
+ session, with `onWillTerminate` first. Either way your game heard `onPause(true)` when the menu
761
+ opened: save there — it is the edge you are sure to get.
762
+
763
+ **Do not draw a settings button of your own while `gem.setMenu` returns `true`.** One menu, in one
764
+ place, in every game is the point. Draw your own **only when it returns `false` after the bridge has
765
+ settled** — an embedder with no menu, or your game running outside any embedder:
766
+
767
+ ```js
768
+ await gem.awaitBridge();
769
+ ownSettingsButton.hidden = gem.setMenu({ onSettings: () => settingsScreen.open() });
770
+ ```
771
+
772
+ Your own **back or home** button — the one that moves around inside your game — goes **top-left**,
773
+ which the Arcade [leaves clear](#the-arcades-own-controls-reserved-areas). It never leaves the game:
774
+ leaving is the Arcade's menu, so a player always knows which control does which.
775
+
698
776
  ## Sound: your own mute control, and the Arcade's
699
777
 
700
- **Every game needs its own mute control**, in its settings or its pause menu, because on a phone
701
- the Arcade shows **no** mute button: your game fills the screen and the Arcade's only control over
702
- it is the Back tab. A player on a phone who wants silence has your control and nothing else.
778
+ **Every game needs its own mute control**, in its settings screen. The Arcade's menu has a mute
779
+ entry on every device, but a player who opens your settings expects sound to be there too — and
780
+ under an embedder with no menu, yours is the only one there is.
703
781
 
704
782
  Wire it both ways, so the player has one mute rather than two that disagree:
705
783
 
706
784
  ```js
707
785
  const gem = createGem({
708
786
  // ...
709
- // The Arcade's mute changed (desktop, tablet, TV): follow it.
787
+ // The Arcade's mute changed: follow it.
710
788
  onPrefs: (prefs) => sound.setMuted(prefs.muted),
711
789
  });
712
790
 
@@ -726,14 +804,14 @@ sent when the Arcade connects; an embedder with no mute button is simply not tol
726
804
  button saying one thing while the game does the other; a game that follows `prefs.muted` but never
727
805
  reports leaves the Arcade's button stale after the player uses yours.
728
806
 
729
- ### The Arcade's mute button, where there is one
807
+ ### The Arcade's mute entry
730
808
 
731
- On a desktop, a tablet and a television the Arcade does keep a mute button beside your game, for
732
- games whose SDK can honour it — an SDK that silences your game says so when it connects, and a game
733
- built on an older one gets no button rather than one that does nothing. When the player presses it,
809
+ The Arcade's menu carries a mute entry on every device, for games whose SDK can honour it — an SDK
810
+ that silences your game says so when it connects, and a game built on an older one gets no entry
811
+ rather than one that does nothing. When the player presses it,
734
812
  `gem.prefs.muted` arrives as `true` and the SDK silences your page itself — every Web Audio graph and
735
813
  every `<audio>`/`<video>` element — then gives the sound back when they press it again. With the two
736
- calls above, the Arcade's button and yours stay in step whichever the player uses.
814
+ calls above, the Arcade's entry and yours stay in step whichever the player uses.
737
815
 
738
816
  - **It turns the volume down; it does not stop your clock.** Your `AudioContext` keeps running, so
739
817
  anything scheduled against `currentTime` stays in time. Do not answer a mute with `ctx.suspend()` in
@@ -780,8 +858,8 @@ and the release checklist checks that it does.
780
858
 
781
859
  The player's platform balances — Gems, Tickets (`ARCADE_TICKETS`) and their achievement score —
782
860
  belong to the Arcade. **Your game never draws one**, and never shows a pending state for one: the
783
- Arcade takes every figure from the platform and draws the wallets itself, in the top-right corner
784
- of your frame on a phone.
861
+ Arcade takes every figure from the platform and draws the wallets itself, beside its menu button in
862
+ the top-right corner of your frame on a phone.
785
863
 
786
864
  **They are shown unless your game hides them.** A game that does nothing has every wallet on show
787
865
  the whole time. The one rule: **hide them during gameplay, show them on your menus.**
@@ -797,10 +875,10 @@ pauseMenu.onOpen = () => gem.setWalletVisible(true);
797
875
  - **It is screen state, not a toggle.** Each call is the whole answer and replaces the last. Call it
798
876
  as often as you like: before the Arcade connects the state is held and sent when it does, and
799
877
  again after any reconnect; a repeat of the current state is not re-sent.
800
- - **Shown wallets take space; a pop does not.** Shown, they reserve the top-right
801
- [corner](#the-arcades-own-controls-reserved-areas), so lay your menu out against
802
- `--gem-reserved-tr-*` — your settings button sits below them; hidden, the corner is yours again and
803
- the same `var()` moves the button up. A pop floats over your game and never moves its layout.
878
+ - **Shown wallets take space; a pop does not.** Shown, they widen the top-right
879
+ [reserved area](#the-arcades-own-controls-reserved-areas) beside the menu button, so lay your
880
+ screen out against `--gem-reserved-tr-*`; hidden, it shrinks back to the button alone. A pop floats
881
+ over your game and never moves its layout.
804
882
  - **Narrowing is optional.** On a screen that sells in one currency — a ticket store — you may show
805
883
  only that one: `gem.setWalletVisible(true, { only: ['ARCADE_TICKETS'] })`, with `'score'` for the
806
884
  achievement score. Use it only there; leaving it out shows every wallet, which is always safe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "7.8.0",
3
+ "version": "7.9.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -893,7 +893,7 @@
893
893
  "",
894
894
  "AMENDED, DELIBERATELY: this message once carried insets only. It now also",
895
895
  "carries `reserved`, rectangles of the frame covered by the parent's own",
896
- "controls floating over the game (a Back tab in a corner, the wallet chips",
896
+ "controls floating over the game (a menu button in a corner, the wallet chips",
897
897
  "shown in another). An inset can only describe a full-width or full-height band,",
898
898
  "and a corner tab expressed as a top inset would take the whole top strip",
899
899
  "from a full-bleed game to clear a control a few dozen pixels wide. A",
@@ -2387,7 +2387,7 @@
2387
2387
  "to it: a reward cue, a pending grant, a landing, a spend, a daily-maximum",
2388
2388
  "note, a score change. The game's state never cancels those. Only wallets",
2389
2389
  "shown BECAUSE OF THIS STATE reserve space: they extend `gem.viewport`'s",
2390
- "`reserved`, and hiding them shrinks it back to the parent's own Back tab. An",
2390
+ "`reserved`, and hiding them shrinks it back to the parent's own menu button. An",
2391
2391
  "event pop floats over the game and never moves its layout. Where the parent",
2392
2392
  "docks its wallets beside the frame rather than over it, this changes",
2393
2393
  "nothing.",
@@ -2416,6 +2416,108 @@
2416
2416
  "rateLimit": { "perMinute": 60 }
2417
2417
  },
2418
2418
 
2419
+ {
2420
+ "type": "gem.menu",
2421
+ "direction": "frameToParent",
2422
+ "transport": "port",
2423
+ "$comment": [
2424
+ "What the game adds to the arcade's own menu — the one control the arcade",
2425
+ "keeps over a game, in the frame's top-right corner, which is how a player",
2426
+ "leaves, mutes, reports a problem, and reaches the game's settings.",
2427
+ "",
2428
+ " settings: true the game has a settings screen and will open it when",
2429
+ " told to; the menu gains a Settings entry",
2430
+ " items up to four entries of the game's own, in this order,",
2431
+ " below the arcade's entries and visibly apart from them",
2432
+ "",
2433
+ "With no message at all, the menu holds the arcade's own entries and",
2434
+ "nothing of the game's. The arcade's entries are the arcade's: a game can",
2435
+ "neither remove nor reorder them, and Return to the Arcade is always there.",
2436
+ "",
2437
+ "SCREEN STATE, NOT A TOGGLE, like `gem.walletDisplay`. Each message is the",
2438
+ "whole answer and the latest one wins; nothing accumulates. The SDK re-sends",
2439
+ "the latest state after every handshake, so a parent that reconnects never",
2440
+ "shows a menu missing what the game had declared.",
2441
+ "",
2442
+ "`id` is the game's own name for an entry and is what comes back in",
2443
+ "`gem.menuSelect`; unique within one message, and the parent keeps the first",
2444
+ "of a repeated id. `label` is what the player reads, ALREADY IN THE PLAYER'S",
2445
+ "LANGUAGE (`gem.prefs.locale`): the parent does not translate it.",
2446
+ "",
2447
+ "A LABEL IS UNTRUSTED TEXT IN THE PARENT'S CHROME. A parent renders it as",
2448
+ "text and never as markup, in the game's group and never among its own",
2449
+ "entries, truncated to fit, and may decline to show an entry at all. A game",
2450
+ "cannot make the menu say something the arcade says: that is what the",
2451
+ "separate group is for.",
2452
+ "",
2453
+ "A PRESENTATION REQUEST ONLY. Not an authorization control; choosing an",
2454
+ "entry does nothing but send `gem.menuSelect`.",
2455
+ "",
2456
+ "Gated by `gameMenu`: sent only to a parent that advertised it. To any",
2457
+ "other, `gem.setMenu` returns false and the game shows a settings control",
2458
+ "of its own instead."
2459
+ ],
2460
+ "fields": {
2461
+ "settings": { "kind": "boolean" },
2462
+ "items": {
2463
+ "kind": "array",
2464
+ "maxItems": 4,
2465
+ "optional": true,
2466
+ "items": {
2467
+ "kind": "object",
2468
+ "fields": {
2469
+ "id": {
2470
+ "kind": "string",
2471
+ "maxLength": 32,
2472
+ "$comment": "The game's own name for the entry, 1..32 characters, echoed in `gem.menuSelect`. The SDK drops an entry whose id is empty or over-long rather than cutting it into a different id."
2473
+ },
2474
+ "label": {
2475
+ "kind": "string",
2476
+ "maxLength": 40,
2477
+ "$comment": "What the player reads, 1..40 characters, already translated. The SDK drops an entry with an empty label and cuts an over-long one."
2478
+ }
2479
+ }
2480
+ }
2481
+ }
2482
+ },
2483
+ "rateLimit": { "perMinute": 60 }
2484
+ },
2485
+
2486
+ {
2487
+ "type": "gem.menuSelect",
2488
+ "direction": "parentToFrame",
2489
+ "transport": "port",
2490
+ "$comment": [
2491
+ "The player chose one of the game's entries in the arcade's menu: its",
2492
+ "Settings entry, or one of the items `gem.menu` declared.",
2493
+ "",
2494
+ " entry: settings open your settings screen",
2495
+ " entry: item, id the item with this id",
2496
+ "",
2497
+ "Sent only for what the frame's LATEST `gem.menu` declared, and only to a",
2498
+ "frame that advertised `gameMenu`. The parent closes its menu first, so the",
2499
+ "`gem.pause` it sent when the menu opened is lifted before this arrives:",
2500
+ "the game is running, in front, and the next thing on screen is the game's",
2501
+ "own. A frame that hears an id it no longer declares ignores it — the menu",
2502
+ "may have been open across a change.",
2503
+ "",
2504
+ "The arcade's own entries never come through here. Leaving, the mute and",
2505
+ "fullscreen reach the game as they always did (`gem.pause`,",
2506
+ "`gem.willTerminate`, `gem.prefs`), and a problem report reaches it not at",
2507
+ "all."
2508
+ ],
2509
+ "fields": {
2510
+ "entry": { "kind": "enum", "values": ["settings", "item"] },
2511
+ "id": {
2512
+ "kind": "string",
2513
+ "maxLength": 32,
2514
+ "optional": true,
2515
+ "$comment": "Present when `entry` is `item`: the id from `gem.menu`."
2516
+ }
2517
+ },
2518
+ "rateLimit": { "perMinute": 60 }
2519
+ },
2520
+
2419
2521
  {
2420
2522
  "type": "gem.requestShare",
2421
2523
  "direction": "frameToParent",
@@ -190,6 +190,13 @@
190
190
  "gates": [
191
191
  "gem.requestShare"
192
192
  ]
193
+ },
194
+ "gameMenu": {
195
+ "implemented": true,
196
+ "gates": [
197
+ "gem.menu",
198
+ "gem.menuSelect"
199
+ ]
193
200
  }
194
201
  }
195
202
  }