@indiegems/gem-web-sdk 7.4.2 → 7.6.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.4.2"
53
+ "@indiegems/gem-web-sdk": "7.6.0"
54
54
  }
55
55
  ```
56
56
 
@@ -157,11 +157,13 @@ your reports arrive with no picture. Two-dimensional canvas games need nothing h
157
157
  covers overriding the capture and attaching your own debug state, which is the part actually
158
158
  worth spending effort on.
159
159
 
160
- **There is no offline mode.** A game must be framed by an arcade page to reach the platform:
161
- unframed it still constructs and runs, but the bridge never connects and anything needing a
162
- token waits forever. To run framed without publishing, open your served build in the Gem
163
- Arcade app (step 6 below), or use the SDK repository's `examples/dev-harness/` — a local
164
- parent page that frames your dev server and completes a real handshake.
160
+ **There is no offline mode.** A game must be framed by a page that speaks the bridge: opened
161
+ at the top level, `createGem` throws `GemUnframedError`, which says how to run it. To run framed
162
+ without publishing, use the Gem dev host, signed in to `gemctl`: `npm run dev` in a project the
163
+ Gem tools scaffolded (`gemctl serve start . --dev`, your vite server with hot reload), or
164
+ `gemctl serve start .` for a build. The Gem Editor's Play frames it too, and the SDK
165
+ repository's `examples/dev-harness/` is a local parent page that frames your dev server. Never
166
+ open the dev server's own address.
165
167
 
166
168
  ## 5. Tell the arcade when you are ready
167
169
 
@@ -13,8 +13,8 @@
13
13
  },
14
14
  {
15
15
  "file": "analytics.md",
16
- "sha256": "3ca38659d3c7fd9087d0f17d6c0a31659246724c227cf63701e1bd9a058f0df2",
17
- "updated": "2026-09-16T22:32:35Z"
16
+ "sha256": "57fb07c3d649c2758a35660707d31b05164b363e9a425156978aed394d086339",
17
+ "updated": "2026-10-02T14:04:37Z"
18
18
  },
19
19
  {
20
20
  "file": "arcade-economy.md",
@@ -31,6 +31,11 @@
31
31
  "sha256": "cf54939a0fcee1f5a6e11b9de52b5ed498ec8c373683e92aa5bc6654b12eb0ab",
32
32
  "updated": "2026-10-01T03:49:20Z"
33
33
  },
34
+ {
35
+ "file": "changelog.md",
36
+ "sha256": "02083c9ed1d62baed5a297ca71b602608afa90320afb73a21ff03187da11346f",
37
+ "updated": "2026-10-02T04:41:46Z"
38
+ },
34
39
  {
35
40
  "file": "cross-promotion.md",
36
41
  "sha256": "3a8121a9c973e4a1bfed076a3226d139f8b4e38a173a06ea9f08000c72bca4b4",
@@ -63,8 +68,8 @@
63
68
  },
64
69
  {
65
70
  "file": "getting-started.md",
66
- "sha256": "732e8cae09fef3ae47511f917573b01479369c77c2cfeff5eac75137447b8def",
67
- "updated": "2026-10-01T20:20:27Z"
71
+ "sha256": "fd859a01988ecf57c857dcfba9958a8a32a65ff2a8ecfaf55f33f9045d368180",
72
+ "updated": "2026-10-02T17:28:15Z"
68
73
  },
69
74
  {
70
75
  "file": "inventory-and-catalogs.md",
@@ -113,8 +118,13 @@
113
118
  },
114
119
  {
115
120
  "file": "server-bundles.md",
116
- "sha256": "254655c3a33fed1164cfc7b44b05d3a817bd6d4db087621b7436dac4d96ceecc",
117
- "updated": "2026-10-01T20:20:27Z"
121
+ "sha256": "8190da0484b7e1ae6f636068b06a43729f3ef132488f5a0cfd2fc5c2988bc55b",
122
+ "updated": "2026-10-02T17:28:15Z"
123
+ },
124
+ {
125
+ "file": "serverless-functions-js.md",
126
+ "sha256": "86b0f9f0b742a662ebb78b1538cbbc399309725faf22f46f613b63b5e0864082",
127
+ "updated": "2026-10-02T17:17:29Z"
118
128
  },
119
129
  {
120
130
  "file": "serverless-functions.md",
@@ -128,8 +138,8 @@
128
138
  },
129
139
  {
130
140
  "file": "what-a-game-must-do.md",
131
- "sha256": "f34156c8e369197a1b7ce010ccd837e5ee38cd20150c3836e9da8f38811b06e6",
132
- "updated": "2026-10-01T19:03:17Z"
141
+ "sha256": "a0a2952a2b16b43b0af831ff58ad8fabe47b414f57fccd2cb2d866861a65465e",
142
+ "updated": "2026-10-02T14:04:37Z"
133
143
  }
134
144
  ]
135
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.4.2/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v7.4.2/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.6.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v7.6.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -0,0 +1,139 @@
1
+ # Serverless functions in JavaScript
2
+
3
+ <!-- gem-web: unpublished — the platform does not run JavaScript functions yet -->
4
+
5
+ A serverless function can be written in JavaScript or TypeScript instead of Rust: the language your
6
+ game is already written in, so the verification code in your browser game moves into a function
7
+ with no change beyond bundling. You upload one bundled ES module, and the platform runs it on a
8
+ JavaScript engine of its own.
9
+
10
+ Everything [serverless-functions.md](serverless-functions.md) says about what a function is for,
11
+ what it may change, its limits, channels, and calling it from your game holds here unchanged. This
12
+ page covers only what is different: how you write one, how the SDK behaves inside it, and how
13
+ publishing checks it.
14
+
15
+ ## 1. The contract
16
+
17
+ The same as a component's: bytes in, bytes out, one fresh instance per call, nothing kept between
18
+ calls. Your module exports **exactly one** of two functions, and which one decides how the bytes
19
+ are read and written:
20
+
21
+ | Export | Input | Return |
22
+ | ------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
23
+ | `handle(input)` | `Uint8Array`: the request body, byte for byte | `Uint8Array`, `ArrayBuffer` or `string` (sent as UTF-8); anything else is an error |
24
+ | `handleJson(input)` | the body parsed as JSON; an empty body is `undefined` | any JSON-serializable value, sent as its JSON text; `undefined` is sent as `null`; a `BigInt` is an error |
25
+
26
+ Either may be `async`; the platform waits for the promise. Exporting neither, or both, is refused
27
+ when you upload. A caller cannot tell which kind of function answered: `gem.functions.invoke`, the
28
+ limits and the refusals in serverless-functions.md are identical for both.
29
+
30
+ **A thrown exception is `Err`.** The call fails with `function_error`, and `err.functionMessage` is
31
+ the exception's `name: message` (a thrown value that is not an `Error` is its `String()`). The
32
+ message and the stack go to the platform's log for the invocation, never to the caller. A
33
+ `handleJson` whose input is not JSON, or whose return value JSON cannot carry, fails the same way.
34
+
35
+ **`console.*` reaches the invocation's log** at the matching level (`debug`, `info`, `warn`,
36
+ `error`); `console.log` and every other method log at `info`. As with a component's `log`, nothing
37
+ reads it back to you: the invocation id is what you quote about one call.
38
+
39
+ ## 2. Writing one
40
+
41
+ ```bash
42
+ gemctl function init --lang js functions/score-check
43
+ cd functions/score-check && npm install && npm run build
44
+ gemctl function upload dist/function.js
45
+ ```
46
+
47
+ **`init --lang js`** writes a `package.json` whose `build` script type-checks and then bundles to
48
+ one ES module, a TypeScript starter handler and a `tsconfig.json`. gemctl runs neither step:
49
+ `npm run build` is yours, the way `cargo build` is for a component.
50
+
51
+ A handler looks like this:
52
+
53
+ ```ts
54
+ import { createGem } from '@indiegems/gem-web-sdk/function';
55
+
56
+ export async function handleJson(claim: { level: number }) {
57
+ const gem = createGem();
58
+ console.info(`level ${claim.level} claimed by ${gem.identity?.playerId ?? 'a game server'}`);
59
+ // Decide from what the player cannot forge, and make any write through `gem`
60
+ // here, before answering: the answer itself is only advice to the browser.
61
+ return { accepted: false };
62
+ }
63
+ ```
64
+
65
+ The package is the one your game already depends on, installed as
66
+ [getting-started.md](getting-started.md) shows. What makes a check worth running is
67
+ serverless-functions.md section 1, and it applies unchanged: a save or a setting the player's own
68
+ browser can write is the player's claim, not evidence.
69
+
70
+ **Imports.** The module may import the SDK and nothing else, under any of the three names a game
71
+ already uses: `@indiegems/gem-web-sdk/function`, `@indiegems/gem-web-sdk/sdk`, or
72
+ `@indiegems/gem-web-sdk/game`. Inside a function all three are the same module, the function
73
+ entry, so shared code keeps its import. A name that module does not export, such as
74
+ `defineServer`, fails every call with `function_error`. Everything else your code needs is bundled
75
+ in, which is what the starter's build does. An `import()` at runtime is refused at upload.
76
+ Top-level `await` is allowed and runs on every call.
77
+
78
+ **What the engine gives you.** `Date`, `performance`, `Math.random`, `crypto.getRandomValues`,
79
+ `crypto.randomUUID`, `TextEncoder`, `URL` and `fetch` all work. `fetch` reaches only the platform
80
+ API, through the SDK, and the save links the platform hands you; any other URL is refused. A timer
81
+ waits at most 10 ms: a function has no reason to sleep, and a long one would run out the wall
82
+ clock.
83
+
84
+ **The SDK version your function runs is the one the platform bundles**, not the one in your
85
+ `package.json`; the starter pins the matching version. A method added in a newer SDK reaches a
86
+ function when the platform's engine picks that version up.
87
+
88
+ ## 3. The SDK inside a function
89
+
90
+ `createGem()` from `@indiegems/gem-web-sdk/function` is synchronous, `awaitBridge()` and
91
+ `awaitIdentity()` resolve at once, and `gem.me` is the player who called. So a game's boot sequence
92
+ runs unchanged, and the same `gem.me.saves.get`, `gem.leaderboards.get` and `gem.profiles.of` calls
93
+ work, with the same types and the same errors. The function's game and channel come from the call;
94
+ nothing you write names them. `gem.context` is who called, for which game and channel, and
95
+ `gem.identity` is the same three fields a browser game reads from its own `gem.identity`.
96
+
97
+ What a browser game has that is backed by the page or the arcade (ads, purchases, rooms, device
98
+ settings, sharing) is not on `gem` inside a function. Four more differences, each following from
99
+ how a function reaches the platform:
100
+
101
+ - **`gem.me` is the invoker.** When a game server invoked the function there is no player, and
102
+ reading `gem.me` or `gem.transfers` throws saying so; `gem.identity` is `null`. Which writes a
103
+ player-invoked function may make is unchanged (serverless-functions.md section 2).
104
+ - **No conditional requests, and no retries.** The platform call a function makes carries no
105
+ headers, so an `ifMatch` or `ifNoneMatch` cannot travel. A call carrying one is refused with
106
+ `TransportRefusal` naming the option, rather than sent as an overwrite that checks nothing.
107
+ Nothing is retried either: a retry is another platform call and a wait is your function's wall
108
+ clock, so your code decides.
109
+ - **`gem.functions` is absent.** A function may not invoke a function.
110
+ - **`gem.gameConfig` is absent.** The config document is not reachable from a function, so
111
+ `gem.me.currencies.get()` takes a currency id rather than a slug.
112
+
113
+ `gem.friends.challenge()` answers `unavailable` without asking anything, because there is nobody in
114
+ front of a screen to confirm it, and `gem.platform.earnAllowance.on()` never fires.
115
+
116
+ ⚠️ **Flush analytics before you return.** A page flushes on a timer and as it closes; a function
117
+ ends the moment your handler settles, so `await gem.analytics.flush()` before you return, or the
118
+ events may be lost.
119
+
120
+ ## 4. Check before you upload
121
+
122
+ ```bash
123
+ gemctl function check dist/function.js
124
+ ```
125
+
126
+ `check` runs the platform's publish-time validation for JavaScript on your machine, and an upload
127
+ answers `400 source_invalid` with the same `details.reason`:
128
+
129
+ | Reason | What it usually is | What to change |
130
+ | ------------------ | ------------------------------------------------------------------------- | ------------------------------------------------- |
131
+ | `not_a_module` | A parse error, with its line and column, or a file that is not UTF-8 | Fix the syntax; bundle to ES2022 |
132
+ | `forbidden_import` | An `import` or `export … from` of anything but the SDK, or an `import()` | Bundle the dependency in; drop the dynamic import |
133
+ | `missing_handle` | Neither `handle` nor `handleJson` is exported (`export *` does not count) | Export one of them from the module itself |
134
+ | `ambiguous_handle` | Both are exported | Keep one |
135
+ | `too_large` | Over the size limit, the same one a component has | Bundle less; check what your build pulled in |
136
+
137
+ An upload also answers `source_invalid`, rather than `component_invalid`, for `not_uploaded`,
138
+ `size_mismatch` and `sha256_mismatch`: upload again. Promoting, channels and invoking are the same
139
+ as for a component (serverless-functions.md sections 4 to 7).
@@ -187,9 +187,9 @@ try {
187
187
 
188
188
  `awaitBridge()` resolves when the bridge leaves `waiting` — connected, refused or closed — or at a
189
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.
190
+ `connected` is a Gem host listening; anything else is a boot with no session, which your game must
191
+ survive anyway. The wait costs nothing when the handshake is quick: it settles the moment the frame
192
+ connects, and at once when the page that framed it is refused.
193
193
 
194
194
  The measured stakes, from the first game to hit this on an iPhone: listener up at ~300ms, then a
195
195
  main-thread block — the parent's offer, queued behind the block, was delivered twenty seconds later
@@ -515,8 +515,8 @@ them**, so the whole migration for a stylesheet is one declaration per edge:
515
515
  ```
516
516
 
517
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.
518
+ arcade or dev host, a third-party embedder — the properties are never set and the browser's own
519
+ answer stands, which is what your game did before.
520
520
  Under a parent that has, the variable wins. One line, correct in both worlds. The four names are
521
521
  `--gem-safe-top`, `--gem-safe-right`, `--gem-safe-bottom` and `--gem-safe-left`.
522
522
 
@@ -538,17 +538,21 @@ the `var()` above, or read `gem.viewport`. The release checklist looks for the b
538
538
 
539
539
  ### The arcade's own controls: reserved areas
540
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.
541
+ On a phone your game is full-bleed, and the arcade keeps one row of its own across the top of it,
542
+ with the middle left open for a centred camera:
548
543
 
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.
544
+ - **Top-left: the Back tab** — the Gem Arcade wordmark, always there. **Do not put a back arrow of
545
+ your own top-left**: it would sit under the arcade's tab, which leaves the game, while yours would
546
+ mean something else.
547
+ - **Top-right: the player's wallets** (Gems, Tickets, their achievement score) while your screen
548
+ [shows them](#the-arcades-wallet-show-it-never-draw-it). Put your own settings, pause or menu
549
+ button top-right **below** them, at `top: var(--gem-reserved-tr-height, …)` — and when gameplay
550
+ hides the wallets with `gem.setWalletVisible(false)`, that rectangle shrinks to nothing and your
551
+ button moves up into the corner on its own.
552
+
553
+ Those rectangles of your frame are **reserved**: keep essential UI and every touch target out of
554
+ them. **Never hard-code their size or height.** They differ by phone, and in native fullscreen the
555
+ whole row may rise into the camera band — read them, as below.
552
556
 
553
557
  An inset cannot say that — as a top inset, a tab a few dozen pixels wide would take your whole top
554
558
  strip. So `gem.viewport` also carries the rectangles, in the same CSS pixels of your frame, measured
@@ -567,13 +571,21 @@ from its top-left corner:
567
571
  reserved rectangles reach in from its two edges. Push the corner's UI clear of it:
568
572
 
569
573
  ```css
570
- /* a score in the top-left corner, below the cluster */
574
+ /* settings in the top-right corner, below the wallets — and up in the corner when they are hidden */
575
+ #settings {
576
+ top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tr-height, 0px));
577
+ right: var(--gem-safe-right, 0px);
578
+ }
579
+ /* a score in the top-left corner, below the tab */
571
580
  #score {
572
581
  top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tl-height, 0px));
573
582
  left: var(--gem-safe-left, 0px);
574
583
  }
575
- /* or a row along the top, starting after it */
576
- #top-bar { padding-left: var(--gem-reserved-tl-width, 0px); }
584
+ /* or a row along the top, between the two */
585
+ #top-bar {
586
+ padding-left: var(--gem-reserved-tl-width, 0px);
587
+ padding-right: var(--gem-reserved-tr-width, 0px);
588
+ }
577
589
  ```
578
590
 
579
591
  All eight are written on every push, `0px` for a clear corner, so a rotation never leaves a stale
@@ -581,9 +593,9 @@ value; they are never set under an embedder that sends no viewport, which is wha
581
593
  fallback is for. A rectangle belongs to the corner its centre lies nearest to. For a HUD laid out
582
594
  from script, read `gem.viewport.reserved` — `[]` when nothing is covered.
583
595
 
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.
596
+ **Lay out against it whenever it changes.** Shown wallets reserve the top-right corner; hiding them
597
+ shrinks it to nothing, and only the tab's top-left rectangle is left. A wallet the arcade pops up for
598
+ a moment while they are hidden never reserves anything — it floats over your game and moves nothing.
587
599
 
588
600
  **Older SDKs get it folded into the top inset.** A game on an SDK from before reserved areas cannot
589
601
  read the rectangles, so the arcade sends it none and instead raises `insets.top` — and so
@@ -746,6 +758,13 @@ window.addEventListener('keydown', resume);
746
758
  Resume on the first input and carry on — never block the first frame on audio, never show an error
747
759
  for it, and do not ask the player to "click to start" just to unlock sound.
748
760
 
761
+ **Such a launch is not counted as a play until the player touches it.** The Arcade tells the SDK it
762
+ started your game on its own, and the SDK holds your analytics — the platform's session and boot
763
+ events and your own `track` calls — until the first real input inside your game, then sends them
764
+ as a session that starts there. You write nothing for it; [analytics.md](analytics.md) has the
765
+ detail. So design the first seconds to be **watchable**: a game that sits on a "press any key"
766
+ screen autoplays as a still picture.
767
+
749
768
  ### Doing it yourself instead
750
769
 
751
770
  `createGem({ manageAudio: false })` turns the SDK's silencing off — for a game that would rather fade
@@ -757,7 +776,8 @@ and the release checklist checks that it does.
757
776
 
758
777
  The player's platform balances — Gems, Tickets (`ARCADE_TICKETS`) and their achievement score —
759
778
  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.
779
+ Arcade takes every figure from the platform and draws the wallets itself, in the top-right corner
780
+ of your frame on a phone.
761
781
 
762
782
  **They are shown unless your game hides them.** A game that does nothing has every wallet on show
763
783
  the whole time. The one rule: **hide them during gameplay, show them on your menus.**
@@ -773,10 +793,10 @@ pauseMenu.onOpen = () => gem.setWalletVisible(true);
773
793
  - **It is screen state, not a toggle.** Each call is the whole answer and replaces the last. Call it
774
794
  as often as you like: before the Arcade connects the state is held and sent when it does, and
775
795
  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.
796
+ - **Shown wallets take space; a pop does not.** Shown, they reserve the top-right
797
+ [corner](#the-arcades-own-controls-reserved-areas), so lay your menu out against
798
+ `--gem-reserved-tr-*` — your settings button sits below them; hidden, the corner is yours again and
799
+ the same `var()` moves the button up. A pop floats over your game and never moves its layout.
780
800
  - **Narrowing is optional.** On a screen that sells in one currency — a ticket store — you may show
781
801
  only that one: `gem.setWalletVisible(true, { only: ['ARCADE_TICKETS'] })`, with `'score'` for the
782
802
  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.4.2",
3
+ "version": "7.6.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -31,6 +31,10 @@
31
31
  "types": "./dist/server-api.d.ts",
32
32
  "import": "./dist/gem-web-sdk.server-api.js"
33
33
  },
34
+ "./function": {
35
+ "types": "./dist/function.d.ts",
36
+ "import": "./dist/gem-web-sdk.function.js"
37
+ },
34
38
  "./schema": "./schema/bridge.v1.json",
35
39
  "./brand/*": "./brand/*"
36
40
  },
@@ -45,6 +49,7 @@
45
49
  "docs/*.md",
46
50
  "docs/manifest.json",
47
51
  "docs/api/*.md",
48
- "docs/api/reference.json"
52
+ "docs/api/reference.json",
53
+ "CHANGELOG.md"
49
54
  ]
50
55
  }
@@ -205,6 +205,33 @@
205
205
  "maxLength": 2048,
206
206
  "format": "origin",
207
207
  "$comment": "The parent's own origin, so the frame can check who is embedding it."
208
+ },
209
+ "unbidden": {
210
+ "kind": "boolean",
211
+ "optional": true,
212
+ "$comment": [
213
+ "True when the parent started this game WITHOUT the player asking — it",
214
+ "autoplays on an information page, say — rather than because the player",
215
+ "pressed Play. Absent or false is an ordinary launch.",
216
+ "",
217
+ "ON THE HANDSHAKE because it must reach the frame before anything the frame",
218
+ "counts: the SDK opens its play session the moment the handshake lands, and",
219
+ "a fact that arrived on any later message would arrive after the session it",
220
+ "is about. A per-launch FIELD rather than a capability, because a",
221
+ "capability is a deployment-wide fact about what a peer implements and this",
222
+ "is a fact about one launch.",
223
+ "",
224
+ "Sent to every frame, since the parent cannot know which grammar a frame",
225
+ "speaks before it answers; a frame that predates the field ignores it, as a",
226
+ "receiver ignores any unknown field. What a frame that understands it does —",
227
+ "holds every analytics event that would count a play until the first real",
228
+ "input inside the frame, and then sends `gem.engaged` — it says by",
229
+ "advertising `engagement` in `gem.handshake.accept`.",
230
+ "",
231
+ "A presentation and measurement fact, NOT AN AUTHORIZATION CONTROL: nothing",
232
+ "may be granted or withheld on it, and a frame cannot use it to tell a",
233
+ "player apart from anyone else."
234
+ ]
208
235
  }
209
236
  },
210
237
  "rateLimit": { "perMinute": 30 }
@@ -867,7 +894,7 @@
867
894
  "AMENDED, DELIBERATELY: this message once carried insets only. It now also",
868
895
  "carries `reserved`, rectangles of the frame covered by the parent's own",
869
896
  "controls floating over the game (a Back tab in a corner, the wallet chips",
870
- "shown beside it). An inset can only describe a full-width or full-height band,",
897
+ "shown in another). An inset can only describe a full-width or full-height band,",
871
898
  "and a corner tab expressed as a top inset would take the whole top strip",
872
899
  "from a full-bleed game to clear a control a few dozen pixels wide. A",
873
900
  "rectangle says exactly which part, and still says nothing about what the",
@@ -2022,6 +2049,40 @@
2022
2049
  "rateLimit": { "perMinute": 5 }
2023
2050
  },
2024
2051
 
2052
+ {
2053
+ "type": "gem.engaged",
2054
+ "direction": "frameToParent",
2055
+ "transport": "port",
2056
+ "$comment": [
2057
+ "The player has given this frame its first REAL input: a pointer press, a",
2058
+ "key press or a gamepad button press that the browser itself reported",
2059
+ "(`isTrusted`). Focus, hover, wheel and synthetic events do not count. Sent",
2060
+ "once per frame document, and only to a parent that advertised",
2061
+ "`engagement`.",
2062
+ "",
2063
+ "WHY THE FRAME SAYS IT. Input inside a cross-origin frame never reaches the",
2064
+ "parent's own listeners, so a parent can only guess at engagement from focus",
2065
+ "and blur — which a game autoplaying on an information page defeats, since",
2066
+ "nothing about watching it moves focus. The frame is the only party that",
2067
+ "sees the press.",
2068
+ "",
2069
+ "SENT ON EVERY LAUNCH, not only an unbidden one: the first input is a fact",
2070
+ "whatever started the game, and a parent that already knows (the player",
2071
+ "pressed its Play) is free to ignore it. On an unbidden launch it is also",
2072
+ "the moment the SDK releases the analytics it held, so it marks where the",
2073
+ "play session it reports begins.",
2074
+ "",
2075
+ "NO FIELDS, AND IT SHOULD STAY THAT WAY. Which key, where on screen, how",
2076
+ "long after load — none of it is the parent's business, and a field here",
2077
+ "would be one a game could fill with anything. Its occurrence is the whole",
2078
+ "message, like `gem.ready`. A cooperative signal from untrusted code, so",
2079
+ "NOT AN AUTHORIZATION CONTROL: a parent may stop autoplay-related chrome",
2080
+ "and count the launch as engaged on it, and must grant nothing."
2081
+ ],
2082
+ "fields": {},
2083
+ "rateLimit": { "perMinute": 5 }
2084
+ },
2085
+
2025
2086
  {
2026
2087
  "type": "gem.progress",
2027
2088
  "direction": "frameToParent",
@@ -123,6 +123,12 @@
123
123
  "gem.frameStats"
124
124
  ]
125
125
  },
126
+ "engagement": {
127
+ "implemented": true,
128
+ "gates": [
129
+ "gem.engaged"
130
+ ]
131
+ },
126
132
  "viewport": {
127
133
  "implemented": true,
128
134
  "gates": [