@indiegems/gem-web-sdk 20.0.0 → 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.
package/dist/sdk.d.ts CHANGED
@@ -2254,6 +2254,15 @@ interface GemSignalEvent {
2254
2254
  readonly matchId: string;
2255
2255
  readonly data: unknown;
2256
2256
  }
2257
+ interface GemRelayDraining {
2258
+ readonly gem: typeof PROTOCOL;
2259
+ readonly type: "gem.relayDraining";
2260
+ readonly matchId: string;
2261
+ readonly clusterTargetId: string;
2262
+ readonly relayIps: string[];
2263
+ readonly drainingSince: string;
2264
+ readonly deadline: string;
2265
+ }
2257
2266
  interface GemDedicatedEvent {
2258
2267
  readonly gem: typeof PROTOCOL;
2259
2268
  readonly type: "gem.dedicatedEvent";
@@ -2570,6 +2579,7 @@ interface TelemetrySample {
2570
2579
  readonly type: string;
2571
2580
  readonly value?: number;
2572
2581
  readonly transport?: TransportLabel;
2582
+ readonly labels?: Readonly<Record<string, string>>;
2573
2583
  }
2574
2584
  interface P2pApiOptions {
2575
2585
  readonly client: GemClient;
@@ -2686,9 +2696,12 @@ interface IceCandidateLike {
2686
2696
  readonly sdpMid?: string | null;
2687
2697
  readonly sdpMLineIndex?: number | null;
2688
2698
  }
2699
+ type EventListenerLike = {
2700
+ bivarianceHack(event: unknown): void;
2701
+ }["bivarianceHack"];
2689
2702
  interface EventTargetLike {
2690
- addEventListener(type: string, listener: (event: never) => void): void;
2691
- removeEventListener(type: string, listener: (event: never) => void): void;
2703
+ addEventListener(type: string, listener: EventListenerLike): void;
2704
+ removeEventListener(type: string, listener: EventListenerLike): void;
2692
2705
  }
2693
2706
  interface DataChannelLike extends EventTargetLike {
2694
2707
  readonly readyState: string;
@@ -2703,6 +2716,16 @@ interface DataChannelInitLike {
2703
2716
  readonly negotiated?: boolean;
2704
2717
  readonly id?: number;
2705
2718
  }
2719
+ interface IceConfigurationLike {
2720
+ iceServers?: {
2721
+ urls: string | string[];
2722
+ username?: string;
2723
+ credential?: string;
2724
+ }[];
2725
+ }
2726
+ interface StatsReportLike {
2727
+ forEach(callback: (value: unknown, key: string) => void): void;
2728
+ }
2706
2729
  interface PeerConnectionLike extends EventTargetLike {
2707
2730
  readonly localDescription: SessionDescriptionLike | null;
2708
2731
  readonly remoteDescription: SessionDescriptionLike | null;
@@ -2715,6 +2738,10 @@ interface PeerConnectionLike extends EventTargetLike {
2715
2738
  setRemoteDescription(description: SessionDescriptionLike): Promise<void>;
2716
2739
  addIceCandidate(candidate: IceCandidateLike): Promise<void>;
2717
2740
  close(): void;
2741
+ setConfiguration?(configuration: IceConfigurationLike): void;
2742
+ getConfiguration?(): IceConfigurationLike;
2743
+ restartIce?(): void;
2744
+ getStats?(): Promise<StatsReportLike>;
2718
2745
  }
2719
2746
  interface PeerConnectionConfig {
2720
2747
  readonly iceServers: readonly {
@@ -2749,6 +2776,7 @@ interface P2pSessionOptions {
2749
2776
  readonly baseUrl: string;
2750
2777
  readonly signaling: SignalingClient;
2751
2778
  readonly clusterTargetId?: ClusterTargetLike;
2779
+ readonly clusterTargetIds?: readonly string[];
2752
2780
  readonly peerConnectionFactory?: PeerConnectionFactory;
2753
2781
  readonly certificate?: unknown;
2754
2782
  readonly connectTimeoutMs?: number;
@@ -2765,7 +2793,11 @@ declare class P2pSession {
2765
2793
  private readonly keys;
2766
2794
  private localPlayerId;
2767
2795
  private migration;
2768
- private iceServers;
2796
+ private lease;
2797
+ private clusterTargetIds;
2798
+ private leaseRenewal;
2799
+ private leaseAskedAt;
2800
+ private leaseRenewalWarned;
2769
2801
  private credential;
2770
2802
  private nextPeerId;
2771
2803
  private ended;
@@ -2779,6 +2811,7 @@ declare class P2pSession {
2779
2811
  private readonly answeredSessions;
2780
2812
  private credentialGeneration;
2781
2813
  private credentialDeadline;
2814
+ private readonly drainTimers;
2782
2815
  constructor(options: P2pSessionOptions);
2783
2816
  get matchId(): string;
2784
2817
  get playerId(): string;
@@ -2804,6 +2837,7 @@ declare class P2pSession {
2804
2837
  private fetchCredential;
2805
2838
  private waitForCredential;
2806
2839
  private onOffer;
2840
+ private answer;
2807
2841
  private onAnswer;
2808
2842
  private hostVerified;
2809
2843
  private maybeAuthenticate;
@@ -2814,6 +2848,17 @@ declare class P2pSession {
2814
2848
  private watchMigration;
2815
2849
  private onMatchEnded;
2816
2850
  private onSignalingError;
2851
+ private onRelayDraining;
2852
+ private onDrainDeadline;
2853
+ private withoutDrainingRelays;
2854
+ private restartIceToHost;
2855
+ private offerIceRestart;
2856
+ private progressRestart;
2857
+ private reportRestart;
2858
+ private takeLease;
2859
+ private refreshLease;
2860
+ private reconfigureLinks;
2861
+ private iceServers;
2817
2862
  private openLink;
2818
2863
  private flushCandidates;
2819
2864
  private onReliableOpen;
@@ -2822,6 +2867,8 @@ declare class P2pSession {
2822
2867
  private refuseLink;
2823
2868
  private finishAdmission;
2824
2869
  private onIceState;
2870
+ private recoverHostLink;
2871
+ private voteHostUnreachable;
2825
2872
  private onConnectionState;
2826
2873
  private expose;
2827
2874
  private dropLink;
@@ -4726,6 +4773,7 @@ interface FrameCallbacks {
4726
4773
  onTicketEvent?(msg: GemTicketEvent): void;
4727
4774
  onSocketState?(msg: GemSocketState): void;
4728
4775
  onSignalEvent?(msg: GemSignalEvent): void;
4776
+ onRelayDraining?(msg: GemRelayDraining): void;
4729
4777
  onDedicatedEvent?(msg: GemDedicatedEvent): void;
4730
4778
  onClusterTarget?(msg: GemClusterTarget): void;
4731
4779
  onPartyResult?(msg: GemPartyResult): void;
@@ -1,6 +1,6 @@
1
1
  # The function surface — API reference
2
2
 
3
- Generated from `@indiegems/gem-web-sdk/function` v20.0.0, the version of the package this file ships in. Every symbol below carries an explicit anchor, its `anchor` in `reference.json` beside this file.
3
+ Generated from `@indiegems/gem-web-sdk/function` v20.1.0, the version of the package this file ships in. Every symbol below carries an explicit anchor, its `anchor` in `reference.json` beside this file.
4
4
 
5
5
  `@indiegems/gem-web-sdk/function` exports exactly the symbols on this page and nothing else.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # The browser surface — API reference
2
2
 
3
- Generated from `@indiegems/gem-web-sdk/sdk` v20.0.0, the version of the package this file ships in. Every symbol below carries an explicit anchor, its `anchor` in `reference.json` beside this file.
3
+ Generated from `@indiegems/gem-web-sdk/sdk` v20.1.0, the version of the package this file ships in. Every symbol below carries an explicit anchor, its `anchor` in `reference.json` beside this file.
4
4
 
5
5
  `@indiegems/gem-web-sdk/sdk` is the one import a game writes, wherever its code runs. `@indiegems/gem-web-sdk/game` exports exactly the symbols on this page and nothing else, for a build that wants only this half.
6
6
 
@@ -9504,8 +9504,9 @@ ARCADE_EMBEDDERS: readonly string[]
9504
9504
  ```
9505
9505
 
9506
9506
  The origins the Gem Arcade embeds a game from, which every game accepts
9507
- without naming them: the website, the developer site, and the Gem Arcade
9508
- app.
9507
+ without naming them: the website and the Gem Arcade app. The list also
9508
+ holds `https://developer.gemarcade.com`, which is reserved and not live —
9509
+ no page is served there, so nothing frames a game from it.
9509
9510
 
9510
9511
  You do not pass these. They are always allowed, and
9511
9512
  [GemOptions.allowedEmbedders](#gemoptions-allowedembedders) adds to them rather than replacing them.
@@ -9534,8 +9535,8 @@ readonly allowedEmbedders?: readonly string[]
9534
9535
  Extra origins allowed to embed your game, **added to** the ones the Gem
9535
9536
  Arcade uses. Most games leave this out.
9536
9537
 
9537
- The website, the developer site and the Gem Arcade app are always
9538
- allowed — see [ARCADE_EMBEDDERS](#arcade_embedders) — so do not list them. Set this only when a site
9538
+ The website and the Gem Arcade app are always allowed — see
9539
+ [ARCADE_EMBEDDERS](#arcade_embedders) — so do not list them. Set this only when a site
9539
9540
  of your own embeds your game.
9540
9541
 
9541
9542
  ```js
@@ -12593,9 +12594,9 @@ await gem.awaitBridge(); // before loading your main bundle
12593
12594
  const me = await gem.awaitIdentity(); // who is playing, once sign-in completes
12594
12595
  ```
12595
12596
 
12596
- No options are needed. The Gem Arcade's website, developer site and app are
12597
- always allowed to embed your game ([ARCADE_EMBEDDERS](#arcade_embedders)), and the arcade
12598
- tells your game which build it is.
12597
+ No options are needed. The Gem Arcade's website and app are always allowed to
12598
+ embed your game ([ARCADE_EMBEDDERS](#arcade_embedders)), and the arcade tells your game
12599
+ which build it is.
12599
12600
 
12600
12601
  A dev host on your own machine (`gemctl serve`, the Gem Editor) needs no
12601
12602
  entry either: it frames a locally-served game from a loopback origin whose
@@ -1,6 +1,6 @@
1
1
  # The server surface — API reference
2
2
 
3
- Generated from `@indiegems/gem-web-sdk/sdk` v20.0.0, the version of the package this file ships in. Every symbol below carries an explicit anchor, its `anchor` in `reference.json` beside this file.
3
+ Generated from `@indiegems/gem-web-sdk/sdk` v20.1.0, the version of the package this file ships in. Every symbol below carries an explicit anchor, its `anchor` in `reference.json` beside this file.
4
4
 
5
5
  `@indiegems/gem-web-sdk/sdk` is the one import a game writes, wherever its code runs. `@indiegems/gem-web-sdk/server` exports exactly the symbols on this page and nothing else, for a build that wants only this half.
6
6
 
@@ -2,7 +2,7 @@
2
2
  "schema_version": 2,
3
3
  "source": {
4
4
  "repo": "IndieGems/gem-web-sdk",
5
- "version": "20.0.0"
5
+ "version": "20.1.0"
6
6
  },
7
7
  "entries": [
8
8
  {
@@ -11139,7 +11139,7 @@
11139
11139
  "group": "start",
11140
11140
  "signature": "ARCADE_EMBEDDERS: readonly string[]",
11141
11141
  "documented": true,
11142
- "summary": "The origins the Gem Arcade embeds a game from, which every game accepts\nwithout naming them: the website, the developer site, and the Gem Arcade\napp.\n\nYou do not pass these. They are always allowed, and\n[GemOptions.allowedEmbedders](api-reference-game.md#gemoptions-allowedembedders) adds to them rather than replacing them.",
11142
+ "summary": "The origins the Gem Arcade embeds a game from, which every game accepts\nwithout naming them: the website and the Gem Arcade app. The list also\nholds `https://developer.gemarcade.com`, which is reserved and not live —\nno page is served there, so nothing frames a game from it.\n\nYou do not pass these. They are always allowed, and\n[GemOptions.allowedEmbedders](api-reference-game.md#gemoptions-allowedembedders) adds to them rather than replacing them.",
11143
11143
  "deprecated": null,
11144
11144
  "params": [],
11145
11145
  "returns": null,
@@ -11171,7 +11171,7 @@
11171
11171
  "group": "start",
11172
11172
  "signature": "readonly allowedEmbedders?: readonly string[]",
11173
11173
  "documented": true,
11174
- "summary": "Extra origins allowed to embed your game, **added to** the ones the Gem\nArcade uses. Most games leave this out.\n\nThe website, the developer site and the Gem Arcade app are always\nallowed — see [ARCADE_EMBEDDERS](api-reference-game.md#arcade_embedders) — so do not list them. Set this only when a site\nof your own embeds your game.\n\n```js\ncreateGem({ allowedEmbedders: ['https://my-own-site.example'] })\n```\n\nA page whose origin is not allowed gets no connection, and — deliberately —\nis told nothing about why. That means a typo here looks exactly like a\nhandshake that has not arrived: pass [GemOptions.onRefused](api-reference-game.md#gemoptions-onrefused) while you\nare integrating, because it is the only place the difference shows.\n\n⚠️ **Bake these in. Never build them from anything the page can\ninfluence** — not `location`, not a query parameter, not\n`document.referrer`. A value the embedder supplies is a check that can never\nfail. Do not add a `localhost:<port>` entry for your dev host either; the\nSDK admits one on your own machine by rule.",
11174
+ "summary": "Extra origins allowed to embed your game, **added to** the ones the Gem\nArcade uses. Most games leave this out.\n\nThe website and the Gem Arcade app are always allowed — see\n[ARCADE_EMBEDDERS](api-reference-game.md#arcade_embedders) — so do not list them. Set this only when a site\nof your own embeds your game.\n\n```js\ncreateGem({ allowedEmbedders: ['https://my-own-site.example'] })\n```\n\nA page whose origin is not allowed gets no connection, and — deliberately —\nis told nothing about why. That means a typo here looks exactly like a\nhandshake that has not arrived: pass [GemOptions.onRefused](api-reference-game.md#gemoptions-onrefused) while you\nare integrating, because it is the only place the difference shows.\n\n⚠️ **Bake these in. Never build them from anything the page can\ninfluence** — not `location`, not a query parameter, not\n`document.referrer`. A value the embedder supplies is a check that can never\nfail. Do not add a `localhost:<port>` entry for your dev host either; the\nSDK admits one on your own machine by rule.",
11175
11175
  "deprecated": null,
11176
11176
  "params": [],
11177
11177
  "returns": null,
@@ -13974,7 +13974,7 @@
13974
13974
  "group": "start",
13975
13975
  "signature": "function createGem(options?: GemOptions): Gem",
13976
13976
  "documented": true,
13977
- "summary": "Connect your game to the Gem Arcade. This is the one call every game makes.\n\nIt assembles everything and connects to the arcade that framed your game. The\nsame code runs on your machine and in production without a flag — but it needs\na frame either way. Opened at the top level it throws\n[GemUnframedError](api-reference-game.md#gemunframederror), which says how to run it: in the Arcade, the Editor,\nor the Gem dev host on your machine (`npm run dev`, signed in to `gemctl`).\n\nOne dependency, imported like anything else. The install is in\n[getting-started](../getting-started.md#3-add-the-sdk).\n\n```js\nimport { createGem } from '@indiegems/gem-web-sdk/sdk';\n\nconst gem = createGem();\n\nawait gem.awaitBridge(); // before loading your main bundle\nconst me = await gem.awaitIdentity(); // who is playing, once sign-in completes\n```\n\nNo options are needed. The Gem Arcade's website, developer site and app are\nalways allowed to embed your game ([ARCADE_EMBEDDERS](api-reference-game.md#arcade_embedders)), and the arcade\ntells your game which build it is.\n\nA dev host on your own machine (`gemctl serve`, the Gem Editor) needs no\nentry either: it frames a locally-served game from a loopback origin whose\nport changes every run, and the SDK admits it by rule — only while the game\nitself is served from loopback, so nothing changes once you publish.\n\nPass `allowedEmbedders` only when a site of your own frames your game. It\nadds to the arcade's origins; see [GemOptions.allowedEmbedders](api-reference-game.md#gemoptions-allowedembedders).",
13977
+ "summary": "Connect your game to the Gem Arcade. This is the one call every game makes.\n\nIt assembles everything and connects to the arcade that framed your game. The\nsame code runs on your machine and in production without a flag — but it needs\na frame either way. Opened at the top level it throws\n[GemUnframedError](api-reference-game.md#gemunframederror), which says how to run it: in the Arcade, the Editor,\nor the Gem dev host on your machine (`npm run dev`, signed in to `gemctl`).\n\nOne dependency, imported like anything else. The install is in\n[getting-started](../getting-started.md#3-add-the-sdk).\n\n```js\nimport { createGem } from '@indiegems/gem-web-sdk/sdk';\n\nconst gem = createGem();\n\nawait gem.awaitBridge(); // before loading your main bundle\nconst me = await gem.awaitIdentity(); // who is playing, once sign-in completes\n```\n\nNo options are needed. The Gem Arcade's website and app are always allowed to\nembed your game ([ARCADE_EMBEDDERS](api-reference-game.md#arcade_embedders)), and the arcade tells your game\nwhich build it is.\n\nA dev host on your own machine (`gemctl serve`, the Gem Editor) needs no\nentry either: it frames a locally-served game from a loopback origin whose\nport changes every run, and the SDK admits it by rule — only while the game\nitself is served from loopback, so nothing changes once you publish.\n\nPass `allowedEmbedders` only when a site of your own frames your game. It\nadds to the arcade's origins; see [GemOptions.allowedEmbedders](api-reference-game.md#gemoptions-allowedembedders).",
13978
13978
  "deprecated": null,
13979
13979
  "params": [
13980
13980
  {
package/docs/changelog.md CHANGED
@@ -16,6 +16,20 @@ 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
+ ## 20.1.0
20
+
21
+ Upgrading from 20.0.0. Impact: required.
22
+
23
+ ### Rebuild
24
+
25
+ - The list of capability names a game can advertise gained one value, p2pRelayDrain. Recompile. If the game switches exhaustively over capability names, add a case for it. Otherwise nothing else changes.
26
+ - The list of messages the Arcade page sends to the game frame gained one entry, gem.relayDraining, and the parent message type gained the matching member. Recompile. If the game handles every parent message in an exhaustive switch, add a case for the new one. Otherwise nothing else changes.
27
+
28
+ ### New capability
29
+
30
+ - A game can now be told when a relay carrying its peer-to-peer match is draining and by when. Pass onRelayDraining in the frame options, or implement it on the frame callbacks, to receive the notice. The SDK already moves the connection off the draining relay by itself, so use this only to inform the player or log it. The notice is sent only by an Arcade page that supports it.
31
+ - Telemetry samples can now carry labels, so a game that reads or forwards telemetry samples can see per-sample label values such as outcome and reason.
32
+
19
33
  ## 20.0.0
20
34
 
21
35
  Upgrading from 19.0.2. Impact: required.
@@ -715,11 +715,39 @@ host does, and the SDK does it for you:
715
715
 
716
716
  | What gem-api answers | Where | What the SDK does |
717
717
  | --- | --- | --- |
718
- | `invalid_cluster_target` | `create()`, `quickJoin()` | Waits up to five seconds for the page to send a different list, and tries once more with it |
718
+ | `invalid_cluster_target` | `create()` | Creates in the next cluster the page sent, at once, until every one has been refused; then waits up to five seconds for the page to send a different list, and tries once more with a cluster it has not yet tried |
719
+ | `invalid_cluster_target` | `quickJoin()` | Waits up to five seconds for the page to send a different list, and tries once more with it |
719
720
  | `invalid_cluster_target` | `startMatch()` — the platform does not run the room's cluster | Starts once in this player's best other cluster, naming it, which moves the room |
720
721
  | `capacity_unavailable` twice in a row | `startMatch()` | Moves to the next acceptable cluster, once, and keeps waiting there |
721
722
 
722
- **Each of them happens once.** A second refusal is the answer, and it reaches you as the error.
723
+ **Each wait and each move happens once.** `create()` trying each cluster the page sent counts as one
724
+ step, and it tries three at most, because the page sends three at most. A refusal after that is the
725
+ answer, and it reaches you as the error.
726
+
727
+ ### When the platform hides a cluster
728
+
729
+ An operator can **hide** a cluster to take it out of service without stopping it: it leaves the list
730
+ the page measures, it takes no new rooms, no new matches and no new TURN relay leases, and the
731
+ matches already running on it carry on. A page that measured before the hide still lists it until
732
+ it measures again, so the SDK is what moves that player on:
733
+
734
+ - **`create()`** is refused with `invalid_cluster_target` for the hidden cluster and creates in the
735
+ next one the page sent, with no wait.
736
+ - **`quickJoin()` and matchmaking** need nothing: the platform drops the hidden cluster from the list
737
+ they send and serves the rest.
738
+ - **`startMatch()`** in a room on the hidden cluster is refused the same way and starts once in the
739
+ host's best other cluster, which moves the room — as for any cluster the platform stopped running.
740
+ - **A P2P relay lease** is answered from another cluster in the same region when there is one, and
741
+ otherwise refused; the SDK then leases from the next cluster the page sent. See
742
+ `p2p-and-parties.md` §4.
743
+
744
+ An earlier SDK version reaches the same place more slowly or not at all. Its `create()` names only the
745
+ best cluster, waits up to five seconds for a list the page will not send, and then gives you the
746
+ refusal, so a game that creates rooms shows an error until the page measures again. Its P2P lease asks
747
+ the hidden cluster once and, with no other cluster in that region to answer for it, connects without
748
+ a relay. Its `startMatch()` and matchmaking behave as above, and so does its `quickJoin()` from
749
+ 5.11.0 on, when the SDK began sending the page's whole list; before that a quick-join names one
750
+ cluster, and one naming only the hidden cluster is refused.
723
751
 
724
752
  **An automatic start has nobody to answer, so the host's SDK answers it.** When a room's template
725
753
  starts the match as it fills to `min_players` and the platform refuses that start, no call is waiting
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "20.0.0"
53
+ "@indiegems/gem-web-sdk": "20.1.0"
54
54
  }
55
55
  ```
56
56
 
@@ -137,8 +137,8 @@ const me = await gem.awaitIdentity();
137
137
 
138
138
  Two things about that call are worth getting right the first time.
139
139
 
140
- **You do not list who may embed your game.** The Gem Arcade's website, developer site and app
141
- are always allowed, and so is a dev host on your own machine (`gemctl serve`, the Gem Editor) while your
140
+ **You do not list who may embed your game.** The Gem Arcade's website and app are always
141
+ allowed, and so is a dev host on your own machine (`gemctl serve`, the Gem Editor) while your
142
142
  game is served from loopback. `allowedEmbedders` exists only for a page beyond those — see
143
143
  [the embedder allowlist](what-a-game-must-do.md#1-the-embedder-allowlist) — and it adds to the
144
144
  arcade's origins rather than replacing them.
@@ -48,8 +48,8 @@
48
48
  },
49
49
  {
50
50
  "file": "changelog.md",
51
- "sha256": "37f3bde69baa7c0280e7d8a58800c1509b3b61c76a684428832bf094f298478b",
52
- "updated": "2026-11-02T19:11:02Z"
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,8 +78,8 @@
78
78
  },
79
79
  {
80
80
  "file": "getting-started.md",
81
- "sha256": "896f7d29d2017e5c42fec20386bce31eacf3f5143b7134c7607574b452c039bf",
82
- "updated": "2026-11-02T19:11:02Z"
81
+ "sha256": "0c4cee83ffc77711f43ccdaafb394283a5a412b78395db1434ebbc2ab6943667",
82
+ "updated": "2026-11-03T00:39:38Z"
83
83
  },
84
84
  {
85
85
  "file": "inventory-and-catalogs.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": "655a5835bf9d1c13997784de57aaf54566d72641bfe220da8a37903d5fad044c",
142
- "updated": "2026-11-02T19:11:02Z"
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/v20.0.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v20.0.0/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": "20.0.0",
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",