@indiegems/gem-web-sdk 19.0.2 → 20.1.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.
@@ -591,7 +591,6 @@ everything under `platform_prices` may carry a price, and a currency under neith
591
591
  all. The grant rows also carry the platform's ceilings, and a game does not declare its own; a
592
592
  `max_per_window` on a mapping is ignored, with a warning.
593
593
 
594
-
595
594
  A browser-only game cannot award a platform currency without a server **or a serverless function**.
596
595
  That is the price of the platform never trusting a client. A game with no dedicated server writes
597
596
  the source progression from a function the player calls, on a progression that opts in with
@@ -657,10 +656,10 @@ subject. [brand-art.md](brand-art.md) has both, how to draw them, and the rest o
657
656
 
658
657
  Every game can choose between two purchase owners for the same published platform price:
659
658
 
660
- | Call | Who owns confirmation | Transport | Result |
661
- | --- | --- | --- | --- |
662
- | `gem.purchases.request(...)` | the platform | postMessage bridge; platform sheet always opens | `ok`, `declined`, or `unavailable` |
663
- | `gem.me.mutations.purchase(...)` | your game | direct authenticated SDK request; no bridge or platform sheet | an order-style result or a `GemApiError` |
659
+ | Call | Who owns confirmation | Transport | Result |
660
+ | -------------------------------- | --------------------- | ------------------------------------------------------------- | ---------------------------------------- |
661
+ | `gem.purchases.request(...)` | the platform | postMessage bridge; platform sheet always opens | `ok`, `declined`, or `unavailable` |
662
+ | `gem.me.mutations.purchase(...)` | your game | direct authenticated SDK request; no bridge or platform sheet | an order-style result or a `GemApiError` |
664
663
 
665
664
  There is no approval step. Both paths are available to every game.
666
665
 
@@ -818,21 +817,21 @@ local lookup. The purchase catalog is keyed by ids. Recipes also require `catalo
818
817
  One vocabulary covers both paths: the sheet's `res.reason` and the direct path's `err.code` are
819
818
  the same strings.
820
819
 
821
- | Code | What happened | What to do |
822
- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
823
- | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
824
- | `currency_not_in_game_payable` | the currency exists but cannot pay for game items | price in a currency `gemctl platform config` lists under `platform_prices` |
825
- | `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought | send the id (not the slug); promote the config version that prices it |
826
- | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
827
- | `busy` | a purchase is already in flight, a sheet is open, the arcade is backing off after a cancel, or no click | ask again only from a fresh player action |
828
- | `cancelled` | the player dismissed the sheet | nothing — return to the shop |
829
- | `insufficient_funds` | the player cannot afford it | say so in your shop's words; never draw a top-up screen |
830
- | `price_mismatch`, `config_version_stale` | the price or config changed since your catalog read; nothing was charged | re-read the catalog and re-render |
831
- | `platform_currency_unavailable` | the currency is not available to this purchase right now | show the store as unavailable |
832
- | `not_available` | the recipe is outside its availability window, or inactive; nothing was charged | hide or disable the row until its window opens |
833
- | `recipe_locked` | the recipe's prerequisites are not met; nothing was charged | show the lock from `recipeAvailability().lockReasons` |
834
- | `recipe_on_cooldown`, `recipe_max_uses_exceeded`, `item_max_stack_exceeded`, `recipe_costs_unmet`, `quantity_invalid` | the recipe or item refused this purchase before any charge | reflect it on the row; `recipeAvailability` has the detail |
835
- | `error` | anything else, or no reliable answer | one neutral "store unavailable"; after `unavailable`, re-read inventory |
820
+ | Code | What happened | What to do |
821
+ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
822
+ | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
823
+ | `currency_not_in_game_payable` | the currency exists but cannot pay for game items | price in a currency `gemctl platform config` lists under `platform_prices` |
824
+ | `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought | send the id (not the slug); promote the config version that prices it |
825
+ | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
826
+ | `busy` | a purchase is already in flight, or a sheet is open | ask again only from a fresh player action |
827
+ | `cancelled` | the player dismissed the sheet | nothing — return to the shop |
828
+ | `insufficient_funds` | the player cannot afford it | say so in your shop's words; never draw a top-up screen |
829
+ | `price_mismatch`, `config_version_stale` | the price or config changed since your catalog read; nothing was charged | re-read the catalog and re-render |
830
+ | `platform_currency_unavailable` | the currency is not available to this purchase right now | show the store as unavailable |
831
+ | `not_available` | the recipe is outside its availability window, or inactive; nothing was charged | hide or disable the row until its window opens |
832
+ | `recipe_locked` | the recipe's prerequisites are not met; nothing was charged | show the lock from `recipeAvailability().lockReasons` |
833
+ | `recipe_on_cooldown`, `recipe_max_uses_exceeded`, `item_max_stack_exceeded`, `recipe_costs_unmet`, `quantity_invalid` | the recipe or item refused this purchase before any charge | reflect it on the row; `recipeAvailability` has the detail |
834
+ | `error` | anything else, or no reliable answer | one neutral "store unavailable"; after `unavailable`, re-read inventory |
836
835
 
837
836
  Reasons are for your shop's state and your logs. Branch money on `outcome` and on a re-read of
838
837
  inventory, never on a reason, and do not retry because of one.
@@ -964,7 +963,9 @@ const gems = allowances.find((a) => a.currencySlug === 'GEMS');
964
963
  // 0 or less once `resetsAt` has passed: the read is stale, so say nothing about when.
965
964
  const hours = gems?.resetsAt ? Math.ceil((gems.resetsAt - Date.now()) / 3_600_000) : 0;
966
965
  if (gems && gems.remaining > 0) {
967
- hud.setText(`You can still earn ${gems.remaining} Gems` + (hours > 0 ? `, more in ${hours}h` : ''));
966
+ hud.setText(
967
+ `You can still earn ${gems.remaining} Gems` + (hours > 0 ? `, more in ${hours}h` : ''),
968
+ );
968
969
  } else if (gems?.resetsAt) {
969
970
  // Spent for now: say when more arrives, never "0 left".
970
971
  hud.setText(hours > 0 ? `More Gems in ${hours}h` : 'More Gems soon');
@@ -1001,7 +1002,7 @@ end of the reported period when both of the platform's ceilings are spent.
1001
1002
 
1002
1003
  **Display only. Never gate gameplay or a grant on it.**
1003
1004
 
1004
- - ✅ `` hud.setText(`${gems.remaining} Gems left to earn today`) ``
1005
+ - ✅ ``hud.setText(`${gems.remaining} Gems left to earn today`)``
1005
1006
  - ❌ `if (gems.remaining > 0) awardDailyBonus()`
1006
1007
 
1007
1008
  Grant anyway: the platform trims every grant to what is left, and the receipt is the authority on
@@ -18,8 +18,8 @@
18
18
  },
19
19
  {
20
20
  "file": "arcade-and-store.md",
21
- "sha256": "aee6e00fa7e0f463cb09174a9afdffe10d66ed1036546e584b341e4c11bb1e27",
22
- "updated": "2026-10-31T19:43:30Z"
21
+ "sha256": "79bd7829d5a6577d3619d7fd831b74065df883e11eadd1b8153754d97a88474a",
22
+ "updated": "2026-11-02T18:42:37Z"
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": "f2139ca3d6d368b6ae42da9f8550f69036ba56dcb5e7076b16421728809ac111",
52
- "updated": "2026-11-02T18:36:05Z"
51
+ "sha256": "8a292ee18fe7f910be0af049d96df66ccebf5c810fe5495c11d5352f7ee121f4",
52
+ "updated": "2026-11-03T00:39:38Z"
53
53
  },
54
54
  {
55
55
  "file": "daily-challenges.md",
@@ -58,8 +58,8 @@
58
58
  },
59
59
  {
60
60
  "file": "dedicated-server-flow.md",
61
- "sha256": "6da1bfc820ab2cf79180f69f9fec4d9f377ddde690f53923e749a88ec6307e22",
62
- "updated": "2026-10-02T19:04:21Z"
61
+ "sha256": "4a51622df43be68b5b9a88ae89907972090e64fd9b793ba74e5daa576489281c",
62
+ "updated": "2026-10-27T02:41:17Z"
63
63
  },
64
64
  {
65
65
  "file": "deprecation-policy.md",
@@ -78,13 +78,13 @@
78
78
  },
79
79
  {
80
80
  "file": "getting-started.md",
81
- "sha256": "6d1d9969076b1f44b900a9923d8e3a82ca0b93278c000a0dc3699d836f57a09c",
82
- "updated": "2026-11-02T18:36:05Z"
81
+ "sha256": "0c4cee83ffc77711f43ccdaafb394283a5a412b78395db1434ebbc2ab6943667",
82
+ "updated": "2026-11-03T00:39:38Z"
83
83
  },
84
84
  {
85
85
  "file": "inventory-and-catalogs.md",
86
- "sha256": "a4763fe2418c0e24639ca58c27f30ffc416f07528857402a6f9ab3fee147f4f6",
87
- "updated": "2026-11-01T21:49:36Z"
86
+ "sha256": "dab6e42f4ad5cc2a63c2adb11e85053f963cd3166d1c447394cd6f532bb6c006",
87
+ "updated": "2026-11-02T18:42:37Z"
88
88
  },
89
89
  {
90
90
  "file": "leaderboards.md",
@@ -93,13 +93,13 @@
93
93
  },
94
94
  {
95
95
  "file": "match-session.md",
96
- "sha256": "cfc2f76e310f4a851ac0b9185687d284cced6533fe12fb359ce0cb2845c5e5ef",
97
- "updated": "2026-10-23T01:58:45Z"
96
+ "sha256": "121eb2bd92da9daf3922097699e0ab450da6a087eec8de552fe573094cf7e2c4",
97
+ "updated": "2026-11-02T16:12:53Z"
98
98
  },
99
99
  {
100
100
  "file": "p2p-and-parties.md",
101
- "sha256": "4cfe2a47def51eb617f154eaac39faf176d8aa7ca2c36907084b6349048ab1b9",
102
- "updated": "2026-10-17T22:11:15Z"
101
+ "sha256": "1de834a88746a4c7b6de555b3e6e3500fce0b7e75fc3cb87d46795efc7e27940",
102
+ "updated": "2026-11-02T23:33:48Z"
103
103
  },
104
104
  {
105
105
  "file": "platform-moments.md",
@@ -138,8 +138,8 @@
138
138
  },
139
139
  {
140
140
  "file": "server-bundles.md",
141
- "sha256": "46bb5ff4bea22129482f314aad4ba6e89b81c853140fdb72dedb7a3bc7853a1b",
142
- "updated": "2026-11-02T18:36:05Z"
141
+ "sha256": "f54aeb88c268e5b657be9d0ea323d44cc61bfbb76fc84670f8a607d4514b005d",
142
+ "updated": "2026-11-03T00:39:38Z"
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": "17df2ebdafe56ac5eafdf4eba783c97025ddd9e0ee88b7b43d25718908496b2a",
167
- "updated": "2026-11-02T14:57:41Z"
166
+ "sha256": "059f3c8a31c0f6098d2c8e4836b79e866d0c5486443dc89039e160648946bd68",
167
+ "updated": "2026-11-02T23:27:22Z"
168
168
  }
169
169
  ]
170
170
  }
@@ -155,6 +155,15 @@ own. When a host migration is already in flight the attempt waits it out instead
155
155
  the way back, and it re-attaches this connection without a dial (§5.1 of
156
156
  [p2p-and-parties.md](p2p-and-parties.md)).
157
157
 
158
+ **A relay that moves is not a drop.** When the platform takes a TURN relay out of service, or one
159
+ dies under a match, the SDK moves the peer-to-peer connection to another relay with an ICE restart
160
+ on the connection it already has (§4.1 and §5 of [p2p-and-parties.md](p2p-and-parties.md)). The
161
+ data channels stay open, so there is no `matchSuspended`, no `matchReconnected` and nothing to
162
+ resync — at most a pause of a few seconds while the route changes. Only a move that misses the
163
+ platform's deadline, or a restart that never comes back, leaves that path: the first is a drop with
164
+ `cause: 'link_closed'` and the re-dial above; the second is treated as the host being gone, as §5
165
+ there describes.
166
+
158
167
  The loop never runs while the page is hidden. It parks, and the next visible edge — the phone
159
168
  unlocked, the tab foregrounded — is itself a read, so a match that ended while the player was away
160
169
  resolves to `matchEnded` and one that is still running resumes reconnecting. Nothing in your game
@@ -327,6 +327,14 @@ peer connection's configuration when it is constructed. Leasing afterwards
327
327
  produces a connection that works whenever both peers are directly reachable —
328
328
  which passes every test on a LAN and fails for a large minority of real players.
329
329
 
330
+ **A lease has a lifetime, and a match can outlast it.** The SDK keeps the
331
+ lease's expiry and renews it whenever it is about to build a connection on a
332
+ lease within two minutes of running out — a host answering a player's offer,
333
+ or a player rebuilding after a migration — so a host
334
+ deep into a long match never answers with credentials that have already
335
+ lapsed. A renewal also reaches every connection already up, ready for its next
336
+ change of route. Renewals are paced at one per event, never one per candidate.
337
+
330
338
  **Which cluster to lease from is normally nobody's decision to make here.** The
331
339
  parent measures the regions and hands the close ones across the bridge, and each
332
340
  peer leases from its own best one — a relay near that peer, whichever cluster
@@ -343,6 +351,23 @@ alike — which is a decision to move the room. When nothing resolves one the
343
351
  connection proceeds without a relay and says so loudly, because the failure it
344
352
  causes is the one that looks like success.
345
353
 
354
+ **A lease refused for its cluster moves to the next one the page sent.** The page
355
+ sends up to three clusters, best first. When the platform refuses a lease because
356
+ of the cluster it named — `cluster_hidden`, an operator took it out of service and
357
+ no other cluster in its region can relay for it, or `invalid_cluster_target`, it
358
+ is not one the platform runs — the SDK leases from the next, before any connection
359
+ is built. Only when every one is refused does the connection proceed without a
360
+ relay, with the same warning. Any other refusal is not walked, because another
361
+ cluster would answer it the same. A cluster you named yourself is the only one
362
+ tried. A renewal walks the same way, starting from the cluster that granted the
363
+ lease it replaces, and a re-dial after a dropped connection leases afresh over the
364
+ whole list, so a cluster hidden mid-match is passed over either way.
365
+
366
+ An earlier SDK version asks only the best cluster. Where the platform can answer
367
+ from another cluster in the same region it does, and that SDK gets its relay
368
+ anyway; where it cannot, that SDK connects without one. `dedicated-server-flow.md`
369
+ §5 says what hiding a cluster does to rooms and matches.
370
+
346
371
  **`null` names no cluster, exactly as omitting the option does.** A game picking
347
372
  its target out of a list writes `targets[0]?.id ?? null`, so that is the spelling
348
373
  ordinary code arrives with — and it defers to the parent's answer the same way an
@@ -351,6 +376,41 @@ request for a cluster with no id: that costs the caller the no-relay warning and
351
376
  reports "the relay was refused" for a player whose actual problem is that nobody
352
377
  ever resolved a region for them.
353
378
 
379
+ ### 4.1 A relay going away does not end the match
380
+
381
+ Relays are taken out of service for deploys, credential rotation and scaling.
382
+ The platform does it in three ordered steps:
383
+
384
+ 1. **Delist.** No new lease names the relay, so no new connection is built on it.
385
+ 2. **Move.** Every player in a match that may be using it is told, and the
386
+ connections on it move elsewhere while it still forwards traffic.
387
+ 3. **Stop.** The relay stops once nobody is on it, or when the platform's
388
+ deadline for the move passes.
389
+
390
+ **Your game sees none of it.** On the notice every player renews its lease and
391
+ drops the draining relay from the list it holds. A player whose connection to
392
+ the host runs through that relay moves with an **ICE restart on the connection
393
+ it already has**: new candidates on the remaining relays, the same DTLS
394
+ session, the same three data channels. The host answers on its existing
395
+ connection. Nothing closes, `gem.match` raises no `matchSuspended`, and nobody
396
+ reports the host unreachable. A player whose route does not touch the draining
397
+ relay renews and does nothing else, and the host renews and answers but never
398
+ offers a restart of its own.
399
+
400
+ **If the move has not completed by the platform's deadline**, the player drops
401
+ that connection and the ordinary reconnect takes over (§5.1, and §4 of
402
+ [match-session.md](match-session.md)): a fresh session at the same host, over
403
+ the renewed lease, reported as this player's own link closing — `link_closed` —
404
+ because the host is fine. The same happens at once if the host has already let
405
+ go of that player's connection when the restart reaches it: the host can only
406
+ answer from a new connection, which the player's existing one cannot use.
407
+
408
+ **The announced move needs the Arcade page to pass the notice on.** The page is
409
+ what relays it into the game frame, and a page whose side of the bridge
410
+ predates the SDK release that brought this drops it. Until the page is at that
411
+ release or later, a relay stopping under a match is recovered the way an
412
+ unannounced loss is (§5): a few seconds of quiet, then the same restart.
413
+
354
414
  ---
355
415
 
356
416
  ## 5. Host migration is where a split match can happen
@@ -388,18 +448,31 @@ trying to fix, and the old host is already gone. A host that settled on its own
388
448
  send could not tell that apart from a healthy match. The active phase is bounded
389
449
  at 30 seconds and a stall is reported as `migration_failed`.
390
450
 
391
- A non-host that sees ICE fail reports the host unreachable **once per epoch**.
392
- That is a vote, not a verdict: gem-api migrates on a quorum of independent
393
- reporters and never on one, so it cannot unseat a host who is fine for everyone
394
- else.
451
+ A non-host whose connection to the host goes quiet **tries to bring it back
452
+ before it reports anything**. ICE `disconnected` is what a lost relay looks like
453
+ as much as a lost host, so the player first renews its lease and restarts ICE on
454
+ the existing connection — the move §4.1 describes, without the notice — and
455
+ gives it about eight seconds to come back. Only if it does not, or the
456
+ connection fails or closes outright meanwhile, does the player report the host
457
+ unreachable, **once per epoch**. A relay that dies without warning therefore no
458
+ longer ends a two-player match or migrates a larger one away from a healthy
459
+ host; the cost is that a host who really has gone is reported a few seconds
460
+ later.
461
+
462
+ The report is a vote, not a verdict. In a match of three or more, gem-api
463
+ migrates on a quorum of at least two independent reporters, so one player
464
+ cannot unseat a host who is fine for everyone else. In a two-player match there
465
+ is nobody to elect, and the lone peer's report ends the match with `host_left`
466
+ — which is why the report waits for the restart to fail.
395
467
 
396
468
  ### 5.1 What `gem.match` does with a migration
397
469
 
398
470
  A `MatchConnection` to a hosting peer survives a migration as a **pause**: its
399
471
  channels re-resolve on `hostChanged`, so the game sees a gap rather than a new
400
472
  connection to build. What the room reports meanwhile is the ordinary session
401
- vocabulary — `matchSuspended` with cause `host_unreachable` when the host's
402
- link fails, `matchReconnected` with `attempt: 0` when the migration seated a
473
+ vocabulary — `matchSuspended` with cause `link_closed` when this side sees the
474
+ host's link fail or `host_unreachable` when the platform reports the host gone,
475
+ `matchReconnected` with `attempt: 0` when the migration seated a
403
476
  new host and the link came back without a re-dial, and `matchEnded` with
404
477
  `host_left` or `migration_failed` when it did not. The screens for each are
405
478
  [match-session.md](match-session.md) §3 and §4; the listen host's own
@@ -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/v19.0.2/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v19.0.2/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v20.1.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v20.1.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -53,10 +53,10 @@ No `baseUrl`. The Arcade names the backend on every token and the SDK routes the
53
53
  tokens only if its origin is allowed, and the pages that matter are always allowed without a word
54
54
  from you:
55
55
 
56
- - **the Gem Arcade website, developer site and app** — the SDK carries their origins as
57
- `ARCADE_EMBEDDERS`, on every platform the app runs on. The developer site,
58
- `https://developer.gemarcade.com`, is where you play your own dev and test builds; a build accepts
59
- it once it is built with an SDK that lists it;
56
+ - **the Gem Arcade website and app** — the SDK carries their origins as `ARCADE_EMBEDDERS`, on every
57
+ platform the app runs on. The list also holds `https://developer.gemarcade.com`, which is
58
+ reserved and not live: no page is served there. You play your own dev and test builds with
59
+ `gemctl serve` or the Gem Editor, or on your game's dev or test channel in the Arcade;
60
60
  - **a dev host on your own machine** — `gemctl serve` and the Gem Editor frame a locally-served game
61
61
  from a loopback origin whose port changes every run, and the SDK admits that shape by rule, only
62
62
  while your game itself is served from loopback. Once published, nothing about the rule applies.
@@ -1710,7 +1710,10 @@ stops your media elements, and restarts neither. The snippet above has stopped l
1710
1710
  the SDK does this part for you. After the page was hidden, or a context left `running` without your
1711
1711
  asking, it listens again for the player's next lift in your frame, resumes the contexts that were
1712
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:
1713
+ listening. A context that reads `running` after the page comes back can still be silent, as when
1714
+ your own `resume()` ran outside a tap, so on that first lift the SDK also suspends and resumes each
1715
+ context that had run, once per return: your clock pauses for a moment, and the sound comes back.
1716
+ You write nothing for it. It stays out of your way:
1714
1717
 
1715
1718
  - **A context you `suspend()` yourself is left alone** until you call `resume()`. If you pause your
1716
1719
  audio when the page is hidden and resume it when it comes back, keep doing that: the SDK steps in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "19.0.2",
3
+ "version": "20.1.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",