@indiegems/gem-web-sdk 18.1.1 → 19.0.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.
@@ -40,7 +40,8 @@ itself. You get an outcome.
40
40
  - [8. The Arcade Store's offers](#8-the-arcade-stores-offers)
41
41
  - [9. Buying from the Arcade Store](#9-buying-from-the-arcade-store)
42
42
  - [10. Letting the Arcade show one offer](#10-letting-the-arcade-show-one-offer)
43
- - [11. Outcomes and reasons](#11-outcomes-and-reasons)
43
+ - [11. What the player holds, and a VIP door](#11-what-the-player-holds-and-a-vip-door)
44
+ - [12. Outcomes and reasons](#12-outcomes-and-reasons)
44
45
 
45
46
  ## 1. Two surfaces, and which one is real money
46
47
 
@@ -67,7 +68,7 @@ three and never grows:
67
68
 
68
69
  `reason` is a short code that says why. The set is open: a new Arcade build may send a reason your
69
70
  game has never seen, so branch on `outcome` and treat `reason` as a hint. The codes in use today are
70
- in [§11](#11-outcomes-and-reasons).
71
+ in [§12](#12-outcomes-and-reasons).
71
72
 
72
73
  ## 3. What is available here
73
74
 
@@ -316,9 +317,9 @@ Pick by who can say what the offer is:
316
317
  - **Do not show a price on the button.** The sheet shows the right one for this player, which a
317
318
  sale or a pass can change, and `priceDisplay` does not say either of those. If you want a price on
318
319
  screen, draw the offer yourself and use `purchase`.
319
- - **You cannot tell whether the player holds a pass.** The Arcade keeps that to itself, and the
320
- sheet tells the player. So the button stays up for a holder, and that is fine: the sheet is what
321
- they see, and they choose from there.
320
+ - **The sheet does not tell you what the player holds.** It tells the player, so a button that
321
+ stays up for a holder is fine: they see what they already have, and choose from there. To change
322
+ your own screen for a holder, read `gem.me.passes` ([§11](#11-what-the-player-holds-and-a-vip-door)).
322
323
  - **Hide the button when the SKU is not in `offers()`.** A SKU that is not on sale here answers
323
324
  `unavailable` / `not_found`, and the player would have pressed a button that does nothing.
324
325
  - **Read `showOfferAvailable`, not `available`.** An older Arcade can sell through `purchase` and
@@ -332,7 +333,145 @@ closed checkout, shared with `purchase`: the next ask of either kind is `decline
332
333
  while. The press shows nothing during a back-off, so give the player something else to do on that
333
334
  screen.
334
335
 
335
- ## 11. Outcomes and reasons
336
+ ## 11. What the player holds, and a VIP door
337
+
338
+ ```js
339
+ const passes = await gem.me.passes.list();
340
+ // [{ pass: 'vip', held: true, endsAt: '2026-11-29T12:34:56Z' }]
341
+
342
+ const vip = await gem.me.passes.get('vip'); // one pass by its slug
343
+ if (vip.held) drawVipBadge();
344
+ ```
345
+
346
+ `gem.me.passes` says which store passes the player holds. **VIP is the pass `vip`**, its offering
347
+ slug. Key on the slug, which is stable and not localized, and never on a display name.
348
+
349
+ - **It lists every pass games may see, held or not.** An admin marks a pass as visible to games, and
350
+ the list has one entry for each, so its shape never says what the player bought. A pass that is not
351
+ marked is left out, and `get` reads a pass that is not listed as not held.
352
+ - **A pass is a slug, whether it is held, and when it ends.** No price, no name, no perks, nothing
353
+ about how the player got it, and nothing about a pass that has ended.
354
+ - **`endsAt` is the exact end,** grace and any queued period included, and `null` when the pass is not
355
+ held or is held with no end, which support can grant. Read `held` first: `null` alone never means
356
+ ended.
357
+ - **A guest reads as not holding.** Every pass is `held: false`, and that is no error.
358
+ - **A read that fails rejects with a `GemApiError`, as every `gem.me` read does.** That is unknown,
359
+ not "not held", so keep what you drew. It is the one call on this page that rejects.
360
+ - **It is a read over your play token,** not an ask of the Arcade. It needs no press, and works
361
+ wherever your game has a token, including where the store is off: holding a pass is not buying one.
362
+ Each call goes to the platform, so read when you draw and do not poll.
363
+
364
+ ### The browser draws, the server decides
365
+
366
+ **Never decide anything from `gem.me.passes`.** It runs in the player's browser, and a player can
367
+ make any answer come back. It is for drawing the door. What is behind the door comes from your
368
+ function, which asks the platform and answers with the contents. A level that ships in your build is
369
+ not behind a door at all, because a player can open it by editing the page.
370
+
371
+ ```js
372
+ // 1. DRAWING. Open or shut. Nothing here unlocks anything.
373
+ function drawVipDoor(passes) {
374
+ door.drawOpen(passes.some((p) => p.pass === 'vip' && p.held));
375
+ }
376
+
377
+ await gem.awaitBridge();
378
+ try {
379
+ drawVipDoor(await gem.me.passes.list());
380
+ } catch {
381
+ door.drawShut(); // unknown: the press below still asks the function
382
+ }
383
+ gem.me.passes.on('changed', drawVipDoor); // opens after a purchase, locks when VIP ends
384
+
385
+ // 2. DECIDING. The press asks the function. The browser's reading is not part of the call.
386
+ door.onPress = async () => {
387
+ let answer;
388
+ try {
389
+ answer = (await gem.functions.invoke({ door: 'vip-lounge' })).json();
390
+ } catch {
391
+ showDoorIsStuck(); // nothing decided: not "you need VIP"
392
+ return;
393
+ }
394
+ if (answer.allowed) enterLounge(answer.lounge);
395
+ else showHowToGetVip();
396
+ };
397
+
398
+ // 3. BUYING. From the press on your own "How to get VIP" button.
399
+ howToGetVipButton.onclick = () => gem.store.showOffer(VIP_SKU);
400
+ ```
401
+
402
+ The function, in Rust, because only the platform's answer counts:
403
+
404
+ ```rust
405
+ use gem::functions::host::{self, ApiRequest, Caller};
406
+
407
+ // The input says which door. It never says who is asking, or what they hold.
408
+ let context = host::get_context();
409
+ let Caller::Player(_) = context.caller else {
410
+ return Err("expected a player's call".into());
411
+ };
412
+ let read = host::call_api(&ApiRequest {
413
+ method: "GET".into(),
414
+ path: format!("/v1/games/{}/players/me/passes", context.game_id),
415
+ body: Vec::new(),
416
+ })?;
417
+ if read.status != 200 {
418
+ return Err(format!("the pass read answered {}", read.status)); // decides nothing
419
+ }
420
+ let body: serde_json::Value = serde_json::from_slice(&read.body).map_err(|e| e.to_string())?;
421
+ let vip = body["passes"]
422
+ .as_array()
423
+ .into_iter()
424
+ .flatten()
425
+ .any(|pass| pass["pass"] == "vip" && pass["held"] == true);
426
+ let answer = if vip {
427
+ serde_json::json!({ "allowed": true, "lounge": lounge_contents() })
428
+ } else {
429
+ serde_json::json!({ "allowed": false })
430
+ };
431
+ Ok(answer.to_string().into_bytes())
432
+ ```
433
+
434
+ "Me" on that route is the player who invoked the function. A function your server invoked has no
435
+ player, and the route refuses it with `403`. The body is `{ pass, held, ends_at }` for each pass.
436
+
437
+ - **If the door and the function disagree, the function is right.** The player sees "How to get VIP"
438
+ or the lounge, and either way nobody got in by editing the page. Redraw the door when the function
439
+ says no.
440
+ - **A guest has no VIP and cannot buy it from here.** Their door is shut, and `offers()` answers
441
+ `unavailable` / `sign_in_required`, so offer `gem.account.requestSignIn` instead of the button.
442
+
443
+ ### When the door moves by itself
444
+
445
+ While something listens to `on('changed')`, the SDK reads again at each of these, and calls you only
446
+ if the answer differs:
447
+
448
+ | Moment | Why you cannot rely on something else |
449
+ | --------------------------------------------------------- | -------------------------------------------------------------------------- |
450
+ | `store.purchase` or `store.showOffer` answers `ok` | `onSettled` does not fire after a plain `ok` |
451
+ | Either answers `unavailable` / `error` | The outcome is unknown, and the player may have paid |
452
+ | `gem.store.onSettled` fires | `ok` / `pending` still reads as not held |
453
+ | Your game resumes | A purchase in the Arcade's own store, from the Gem menu, sends no event at all |
454
+ | The player signs in | A guest who signs in may hold VIP |
455
+ | A held pass reaches `endsAt` | VIP does not renew, so the door has to lock by itself |
456
+
457
+ A read that fails changes nothing, and is tried again later. A read that was already on its way when
458
+ a purchase ended is never reused, so a stale answer cannot shut the door on the buyer. If you read
459
+ without a listener, call `list()` yourself at those moments; it is always fresh.
460
+
461
+ `on` never fires in a function, which has none of those moments.
462
+
463
+ ### On a dedicated server
464
+
465
+ The platform has a read for this,
466
+ `GET /v1/games/{game_id}/matches/{match_id}/players/{player_id}/passes`, with the same body, for a
467
+ player in your live match. **A server bundle cannot call it yet**, and the SDK has no method for it,
468
+ because it needs the allocation nonce: the route requires `X-Alloc-Nonce`, which the platform's
469
+ runtime holds and never hands to your bundle. The boost read has the same limit. Reaching it takes a
470
+ hook in the runtime, like the one behind `gem.match.end()`.
471
+
472
+ Until then, decide in a function, or leave a VIP door out of a dedicated-server match.
473
+
474
+ ## 12. Outcomes and reasons
336
475
 
337
476
  | Call | `ok` | `declined` | `unavailable` |
338
477
  | ---------------- | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
package/docs/changelog.md CHANGED
@@ -16,6 +16,24 @@ 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
+ ## 19.0.1
20
+
21
+ Upgrading from 19.0.0. Impact: none.
22
+
23
+ Nothing in this release asks anything of a game.
24
+
25
+ ## 19.0.0
26
+
27
+ Upgrading from 18.1.1. Impact: required.
28
+
29
+ ### Rebuild
30
+
31
+ - The player scope on the game object is now typed as PlayerScope instead of EntityScope. Recompile your game. If you annotated that value, or passed it to code that expects EntityScope, change the annotation to PlayerScope. Otherwise nothing else needs to change.
32
+
33
+ ### New capability
34
+
35
+ - A game can now read which passes its player holds. Every pass the Arcade marks as visible to games is reported, held or not, with the time it ends. The read is exposed through PassesApi on PlayerScope, and it is not available on EntityScope because a character holds no pass. The result only tells you who holds a pass; your game still decides what the pass unlocks. PassesEvent lets you listen for changes, and the SDK re-reads after a store purchase completes, on resume, after a sign-in, and when a held pass ends, so a buyer's doors open without a reload. GamePass describes one entry in the result.
36
+
19
37
  ## 18.1.1
20
38
 
21
39
  Upgrading from 18.1.0. Impact: none.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "18.1.1"
53
+ "@indiegems/gem-web-sdk": "19.0.1"
54
54
  }
55
55
  ```
56
56
 
@@ -198,6 +198,19 @@ Read every instance of a definition without walking the whole bag:
198
198
  const swords = await gem.me.inventory.instances('iron-sword');
199
199
  ```
200
200
 
201
+ ⚠️ **`instanceId` is never a key on its own.** A fungible item has one row per definition, and every
202
+ one of those rows carries the same placeholder, `__stack__`, so all of a player's fungible items
203
+ share one `instanceId`. A live game removed repeated rows while paging the bag by `instanceId`
204
+ alone: only the last fungible item survived, every other look the player had bought read as not
205
+ owned, and the game offered them for sale again. Key a row by its definition and instance
206
+ together, and answer "does the player hold this?" from `itemDefinitionId` and `quantity`:
207
+
208
+ ```js
209
+ const rowKey = (item) => `${item.itemDefinitionId}:${item.instanceId}`;
210
+ const holds = (bag, itemDefinitionId) =>
211
+ bag.items.data.some((item) => item.itemDefinitionId === itemDefinitionId && item.quantity > 0);
212
+ ```
213
+
201
214
  ⚠️ **An item's `properties` and `customData` are untrusted, and rendering them wrong is a
202
215
  cross-player attack.** They are typed `unknown` **because the platform does not validate their
203
216
  contents** — every read of them is a parse, not a trusted object. And a transfer moves an instance
@@ -18,8 +18,8 @@
18
18
  },
19
19
  {
20
20
  "file": "arcade-and-store.md",
21
- "sha256": "7320826529435d290159c8b0707f69819124607d1b38160dbc85f63b385fdea9",
22
- "updated": "2026-10-30T13:36:37Z"
21
+ "sha256": "aee6e00fa7e0f463cb09174a9afdffe10d66ed1036546e584b341e4c11bb1e27",
22
+ "updated": "2026-10-31T19:43:30Z"
23
23
  },
24
24
  {
25
25
  "file": "arcade-avatars.md",
@@ -48,8 +48,8 @@
48
48
  },
49
49
  {
50
50
  "file": "changelog.md",
51
- "sha256": "1d0fa00863bff3d03f1670d7408e6b5133e2af0e0e945a384cb790ae59b57f59",
52
- "updated": "2026-10-30T20:53:10Z"
51
+ "sha256": "069554f114dab51df5461c7b4e7b65339886bc0bc1c455baad83688aad3ee4fa",
52
+ "updated": "2026-11-02T15:26:17Z"
53
53
  },
54
54
  {
55
55
  "file": "daily-challenges.md",
@@ -78,13 +78,13 @@
78
78
  },
79
79
  {
80
80
  "file": "getting-started.md",
81
- "sha256": "46d4c308ae9c44c480924c692e6c6bb172d3f5cd33d222fc4d6227c540049ffa",
82
- "updated": "2026-10-30T20:53:10Z"
81
+ "sha256": "1175cae0795051d2b2f0d736c49e60ba455197273bcb09c3e2c565ab529be4b6",
82
+ "updated": "2026-11-02T15:26:17Z"
83
83
  },
84
84
  {
85
85
  "file": "inventory-and-catalogs.md",
86
- "sha256": "9c898355af92ab7e17035bad964dce57b26e64624b18e83149b88ae10b96fd9c",
87
- "updated": "2026-10-30T13:36:37Z"
86
+ "sha256": "a4763fe2418c0e24639ca58c27f30ffc416f07528857402a6f9ab3fee147f4f6",
87
+ "updated": "2026-11-01T21:49:36Z"
88
88
  },
89
89
  {
90
90
  "file": "leaderboards.md",
@@ -138,8 +138,8 @@
138
138
  },
139
139
  {
140
140
  "file": "server-bundles.md",
141
- "sha256": "37c9b544d3c2d7aca21ef1319f385188971975a133f306cea20aedf110f5244a",
142
- "updated": "2026-10-30T20:53:10Z"
141
+ "sha256": "e3e49043fa4c6744df9c7f4c5e901511a4e9832d7f46f6880f1995648bacbf09",
142
+ "updated": "2026-11-02T15:26:17Z"
143
143
  },
144
144
  {
145
145
  "file": "serverless-functions-js.md",
@@ -163,8 +163,8 @@
163
163
  },
164
164
  {
165
165
  "file": "what-a-game-must-do.md",
166
- "sha256": "8faa9ef2cef2a5eb73cf95d7c52af914325c2bc26834625c9cb4b5748eecc616",
167
- "updated": "2026-10-30T14:09:09Z"
166
+ "sha256": "17df2ebdafe56ac5eafdf4eba783c97025ddd9e0ee88b7b43d25718908496b2a",
167
+ "updated": "2026-11-02T14:57:41Z"
168
168
  }
169
169
  ]
170
170
  }
@@ -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/v18.1.1/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v18.1.1/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v19.0.1/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v19.0.1/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -829,6 +829,12 @@ The short version: the browser half is what a player's **session** can do, and t
829
829
  what a **match** can do. `gem.rooms.join()` has no meaning to a process that exists because a match
830
830
  was already allocated.
831
831
 
832
+ **`gem.me.passes` is browser and function only**, and that is a limit rather than a principle. The
833
+ platform has a match read of which passes a player in your match holds, but your bundle cannot call
834
+ it: it requires the allocation nonce, which the runtime never hands to a bundle, as the boost read
835
+ does, and it answers `403` to every caller until game servers are granted `passes.read`.
836
+ [arcade-and-store.md](arcade-and-store.md#on-a-dedicated-server) has what to do instead.
837
+
832
838
  **Rooms are the case where those two answers differ**, so they are worth stating separately rather
833
839
  than filed under "browser-only". `gem.rooms` — join, leave, ready up, quick-join — is a player
834
840
  acting on their own membership, and your server has none. Reading and administering the room your
@@ -1687,7 +1687,8 @@ startMusic(ctx); // start it now: it plays at once where sound is allowed, and f
1687
1687
  // context say when it is running rather than deciding from the first read.
1688
1688
  const LIFTS = ['pointerup', 'touchend', 'click', 'keydown'];
1689
1689
  const unlock = () => {
1690
- if (ctx.state === 'suspended') void ctx.resume();
1690
+ // 'interrupted' is how some WebKit versions report a context iOS is holding.
1691
+ if (ctx.state === 'suspended' || ctx.state === 'interrupted') void ctx.resume();
1691
1692
  };
1692
1693
  const settled = () => {
1693
1694
  if (ctx.state !== 'running') return;
@@ -1703,6 +1704,26 @@ if (ctx.state !== 'running') {
1703
1704
  A `<audio>` or `<video>` element works the same way. Call `play()` straight away, and only if it
1704
1705
  rejects, call it again from the first lift.
1705
1706
 
1707
+ **Coming back from the background is the SDK's.** On an iPhone, a player who swipes your game away
1708
+ and comes back finds its sound cut off: iOS leaves the `AudioContext` `suspended` or `interrupted`,
1709
+ stops your media elements, and restarts neither. The snippet above has stopped listening by then, so
1710
+ the SDK does this part for you. After the page was hidden, or a context left `running` without your
1711
+ asking, it listens again for the player's next lift in your frame, resumes the contexts that were
1712
+ running and replays the elements that were playing when the sound was cut off, then stops
1713
+ listening. You write nothing for it. It stays out of your way:
1714
+
1715
+ - **A context you `suspend()` yourself is left alone** until you call `resume()`. If you pause your
1716
+ audio when the page is hidden and resume it when it comes back, keep doing that: the SDK steps in
1717
+ only when your own resume did not take, which on iOS it often does not without a tap.
1718
+ - **An element you `pause()` is not replayed**, and nothing is restarted while the Arcade has your
1719
+ game paused: your `onPause(false)` handler decides what plays when the menu closes.
1720
+ - **The Arcade's mute still holds.** A context the SDK resumes is still silent while the player has
1721
+ the Arcade muted.
1722
+
1723
+ It reaches the audio the mute does: created after `createGem`, with `manageAudio` left on. With
1724
+ `manageAudio: false` it is yours: listen for the lift again on `visibilitychange` and on the
1725
+ context's `statechange`, rather than once.
1726
+
1706
1727
  Never block the first frame on audio, never show an error for it, and do not ask the player to "tap
1707
1728
  to start" just to unlock sound.
1708
1729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "18.1.1",
3
+ "version": "19.0.1",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",