@indiegems/gem-web-sdk 7.4.0 → 7.4.2

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.
Files changed (38) hide show
  1. package/README.md +54 -0
  2. package/dist/gem-web-sdk.esm.js +1 -1
  3. package/dist/gem-web-sdk.game.js +1 -1
  4. package/dist/gem-web-sdk.min.js +1 -1
  5. package/dist/gem-web-sdk.sdk.js +1 -1
  6. package/dist/gem-web-sdk.server.js +1 -1
  7. package/docs/achievements.md +232 -0
  8. package/docs/analytics.md +271 -0
  9. package/docs/api/api-reference-game.md +15308 -0
  10. package/docs/api/api-reference-server.md +4397 -0
  11. package/docs/api/reference.json +24130 -0
  12. package/docs/arcade-economy.md +263 -0
  13. package/docs/async-opponents.md +396 -0
  14. package/docs/brand-art.md +334 -0
  15. package/docs/cross-promotion.md +137 -0
  16. package/docs/daily-challenges.md +583 -0
  17. package/docs/dedicated-server-flow.md +879 -0
  18. package/docs/deprecation-policy.md +124 -0
  19. package/docs/friends.md +233 -0
  20. package/docs/game-economy-tutorial.md +326 -0
  21. package/docs/getting-started.md +552 -0
  22. package/docs/inventory-and-catalogs.md +1035 -0
  23. package/docs/leaderboards.md +277 -0
  24. package/docs/manifest.json +135 -0
  25. package/docs/match-session.md +288 -0
  26. package/docs/p2p-and-parties.md +783 -0
  27. package/docs/platform-rules.md +372 -0
  28. package/docs/player-sign-in.md +89 -0
  29. package/docs/room-shapes.md +195 -0
  30. package/docs/rooms-and-matches.md +779 -0
  31. package/docs/saving-player-progress.md +457 -0
  32. package/docs/server-bundles.md +1041 -0
  33. package/docs/serverless-functions.md +748 -0
  34. package/docs/trading-and-gifting.md +98 -0
  35. package/docs/what-a-game-must-do.md +1231 -0
  36. package/package.json +7 -2
  37. package/schema/bridge.v1.json +5 -5
  38. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,879 @@
1
+ # Dedicated servers — the browser half
2
+
3
+ How a browser game gets from "I am in a room" to "I have a reliable ordered channel to a dedicated
4
+ server" — over WebRTC by default, or across the negotiated transport ladder (WebTransport, WebRTC,
5
+ WebSocket) that [`connect({ transports })`](#choosing-a-transport) opts into. The platform half is
6
+ gem-api's and has its own design; this describes only what runs inside the game frame, and it is
7
+ deliberately narrow.
8
+
9
+ **Most games do not call this flow directly.** `gem.match.connect()` resolves one `MatchConnection`
10
+ to whichever authority the room template chose — this dedicated server, a hosting peer, or the
11
+ host's own in-process bundle — and on a dedicated match it runs exactly the flow below, with the
12
+ negotiated transport ladder as its default. `gem.dedicatedServer.connect()` stays public for a game
13
+ that wants the options this page describes: `retry`, `onMessage`, the WebRTC-only session and the
14
+ raw `channel`. [`server-bundles.md`](server-bundles.md#the-same-bundle-hosted-by-a-player) shows
15
+ the two `gem.match` calls.
16
+
17
+ ## What this is and is not
18
+
19
+ This is the transport half only: protocol refusal, offer, answer, channel. **Rooms, membership, the
20
+ host, ready state and the match lifecycle are `rooms-and-matches.md`**, and this flow reads its
21
+ match from the room rather than from an argument.
22
+
23
+ It used to be otherwise. This flow was written around a room id it was handed and a `matchId` a game
24
+ had to carry from the host to the other players itself, because rooms were assigned elsewhere. That
25
+ was reversed on 2026-08-01 and the room now supplies both. `connect` takes neither id.
26
+
27
+ ## 1. How the request reaches gem-api
28
+
29
+ Over the ordinary authenticated client. No new transport, no new token path.
30
+
31
+ Allocation and connection details are two existing routes, and both take
32
+ `AuthenticatedGamePlayer` — the game-scoped play token the bridge already delivers:
33
+
34
+ | Step | Route |
35
+ | ------------------ | ----------------------------------------------------------------------------- |
36
+ | Allocate | `POST /v1/games/{game_id}/game-rooms/{game_room_id}/start_match` |
37
+ | Connection details | `GET /v1/games/{game_id}/game-rooms/{game_room_id}/matches/{match_id}/status` |
38
+
39
+ `game_id` comes from the token, never from the game — the same rule the HTTP transport already
40
+ applies to its default query. **No new foundation was needed for this:** the backend client, the
41
+ token source and the transport seam landed in earlier stages and this sits on top of them unmodified.
42
+
43
+ Three things about those routes are not free, though, and none of them is the caller's to supply any
44
+ more.
45
+
46
+ **Match status requires version fields, but only once the match is ready.** `platform` and
47
+ `game_version` are validated on the `ready` branch and nowhere else, and gem-api's shipped config
48
+ sets `require_version_fields`. Omitting them therefore polls healthily through every `pending`
49
+ response and fails at the exact moment the server comes up, with a 400 that reads like an SDK bug.
50
+ They are sent on every one of these calls for that reason. They are the same values the room was
51
+ joined with, which is what gates "everybody is on the same build" — and the SDK has an answer for all
52
+ three without asking the game: it reports the platform and the engine version itself, and the game
53
+ version comes from the arcade with the play token.
54
+
55
+ **`platform` is `web`, and the SDK reports it — a game does not supply one.** It decides which
56
+ package dimension the arcade checks this build against, and a browser game publishes its bundle in
57
+ the `web` dimension and nothing else. Report anything else and the check is run against a dimension
58
+ the game has never published into, where there is nothing to find and therefore nothing to refuse:
59
+ blacklist, not-released and expiry all pass without looking at the build at all. That is not a
60
+ weaker check, it is no check, and the way it fails is that nothing fails. A value passed here from
61
+ an older build is replaced with `web`, with a console warning saying so.
62
+
63
+ So every one of the platform's version controls reaches a web build, and **the version reported has to
64
+ name a build you actually published**. The arcade looks it up in the web dimension on the channel
65
+ being played: a number that matches no upload there is refused as `unknown_version`, one that was
66
+ never promoted to that channel as `not_released`, one you blacklisted as `blacklisted`, and one that
67
+ was replaced more than a day ago as `expired`. None of that is this SDK's machinery, but sending the
68
+ right platform is what turns it on.
69
+
70
+ **The game version is the arcade's answer, not the game's.** It arrives with the play token — the
71
+ arcade chose which build to route this player to, so it is the only party that can be wrong about it
72
+ by accident rather than by hand. `clientVersion.gameVersion` is deprecated and still read, which is
73
+ the fallback under an arcade too old to send one; where both exist the arcade's wins.
74
+
75
+ **The engine version is the SDK's too, and a game does not supply one either.** The room routes want
76
+ a third field — `engine_version`, the version a native build was exported from — and a browser game
77
+ is not an engine export, so it has none. It is not optional: `require_version_fields` covers all
78
+ three, and sending two is a 400. So the SDK reports `0.1.0-beta` on the game's behalf, exported as
79
+ `WEB_ENGINE_VERSION`, and the game supplies none of the three.
80
+
81
+ That is not a formality, because the value decides which fleet the match can reach: it derives an
82
+ engine _line_, and **the only web fleet line the platform runs is `0-beta`**. A game that had written
83
+ `1.0.0`, the obvious thing to write, would land on line `1`, match no fleet, and be told there is no
84
+ capacity — at the allocation, minutes after the create that actually decided it, naming a region
85
+ rather than a version. A build that does know its own engine version may still pass one; it is held
86
+ to a line a fleet runs and refused by name before anything is sent. How that line is derived, and why
87
+ its refusal is the hardest one on this path to read, is
88
+ [below](#two-version-lines-two-derivation-rules-and-a-refusal-that-names-neither).
89
+
90
+ **Allocation is host-only.** `start_match` requires the caller to be the room's host and refuses
91
+ everyone else with 403, before its idempotency fence — so in a four-player room, one player
92
+ allocates and three do not. Polling needs only room membership.
93
+
94
+ That used to force two entry points, and the second of them took a `matchId` nothing in the SDK could
95
+ produce. It does not any more: **the room knows who the host is**, so `connect` allocates for that
96
+ player and waits for the rest, and it learns the match id from the room. One method, called
97
+ identically by every member. See `rooms-and-matches.md` §2.
98
+
99
+ That answers the question the work started from. The offer takes a different route, and it is the
100
+ subject of the next section.
101
+
102
+ ### Two version lines, two derivation rules, and a refusal that names neither
103
+
104
+ Three version fields go up with a room, and **two of them derive a _line_** — a compatibility group
105
+ — by **different rules**. This is the most expensive thing to get wrong on this path, because
106
+ neither failure mentions a version.
107
+
108
+ - **The engine line decides which fleet the match can be allocated from.** It comes from the engine
109
+ version, which the SDK supplies: the semver major plus any prerelease tag, build metadata dropped.
110
+ `0.1.0-beta` → `0-beta`.
111
+ - **The game line decides which published server build serves the match.** It comes from the version
112
+ the build was published under, by a different rule: `0.{minor}` while you are on `0.x`, the bare major from
113
+ `1.0.0` on, and a prerelease is a line of its own. `0.4.7` → `0.4`.
114
+ [`platform-rules.md`](platform-rules.md#4-the-compatibility-line) has the full table.
115
+
116
+ So your client build and your published server bundle have to land on the same **game** line — a
117
+ `0.4.7` client against a `0.5.0` server build has no server, though the two numbers look adjacent.
118
+ The engine line is not yours to choose at all.
119
+
120
+ **The two failures look nothing alike, and the second is the trap.**
121
+
122
+ | What is wrong | What the host gets from `start_match` |
123
+ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
+ | The room's game line has no playable server build | 409 `no_compatible_server`, with `details.reason`: `none_on_channel` (this channel has no servable server build at all), `none_in_line` (it has some, none on this line) or `expired` (this line's builds aged out of the channel's expiry window) |
125
+ | The engine line matches no fleet | 503 `capacity_unavailable` — **the same refusal a genuinely full fleet gives**, because the allocator cannot tell the two apart |
126
+
127
+ The second row is why this is worth a section. A capacity refusal carries a retry hint and reads as
128
+ "try again shortly", so a game that supplied a plausible-looking engine version of its own would
129
+ wait through it forever: no amount of capacity ever arrives for a line no fleet runs. **What
130
+ separates the two is the message**, which names the engine and the line the allocator looked for. A
131
+ 503 that does not clear is worth reading before it is treated as a fleet problem.
132
+
133
+ ## 2. How the offer reaches the server
134
+
135
+ **Through the parent page, over the player's notification WebSocket.** Not because that is convenient
136
+ but because it is the only route: the frame cannot open that socket, and gem-api's dedicated signaling
137
+ lives on it.
138
+
139
+ ### 2a. What gem-api implements
140
+
141
+ The full path is shipped. The browser sends `webrtc_offer` on its notification socket; gem-api
142
+ validates the SDP structurally, pins the session to its DTLS fingerprint, stores the offer under a
143
+ session id, and delivers it to the dedicated server allocated to that match. The server answers on
144
+ its own separate socket, and the answer comes back to the offering player as
145
+ `webrtc_sdp_answer`. A refusal at any point comes back as `webrtc_error`.
146
+
147
+ Three properties of that path shape this side:
148
+
149
+ - **The offer is relayed verbatim and no candidate is ever read.** gem-api validates structure only.
150
+ - **The gate is room membership, resolved from the ALLOCATION.** The message's `game_id` is a lookup
151
+ key that can only narrow, never select; the room comes from the allocation and the membership check
152
+ runs before the offer is stored or published. A frame naming a match it is not in is refused with
153
+ `not_match_member` and the offer reaches nobody.
154
+ - **Silence is reported.** A publish that finds no subscriber answers `webrtc_no_server` rather than
155
+ leaving the offer to expire — which matters here, because the whole handshake has to fit inside a
156
+ sixty-second credential window.
157
+
158
+ `ServerProtocol` also has its `webrtc_dedicated` value now, so a match can report the protocol step 3
159
+ requires.
160
+
161
+ ### 2b. Why the frame cannot send it directly
162
+
163
+ The platform design has the exchange riding the notification WebSocket on the reasoning that "the
164
+ browser holds one anyway". **That is true of the Arcade page and false of the game frame**, and that
165
+ difference is the whole of the argument.
166
+
167
+ `POST /v1/ws/ticket` takes `AuthenticatedPlayer`, which rejects every game-scoped token by kind before
168
+ it does anything else. The ticket exists because the browser WebSocket API cannot set handshake
169
+ headers, so there is no bearer-header route around it either.
170
+
171
+ A page that signs the player in holds both credentials and feels no friction — it can call room and
172
+ match routes with a game token and `/v1/ws/ticket` with an account token, in the same file. A
173
+ game inside the Arcade iframe has only the first, by design: handing the account token across the
174
+ bridge is the exact thing the game-scoped token exists to prevent.
175
+
176
+ `GET /v1/regions` has the same shape and the same consequence — see §5.
177
+
178
+ ### 2c. So the parent relays, under a template it owns
179
+
180
+ Exactly as P2P signaling does, with a smaller message set and its own capability.
181
+
182
+ | Direction | Bridge message | Socket message |
183
+ | -------------- | ----------------------------------- | ------------------- |
184
+ | Frame → parent | `gem.dedicatedOffer` | `webrtc_offer` |
185
+ | Parent → frame | `gem.dedicatedEvent` (`answer`) | `webrtc_sdp_answer` |
186
+ | Parent → frame | `gem.dedicatedEvent` (`error`) | `webrtc_error` |
187
+ | Parent → frame | `gem.dedicatedEvent` (`matchEnded`) | `match_ended` |
188
+
189
+ The frame names no socket message type and supplies no game id. It sends a match id, an SDP and an
190
+ intent; the parent selects the message type, derives `game_id` from the token it issued, meters the
191
+ write against the socket's per-second budget — one budget shared with P2P, because there is one
192
+ socket — and refuses a frame the socket would not carry.
193
+
194
+ **`dedicated` is its own capability rather than part of `p2p`.** A page may relay one transport and
195
+ not the other; gem-api ships an independent kill switch for each on exactly that reasoning. One token
196
+ covering both would make a partial parent indistinguishable from a complete one, and the game would
197
+ find out by sending an offer nobody answers.
198
+
199
+ **The match id is not pinned, and only on this message.** `gem.signal` refuses a match the parent has
200
+ not seen on the socket, exempting the P2P join because a player who joins a room already in a match
201
+ learns the id by reading the room rather than from a push. The dedicated offer is that same case with
202
+ no join ahead of it — it is the first message on this path — so a pin would make "join a match already
203
+ running" unconnectable outright. What stands in is gem-api's own membership check above, which is
204
+ stronger here than on the P2P path because a refused offer reaches nobody at all.
205
+
206
+ **The error push is load-bearing.** gem-api stamps no game id on `webrtc_error`, so the parent scopes
207
+ it by match: an error crosses for a match the socket has mentioned or one this frame has legitimately
208
+ asked about. Without that, every refusal on this path is dropped and reads as a server that never came
209
+ up. The same rule fixes `p2p_error`, which has the same shape and was being dropped for the same
210
+ reason.
211
+
212
+ **The parent can refuse an offer itself, on the same push.** An `error` for an offer the parent did
213
+ not put on the socket settles the exchange just as gem-api's does, and carries a code the parent
214
+ wrote. A code beginning `embedder_` is always one of those, for an offer that never left the page.
215
+ Where the parent's refusal means exactly what one of gem-api's codes means, it sends that code
216
+ instead, so a code without the prefix does not prove the offer reached gem-api.
217
+ `embedder_relay_unavailable` is a momentary socket outage: the offer passed every check the parent
218
+ makes before a send and the socket was not open at that moment, so nothing needs fixing and a resend
219
+ may succeed. `embedder_no_relay` is final: the parent advertised `dedicated` and has nothing wired to
220
+ send an offer with. That will not change while the page is open, so a resend cannot help.
221
+
222
+ **The match-ended push is what makes a client let go**, and it is a different socket type from the
223
+ P2P one. gem-api sends `p2p_match_ended` when a peer-to-peer match ends and plain `match_ended` when
224
+ a dedicated one does — the second to the room's members, from `match_end`, which is the route a game
225
+ server calls when it is finished. This SDK listed only the first, so the second reached no allowlist
226
+ and was dropped like any type it does not know, and a browser left open on a finished match never
227
+ learned the match was over. See §7 for what that cost.
228
+
229
+ ## 3. What that means for the shape
230
+
231
+ Signaling is a seam — an interface with one method — and the shipped implementation relays over the
232
+ bridge.
233
+
234
+ ```ts
235
+ interface SignalingChannel {
236
+ exchange(request: OfferRequest, signal?: AbortSignal): Promise<SignalingAnswer>;
237
+ close(): void;
238
+ }
239
+ ```
240
+
241
+ One complete offer in, one answer out. That is not a placeholder shape chosen to defer a decision: the
242
+ platform design's non-trickle choice makes the exchange a single round trip, so an interface with a
243
+ candidate stream in it would be modelling something the design deliberately does not do. It fits on
244
+ the socket because gem-api's client frame ceiling was raised to 16 KB for it — a complete gathered
245
+ offer plus its JSON envelope passes, which is what makes non-trickle possible at all.
246
+
247
+ The seam remains a seam because the bridge is not the only arrangement worth supporting: a game
248
+ embedded somewhere other than the Arcade, or a test that wants the sequence without a bridge, supplies
249
+ its own. When nothing is wired — the parts assembled by hand — the fallback **throws
250
+ immediately, naming what is missing**. Never a hanging promise and never a silent no-op: a game that
251
+ calls this outside a relaying parent gets a sentence explaining why, not a spinner.
252
+
253
+ The flow does not trust a supplied one to settle either. The exchange is bounded and abort-aware on
254
+ this side, because "the seam honours its contract" is an assumption about code this repo does not
255
+ own, and the cost of it being wrong is a promise that never settles.
256
+
257
+ **The flow closes the channel it was given** — when the session ends, and on every failure, including
258
+ the refusals that happen before the handshake begins. A narrower promise would orphan a channel on
259
+ every attempt that refuses early: a match that never allocated, or one reporting a protocol this flow
260
+ will not connect. It matters for the bridge implementation specifically, which shares one routing
261
+ table across the document — which is why `connect` is handed a factory and closes what it built,
262
+ rather than holding a channel that would take the table down with the first failed match.
263
+
264
+ ## 4. The connect sequence
265
+
266
+ 1. **Allocate — if this player is the host.** `start_match`, in the room's own cluster (§5). Returns a match id,
267
+ and `pending` for a dedicated allocation. Every other player skips this step without being told to:
268
+ the room answers "am I the host", so the call is the same one.
269
+
270
+ **This step can find no server and is retried.** A start that arrives when no server is available
271
+ is refused with 503 until the platform has another. `startMatch` absorbs that with a jittered
272
+ exponential backoff inside a **30-second** budget and reports each wait as `matchStartWaiting`, so
273
+ the game has something to render. Two refusals in a row move the start, once, to the next cluster
274
+ the player can play well in (§5). That budget is set by what a player will sit through rather than
275
+ by how long the platform may take, so it will miss some starts a longer one would have caught.
276
+ `rooms-and-matches.md` §4.4 has that reasoning, the other numbers, why retrying this route
277
+ cannot duplicate a match, and the refusals that are deliberately not retried — including a
278
+ peer-to-peer room, which fails at once rather than waiting for a server nothing is allocating.
279
+ **`connect()` therefore has two bounded waits in series** — this one for a match to exist, then
280
+ step 2's for it to come up — and `retry` and `timeoutMs` are the separate knobs on them.
281
+ `gem.match.connect()` has the same two waits but exposes only `timeoutMs`; the start budget stays
282
+ at its 30-second default there, and `gem.dedicatedServer.connect({ retry })` is how to change it.
283
+
284
+ 2. **Wait for the match.** The room reaches `in_game` carrying a match id, which is how a non-host
285
+ learns there is one at all, and the manager polls status to a terminal state. Bounded — a poll loop
286
+ with no ceiling is how a game hangs on a fleet that will never have capacity. `awaitMatchOutcome`
287
+ resolves carrying `ready` or `failed` rather than rejecting on `failed`, because a match that failed
288
+ to allocate is an outcome a caller can branch on; `rooms.awaitMatch` rejects, because a caller
289
+ asking for the match it is about to connect to has to be told it is not coming.
290
+ 3. **Refuse an unrecognised protocol, loudly.** Only `webrtc_dedicated` proceeds.
291
+ 4. **Offer.** A peer connection with **no ICE servers**, one reliable ordered data channel, and no
292
+ media. Gather to completion, send one offer, bounded.
293
+ 5. **Answer.** Check it, apply it, wait for the channel to open.
294
+ 6. **Present the join credential** as the first message on the channel, and wait for the runtime's
295
+ answer. The format belongs to the server runtime and is
296
+ [`server-bundles.md`](server-bundles.md): a `{"t":"join","credential":"<jwt>"}` frame, before
297
+ anything else, answered with `welcome` or `rejected`. `connect()` resolves once the server has
298
+ **accepted** this player, not merely once the transport is up.
299
+
300
+ This used to be the game's step, and the argument for that was that the flow attaches no message
301
+ handler so `channel.onmessage` stays the game's alone. What that produced in practice was every
302
+ game writing the same exchange, and the first one to ship on this platform discovering by hand
303
+ that a rejection carries a reason, that two of the five reasons are worth another attempt, and
304
+ that the wait belongs to the credential rather than to a constant. A game that gets any of it
305
+ wrong presents as a channel that opened and then went quiet — which reads as a platform fault and
306
+ gets chased in the wrong repository.
307
+
308
+ The handler is removed the instant the exchange settles, so `channel.onmessage` is still the
309
+ game's. `presentCredential: false` takes the raw channel back for a game that wants the exchange
310
+ itself; `session.welcome` is then null.
311
+
312
+ ⚠️ **The gap between the welcome and your handler.** A data channel with no listener drops what
313
+ arrives — nothing buffers, anywhere — and the server bundle's `join` callback runs at exactly that
314
+ moment, so its opening snapshot is the message most likely to be lost. Attaching in the
315
+ continuation right after `await connect()` is normally fast enough, and `connect({ onMessage })`
316
+ removes the gap entirely by attaching before the join frame goes out. Do not do both: two handlers
317
+ see every message twice. `gem.match.connect()` takes no `onMessage` option — attach
318
+ `connection.onMessage(...)` in the continuation immediately after the `await`.
319
+
320
+ ### Choosing a transport
321
+
322
+ A match is served over up to three transports on one port — **WebTransport**, **WebRTC** and
323
+ **WebSocket** — and **which of them, in what order, is a property of the room template, not of your
324
+ game's source.** The template's `transports` list in your game config is the ladder:
325
+
326
+ ```yaml
327
+ game_room_templates:
328
+ - slug: skirmish
329
+ match_type: dedicated
330
+ transports: [webtransport, webrtc, websocket] # most preferred first
331
+ ```
332
+
333
+ Each entry is one of `webtransport`, `webrtc`, `websocket`; the config upload refuses anything else,
334
+ an empty list, or a repeated entry. A template that names no list gets exactly that default order.
335
+ A `match_type: p2p` template may name only `webrtc` — a browser hosts nothing else — and a
336
+ `match_type: none` template may name none. Different templates in one game can carry different
337
+ lists, so a LAN-party template can be WebSocket-only while the ranked one keeps the full ladder.
338
+
339
+ The platform resolves the list onto every room read (`room.transports`, most preferred first), and
340
+ hands the same list to whoever hosts the match: the dedicated-server runtime offers only the
341
+ transports on it, and the SDK dials only those, in that order. **Your code names no transport:**
342
+
343
+ ```js
344
+ const connection = await gem.match.connect();
345
+ connection.onMessage((data, meta) => handle(data, meta));
346
+ connection.send(update, { reliable: false, ordered: false });
347
+ console.log(connection.transport); // the rung that won: 'webtransport' | 'webrtc' | 'websocket'
348
+ ```
349
+
350
+ `gem.match.connect()` reads the room's list and negotiates across it. `gem.dedicatedServer.connect()`
351
+ does the same when handed `transports: []`; without the option it takes the WebRTC-only path this
352
+ document describes and resolves a `DedicatedServerSession` — which the SDK refuses as
353
+ `misconfigured` if the template does not offer WebRTC, since that path has nothing else to dial.
354
+ On the negotiated path `connect()` resolves a `MultiTransportSession` whose `link` speaks one
355
+ shape (`send`/`onMessage`/`onClose`/`close`) whichever transport won; `session.transport` names
356
+ the winner, and `session.channel`/`session.peerConnection` are populated only when it was WebRTC.
357
+
358
+ A non-empty `transports` option on either call only **narrows** the template's ladder to the kinds
359
+ named — its order is the template's, and it cannot add a transport the template left out. Most
360
+ games have no reason to pass it.
361
+
362
+ **The advertisement.** The runtime writes what it serves into the game room's settings under the
363
+ reserved `gem_transports` key — whether WebTransport and WebSocket are up, their URLs, and the
364
+ certificate hash a WebTransport client must pin — and it writes it **before** it reports the match
365
+ ready.
366
+
367
+ **Two WebTransport dials.** On platforms where the gameserver holds a DNS name and a CA-issued
368
+ certificate, the advert's `webtransport` entry also carries `named_url`: the same listener, dialled
369
+ by name with the browser's **ordinary certificate verification and no hashes**. The SDK prefers it
370
+ when offered — it is the only WebTransport an engine without `serverCertificateHashes` (WebKit) can
371
+ complete, and it works everywhere else too — and falls back to the pinned `url` if the named dial
372
+ fails, so a stale DNS record costs an attempt, never the rung. A named dial refused because the
373
+ browser's WebTransport has no datagram lane is the one exception: that is a fact about the browser,
374
+ so the SDK skips the pinned dial and the rest of the rung for the page. Games never look at any of this;
375
+ it is what `gem.match.connect()` does on your behalf. The ladder therefore reads the advert off a **fresh room fetch made after status reports
376
+ `ready`**, where that ordering is guaranteed; the room snapshot the manager already holds arrives on
377
+ a push that the status poll can beat, so it serves only as the fallback when the fetch fails.
378
+ (`readTransportAdvert(room.settings, matchId)` is the reader, whichever copy it is handed.) A missing advert
379
+ (an older runtime, or a game that overwrote the settings object wholesale) means exactly "WebRTC
380
+ only", which is what every older runtime serves. The advert carries `match_id`, the match it was
381
+ written for; one bound to a match other than the one being joined is ignored as if none were
382
+ written, so an advert that outlives its match cannot redirect the next one's players. An advert
383
+ without the field — all a runtime predating it can write — is ignored too: it cannot say which
384
+ match it was written for, and the match it claims is the one thing the check needs.
385
+
386
+ **The ladder.** Candidates are tried in the template's order, filtered first: WebTransport needs
387
+ the advert and a browser that has the API; WebSocket needs the advert; WebRTC qualifies without an
388
+ advert (an older runtime serves it) and with one unless the advert says `webrtc: false` — the
389
+ runtime's word that the template left it out. A ladder that filters to nothing refuses as
390
+ `misconfigured`, naming the template's list. A rung that fails
391
+ with a _transport-shaped_ reason — `timeout`, `connection_failed`, `signaling_unavailable`,
392
+ `signaling_failed` — falls through to the next, and so does a WebRTC rung the page refused with
393
+ `embedder_no_relay`, because that refusal is about the page's relay, which only WebRTC uses;
394
+ `credential_expired` and `credential_reused` fetch a fresh credential and retry the same rung once;
395
+ `join_rate_limited` waits — at least two seconds, growing, with jitter, for three retries or about
396
+ fifteen seconds in all — and retries the same rung, never the next one down, because a lower rung
397
+ would spend another join from the budget that just refused one; anything else (`join_refused`,
398
+ `match_ended`, `aborted`, …) aborts the whole negotiation, because it would fail identically on
399
+ every transport. If every rung fails, the error names which transports were tried and carries the
400
+ last rung's `reason` and `platformCode`, and so its `retryable`: `cause` holds each transport's own
401
+ failure, in the order tried, so an earlier one that was worth retrying is still there to read.
402
+
403
+ Why the order: WebTransport is preferred where it works — real datagrams, one QUIC handshake — but
404
+ it shares WebRTC's UDP reachability, so it rescues nobody WebRTC strands. The transport that does
405
+ is WebSocket: TCP, for [the networks that drop UDP outright](#which-players-can-reach-a-server-and-which-cannot).
406
+ Every delivery on it is reliable and ordered, because TCP has nothing else.
407
+
408
+ **The tagged wire (`wire: 2`).** On this path the join frame carries `"wire": 2`, which selects the
409
+ tagged framing on every transport: binary messages lead with one flags byte naming what the sender
410
+ _asked for_ (unreliable, unordered), text stays reliable ordered text. The flags cross even when
411
+ the transport upgraded delivery — a WebSocket player receives an unreliable-flagged message
412
+ reliably, and `meta` still says what was asked. On the WebRTC rung the SDK also opens a second
413
+ `gem-uu` data channel (unordered, zero retransmits) so unreliable sends get real drop semantics.
414
+
415
+ **The secure-context WebSocket caveat.** A `ws://` (TLS-less) listener cannot be dialled from a
416
+ secure context — the browser refuses the mixed-content socket — so a TLS-less advert qualifies only
417
+ where the page itself is not secure (local development, essentially). In the Arcade, WebSocket is
418
+ only ever tried when the runtime advertises `tls: true`.
419
+
420
+ **P2P is WebRTC, by construction.** A `webrtc_p2p` match never reaches this ladder: it is a
421
+ different flow with its own connect (`p2p-and-parties.md`), and this one refuses the protocol at
422
+ step 3 exactly as before. Its template's `transports` resolves to `['webrtc']` whatever it says —
423
+ which is why the config upload refuses a `p2p` template that names anything else. A game does not have to pick between the two itself: `gem.match.connect()`
424
+ reads the protocol and takes this ladder for a dedicated match or the P2P session for a
425
+ `webrtc_p2p` one, and `gem.match.serve(bundle)` runs your published server bundle in the hosting
426
+ browser when the template says P2P.
427
+
428
+ ### What one complete offer weighs
429
+
430
+ Step 4 sends the offer whole, and gem-api refuses an SDP at **10240 bytes** — by closing the socket,
431
+ not by answering, so an offer that is too big reads to the game as a connection that silently never
432
+ establishes. The design rested on that never happening and nobody had measured it.
433
+
434
+ Measured on 2026-08-02, by building the connection this flow builds — no ICE servers, one ordered
435
+ data channel, no media — gathering to completion and weighing the result. Chromium 150, Firefox
436
+ 153.0.1, WebKitGTK 2.43.4 (Safari has no Linux build; the engine is the same one). Chromium 149 and
437
+ Firefox 123 were measured alongside them and produced identical byte counts, which is a useful thing
438
+ to know about a number this design leans on: two years of releases did not move it.
439
+
440
+ | Machine | Chromium | Firefox | WebKit |
441
+ | ---------------------------------------- | ------------------ | -------- | -------- |
442
+ | This one — ethernet, a VPN adapter | 587 B, 1 candidate | 716 B, 2 | 703 B, 2 |
443
+ | Synthesised with **8** virtual adapters | 716 B, 2 | 907 B, 4 | 913 B, 4 |
444
+ | Synthesised with **64** virtual adapters | 716 B, 2 | 907 B, 4 | 914 B, 4 |
445
+
446
+ **Sixty-four adapters cost nothing over eight, or over none.** The worst offer any engine produced was
447
+ 914 bytes, an **11x** margin against the ceiling, and every gather completed — in 35–278 ms, against
448
+ the flow's three-second wait. Headroom that size is not a number to defend; it is a number that says
449
+ the question is closed.
450
+
451
+ It is closed for a structural reason rather than a lucky one. With no ICE servers there is nothing to
452
+ reflect or relay off, so the list is local addresses only — and every engine gathers on the **default
453
+ route alone**, ignoring every other adapter on the machine. The candidate list is a property of the
454
+ page, not of the network it sits on.
455
+
456
+ One thing widens it, in one engine: a Chromium frame **holding a camera or microphone permission** is
457
+ offered every adapter there is. The list then grows linearly, at about 115 bytes a candidate, and
458
+ crosses the ceiling at around **forty adapters**.
459
+
460
+ | Adapters (Chromium, capture permission held) | 8 | 16 | 32 | **40** | 48 | 64 |
461
+ | -------------------------------------------- | ------ | ------ | ------ | ----------- | ------- | ------- |
462
+ | Offer | 2955 B | 4796 B | 8486 B | **10333 B** | 12172 B | 15884 B |
463
+ | Candidates | 22 | 38 | 70 | 86 | 102 | 134 |
464
+
465
+ Firefox and WebKit stayed at 4 candidates through all of it, permission or no permission.
466
+
467
+ Forty adapters is a container-heavy developer machine, not a player's. Two things have to be true at
468
+ once for it to matter, and the second is not this repo's to decide: **the Arcade would have to
469
+ delegate `microphone` or `camera` to the game iframe**, and the player would have to be on such a
470
+ machine, in Chromium. Until the frame can hold a capture permission at all, the ceiling is
471
+ unreachable from this flow.
472
+
473
+ When it does happen the failure is at least named and local: the frame-side size check refuses it as
474
+ `invalid_sdp` with the byte count, before anything is sent, rather than letting the socket close
475
+ underneath the game. That is the whole reason those checks are mirrored client-side.
476
+
477
+ Nothing here argues for moving a limit. Raising `max_sdp_size_bytes` would drag `MAX_FRAME_SIZE` with
478
+ it (they are tied by a boot assertion), trickling would be a protocol change on both ends for a
479
+ margin that is already 11x, and capping the candidate list would trade connectivity for bytes nobody
480
+ needs back.
481
+
482
+ ### Why step 3 is a refusal and not a fallback
483
+
484
+ `MatchStatusResponse` populates `host` and `port` for every dedicated match, including this one, where
485
+ they are advisory and the authoritative address is the candidate inside the answer SDP. The platform
486
+ design says plainly that "a client that does not recognise the protocol value will use them and fail".
487
+
488
+ Refusing also catches the rarer case worth catching: a match reporting a protocol this flow does not
489
+ recognise is one something upstream got wrong, and it is better for that to be loud at one end than
490
+ invisible at both. A client that shrugged and dialled host/port would be the second of those.
491
+
492
+ ### Why the client refuses media in both directions
493
+
494
+ The platform refuses signaling SDP that carries an active audio or video section, and that is a
495
+ compliance control rather than hygiene: voice belongs to the platform's own voice sessions, which
496
+ the server side of a match allocates and tears down ([`server-bundles.md`](server-bundles.md)), and
497
+ signaling is not a route to adding it here. Those sessions are themselves WebRTC and the player's
498
+ browser connects to one the platform runs; what this rule excludes is voice over the game's own peer
499
+ connections, not voice in the browser. This flow builds its own offer and adds no track, so the
500
+ rule is satisfied by construction — but the peer-connection factory is injectable, so a game _can_
501
+ hand in a connection with a track already attached. Checking our own offer before sending it turns
502
+ that into an immediate, explained refusal rather than a server rejection the game cannot interpret.
503
+
504
+ The **answer** is checked too, and that half is not merely symmetry: an answer accepting an audio
505
+ section establishes an inbound track in the browser, so checking only what we send would leave one of
506
+ the two directions unguarded.
507
+
508
+ ### Why the credential is spent against a deadline
509
+
510
+ The join credential from match status has a sixty-second TTL, and the whole remaining chain — offer,
511
+ answer, ICE, DTLS, SCTP, present — has to fit inside it. Over an already-open socket it fits
512
+ comfortably, but "comfortably" is not a guarantee, and a credential that expires mid-handshake fails at
513
+ the server with an error that reads like a bug in the game. The deadline is anchored to the moment
514
+ status was **asked for**, not the moment it answered — gem-api started the credential's clock a round
515
+ trip earlier, and deriving it from the reply would put the deadline late, which is the unsafe
516
+ direction. It then **caps every wait after that point**: the signaling exchange and the channel-open
517
+ wait each end at whichever comes first, their own timeout or the credential. At the tie the credential
518
+ is what gets named, because it is the bound a developer can do something about.
519
+
520
+ Absence is absence: a match that issues no credential (a P2P one does not) reports none, rather than an
521
+ empty string with a deadline of _now_ — which reads as a credential that expired the instant it
522
+ arrived, and sends a developer looking for a clock problem.
523
+
524
+ ### Which players can reach a server, and which cannot
525
+
526
+ This failure is **per player, not per game**. A permissive home network connects and an office
527
+ network may not, so a playtest that succeeds proves nothing about the shapes it did not run on —
528
+ and a per-player failure nobody expected gets reported as a flake.
529
+
530
+ **NAT is not the failure shape here.** The server is a passive ICE agent sitting on a publicly
531
+ routable address and port: it never initiates and never gathers, so the browser always sends the
532
+ first connectivity check, the server answers the source address it observed, and neither side has to
533
+ predict the other's mapped port. Every home router works, including the strictest filtering
534
+ discipline — the one usually called "symmetric NAT" — and so do CGNAT and double NAT. A connectivity
535
+ report that blames NAT type is pointing at the wrong thing.
536
+
537
+ What breaks it is a network that will not carry the packets at all:
538
+
539
+ | The player's network | Direct connection |
540
+ | --------------------------------------------------------------------------- | ----------------- |
541
+ | Any NAT, any filtering discipline, including "symmetric"; CGNAT; double NAT | works |
542
+ | Outbound UDP permitted to any destination port | works |
543
+ | **Outbound UDP permitted only to well-known ports** (443, 3478, 53) | **fails** |
544
+ | **Outbound UDP blocked entirely** | **fails** |
545
+ | **IPv6-only with no CLAT** | **fails** |
546
+
547
+ The third row is the one worth knowing about, because it is a common corporate and guest-wifi
548
+ posture rather than an exotic one. A dedicated server's UDP port is assigned dynamically and is not a
549
+ well-known one, so a firewall that permits UDP only to the ports it recognises blocks the game while
550
+ leaving the videoconferencing products on 3478 working perfectly — which is exactly the shape that
551
+ gets read as "your game is broken and everything else is fine". The last row fails because an ICE
552
+ candidate is an IP literal: there is no name for DNS64 to synthesise from, so a NAT64-only network
553
+ cannot reach it. Most IPv6-only mobile carriers deploy a CLAT and are unaffected.
554
+
555
+ **There is no relay to fall back on.** A TURN lease is a peer-to-peer feature: a dedicated match is
556
+ issued no relay, and the lease endpoint refuses one by name (`match_not_p2p`) if asked. So for the
557
+ three failing rows there is nothing to try today, and a player on such a network cannot reach a
558
+ dedicated match at all.
559
+
560
+ ### What the client sees when it does not connect, and why only the client can say
561
+
562
+ **gem-api stops watching once it has answered the offer.** It is not on the media path and never
563
+ reads a candidate address, so from the exchange onwards a connection that failed and one that
564
+ succeeded silently are the same thing from where it stands. Everything below the signaling layer is
565
+ visible only to the two endpoints.
566
+
567
+ So the cause is told apart by **which stage the attempt reached**, and the failure this flow raises
568
+ names it:
569
+
570
+ | Where it stopped | What happened |
571
+ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
572
+ | A **`platformCode`** without the `embedder_` prefix | Refused by the platform, or by the page for the same reason. The code is one of gem-api's: `match_not_found`, `not_match_member`, `account_banned`, `age_restricted`, `invalid_sdp`, `rate_limited`, `service_unavailable`, `fingerprint_mismatch`, `webrtc_no_server` (the offer was stored and nobody was listening for it) or `internal_error` |
573
+ | A **`platformCode`** beginning `embedder_` | The offer never left the page embedding your game, which refused it. `embedder_relay_unavailable` is a momentary socket outage, and a resend may succeed. `embedder_no_relay` is final: the page has nothing wired to relay with, and a resend cannot help |
574
+ | `timeout` waiting for an answer, and no `platformCode` | **Three causes, one silence.** The server heard the offer and either its answer was refused or it died — a refused answer is reported to the _server's_ socket, so the player is told nothing at all |
575
+ | `connection_failed` once the answer was applied | **This is the ICE failure**, and the network table above is where it comes from |
576
+ | `join_refused` once the channel opened | The transport is fine; the runtime turned down the join credential |
577
+ | `signaling_unavailable`, with no offer ever sent | No parent, or one that never offered to relay (it did not advertise `dedicated`). A property of where the game is embedded, not of the platform |
578
+
579
+ **`platformCode` is the discriminator worth keying on**, because its presence proves that whoever
580
+ was carrying the offer answered it — whatever `reason` the code was mapped to. It is not proof that
581
+ the offer reached gem-api: an `embedder_` code says it did not, and a code without the prefix cannot
582
+ say it did, because the page sends one of gem-api's codes where its own refusal means exactly the
583
+ same thing. The mapping is by whether another attempt could end differently: a malformed SDP arrives
584
+ as `invalid_sdp`; the four refusals that are facts about the account or about this client's picture
585
+ of the match arrive as `offer_refused`, and so does `embedder_no_relay`, which is a fact about the
586
+ page — it has nothing wired to relay with, and will not while it is open; and everything else —
587
+ including `webrtc_no_server` and `embedder_relay_unavailable` — arrives as `signaling_failed` and is
588
+ marked retryable, because a runtime still pulling its image answers the next offer, and a socket that
589
+ was down a moment ago may be open for the next one. On the negotiated path, `embedder_no_relay` costs
590
+ only the WebRTC transport: the SDK carries on to one that does not need the page.
591
+
592
+ **A single handler on "it failed" collapses the first four rows into one symptom**, and that is
593
+ precisely the shape that gets reported to a platform team as a flake. Record the stage. The
594
+ [boot line](what-a-game-must-do.md#boot-diagnostics-where-the-seconds-went) is the other half of the
595
+ same job, because it survives in a console capture taken from a player nobody can reach.
596
+
597
+ ### What a failed connect tells the platform
598
+
599
+ A connect that fails from step 4 onwards is counted once — bar the exclusions at the end of this
600
+ section — as a `connection_failures_total` event on
601
+ `POST /v1/games/{game_id}/telemetry/p2p` labelled `transport: dedicated`. The route is named for P2P
602
+ and is not only P2P's: it is scoped by **game** rather than by match, so a dedicated client reaches it
603
+ with the play token it already holds, and the label is what keeps the two transports apart once
604
+ counted. Nothing is wired for this — the flow reads the game id from the room it was already given —
605
+ because a reporter a caller can forget to pass is one that ends up with no callers.
606
+
607
+ **The client is the only party that can send it.** gem-api answers the offer and then stops watching;
608
+ it is not on the media path, so from the exchange onwards a connection that failed and one that
609
+ succeeded silently are the same thing from where it stands. A flat zero on that series means nobody
610
+ reported, not that nobody failed — which is precisely what it meant before this shipped.
611
+
612
+ **What is deliberately not reported: anything the platform decided itself.** An allocation it watched
613
+ fail, an offer it refused, a fleet it had no room in, a match it ended — those are counted where they
614
+ happened, and saying them again here would be this client telling the allocator what the allocator
615
+ just told it. Nor is a cancelled connect or a misconfigured game. The page's own final refusal,
616
+ `embedder_no_relay`, is reported all the same: it arrives as `offer_refused`, but the platform never
617
+ saw that offer.
618
+
619
+ So step 4 is where counting _becomes possible_, not a rule that everything past it counts. The
620
+ `match_ended` refusal of §7 is the case that shows the difference, and it is the one to keep in mind
621
+ when reading the two sections together: it lands squarely inside the handshake, and it is still not
622
+ reported — a match the platform ended is not a connection that failed, and the transport may have
623
+ been perfectly healthy right up to the push that ended it. Counting it would fill the panel that
624
+ exists to find players who could never connect with players whose match simply finished.
625
+ The set of reported reasons is an inclusion list, so a failure reason added later is not silently
626
+ swept in.
627
+
628
+ ## 5. Cluster target selection is the parent's, and the game names nothing
629
+
630
+ A dedicated allocation requires a `cluster_target_id`, and until this section was rewritten the SDK
631
+ made every caller supply one by hand on the reasoning that nothing reachable from the frame could
632
+ rank regions. The second half of that was true. The first half assumed the frame had to be the one
633
+ doing the ranking.
634
+
635
+ **Two endpoints, and they are not alternatives.** `GET /v1/ping/targets` is the NATIVE path: the
636
+ endpoint each target advertises is a UDP echo, which is precisely the measurement a browser cannot
637
+ make, so nothing in that response ranks anything. It stays, and the SDK still exposes it as
638
+ `dedicatedServer.listClusterTargets()` — enumeration, plus each target's coarse `pressure`, which
639
+ gem-api asks clients not to branch on until the refresher that varies it lands.
640
+
641
+ `GET /v1/regions` is the BROWSER path, published for exactly this purpose: per region, the cluster
642
+ target id and a list of **STUN** endpoints. A Binding request needs no credentials, no lease and no
643
+ match, so a browser can time them at startup and rank by round trip. It takes `AuthenticatedPlayer`,
644
+ so the frame cannot call it — the same boundary that keeps the frame off the notification socket.
645
+
646
+ ### The parent measures; the acceptable clusters cross
647
+
648
+ ```
649
+ Arcade page game frame
650
+ ─────────── ──────────
651
+ GET /v1/regions (player token)
652
+ time STUN probes per region
653
+ keep the close ones ──▶ up to three ids, best first
654
+ ──── gem.clusterTarget ────▶ held
655
+ create() ──▶ cluster_target_id (the first)
656
+ quickJoin() ──▶ cluster_target_ids (all of them)
657
+ startMatch() ─▶ nothing: the room's own
658
+ ```
659
+
660
+ This is what the native launcher already does for the engine, and putting it on the page rather than
661
+ in the frame is a decision rather than a convenience:
662
+
663
+ - **It happens once per player session.** Every game the page loads inherits the answer, across a
664
+ pack swap. In the frame it would be repeated per game and per reload, and each repeat is a fresh
665
+ round of UDP to every region.
666
+ - **The vector never reaches game code.** A round-trip time to every region is coarse geolocation
667
+ plus a network fingerprint, and a game frame is third-party code. What crosses is the ids of the
668
+ clusters close enough to the fastest that the player would not notice the difference — at most
669
+ three, best first — and nothing else: no timings, no list of the regions that were not close, no
670
+ `probe_targets_fresh`. Three ids with no timings is continent-level information. The field list
671
+ and the bound are pinned by a test, because the freeze rule would otherwise permit a `latencyMs`
672
+ "just for telemetry".
673
+ - **The token the page holds is the one the route wants.** No new credential, no new hole in the
674
+ frame sandbox.
675
+
676
+ The publisher-side constraints exist for browser reasons and belong to whoever measures: **at most
677
+ three STUN endpoints per region**, because Firefox degrades past two servers on a single
678
+ `RTCPeerConnection` and the cap is there so a drained node does not blank a region rather than as an
679
+ invitation to probe all three at once; and **IPv4 only**, because WebKit filters IPv6
680
+ server-reflexive candidates without camera or microphone permission, so an IPv6 target would make
681
+ Safari silently never answer and rank that region last on every Apple device.
682
+
683
+ ### A room carries its cluster
684
+
685
+ **Where a match runs is decided when the room is made, not when the match starts.** `create()`
686
+ names the best cluster, and the room keeps it. `quickJoin()` names every acceptable one: it joins
687
+ only a room whose cluster is in the list — so the players it puts together can all reach that
688
+ server well — and a room it has to make takes the first. `joinByCode()` and `join()` apply no such
689
+ filter; friends play together wherever the host is.
690
+
691
+ **`startMatch()` then names no cluster at all,** and the match starts where the room is. That is
692
+ what stops a host who inherited a public room from dragging it to their own region by pressing
693
+ Start. The one exception is a room that carries no cluster — made by an older client, or entered
694
+ before the page answered — which has nothing to fall back on, so the SDK names this player's best
695
+ one there.
696
+
697
+ Nothing means the field is omitted, never sent empty. A sentinel would be refused as an unknown
698
+ cluster instead: the same failure, reported as the caller's mistake.
699
+
700
+ The frame waits up to two seconds for a first answer, and only when the parent advertised the
701
+ `clusterTarget` capability. That covers the one race there is — a game that enters a room while the
702
+ page is still measuring — and **the grace is spent once per session**, across the create, the
703
+ quick-join and the start, so a parent that advertises and never sends does not put a stall in front
704
+ of every button.
705
+
706
+ **Passing `clusterTargetId` to `startMatch()` still wins, and it moves the room.** The match starts
707
+ in the cluster you named and, once it has started, the room's later matches start there too. That is
708
+ the override for a region picker. The SDK never second-guesses an id you named: neither recovery
709
+ below runs on one.
710
+
711
+ ### When a cluster refuses
712
+
713
+ The platform never moves a room on its own. When the cluster a room names stops being usable, the
714
+ host does, and the SDK does it for you:
715
+
716
+ | What gem-api answers | Where | What the SDK does |
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 |
719
+ | `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
+ | `capacity_unavailable` twice in a row | `startMatch()` | Moves to the next acceptable cluster, once, and keeps waiting there |
721
+
722
+ **Each of them happens once.** A second refusal is the answer, and it reaches you as the error.
723
+
724
+ **An automatic start has nobody to answer, so the host's SDK answers it.** When a room's template
725
+ starts the match as it fills to `min_players` and the platform refuses that start, no call is waiting
726
+ to be refused. The host's game gets `matchAutoStartFailed`, carrying the same error code a manual
727
+ start would have had — and for `invalid_cluster_target` and `capacity_unavailable` the SDK then
728
+ starts the match from the host's game itself, which runs the recoveries above. The event's `handled`
729
+ is `true` when it does.
730
+
731
+ That start is made **once per room until a match begins in it**, and never beside a start already in
732
+ flight. A later refusal reaches the game with `handled: false`, and so does every other code, because
733
+ those are about the build and the same start would meet them again. A game that wants to decide
734
+ passes `createGem({ retryAutoStart: false })`; the event then always says `handled: false`, and
735
+ starting the match yourself runs the same recoveries:
736
+
737
+ ```js
738
+ gem.rooms.on('matchAutoStartFailed', () => gem.rooms.startMatch());
739
+ ```
740
+
741
+ ### The degraded cases, and where each one lands
742
+
743
+ | What happened | What the SDK names | What the player gets |
744
+ | --- | --- | --- |
745
+ | Parent sent the acceptable clusters | the list on quick-join, the first on create | a room near them, with players near them |
746
+ | Parent sent one id, as an older page does | that id, as a list of one | the same, pooled a little more narrowly |
747
+ | Parent measured nothing and sent its `default_cluster_target_id` | that id | a room, not necessarily near |
748
+ | Parent does not advertise `clusterTarget` | nothing | a room that carries no cluster — see below |
749
+ | No clusters configured at all (`default_cluster_target_id` absent) | nothing | nothing to allocate in either way |
750
+
751
+ The fourth row is the one to be precise about. **A room entered with no cluster pools only with other
752
+ rooms that carry none**: quick-join matches it only for a request that names no cluster either. Its
753
+ automatic start runs in the platform's configured default cluster. A manual dedicated start on it
754
+ must name a cluster, and with nobody resolving one it is refused by name —
755
+ `400 cluster_target_required`. That is the honest outcome rather than a gap. The last row is why: an
756
+ absent `default_cluster_target_id` means no clusters are configured at all, which is local dev and
757
+ integration tests, where no dedicated allocation could have succeeded whatever id was sent.
758
+
759
+ ## 6. P2P
760
+
761
+ Its allocation already worked here — `start_match` is one route and the room reports whatever protocol
762
+ comes back, so a P2P match allocates and reaches step 3 unmodified, where this flow refuses it.
763
+
764
+ **One place the two allocations are now told apart, and it is step 1's retry.** A 503 means "no
765
+ capacity" for a dedicated start and "this backend has no P2P" for a peer-to-peer one — so the wait
766
+ above would be spent on a server nothing was ever going to allocate. The refusal's error code says
767
+ which (`capacity_unavailable` against `p2p_unavailable`), and a peer-to-peer start is refused at
768
+ once as `p2p_unavailable`; `rooms-and-matches.md` §4.4 has the mechanism. Nothing else in this flow
769
+ branches on it.
770
+
771
+ Its _connection_ is now built, and it is [`p2p-and-parties.md`](p2p-and-parties.md). It did not
772
+ fall out of the same shape and it did not try to: a designated host peer rather than a server,
773
+ trickled candidates rather than one gathered offer, a TURN lease, host migration and epochs. Sharing
774
+ the offer/answer nouns is not sharing the flow, and the two `connect` methods are separate for the
775
+ same reason the two refusals are. Separate underneath, and joined above: `gem.match.connect()` is
776
+ the branch between them written once, in the SDK, so the hosting decision stays in
777
+ `game_room_templates` rather than in every game's source.
778
+
779
+ The one thing worth carrying between them: **the token boundary was never about either transport
780
+ specifically.** Every other route on both paths takes a game-scoped token and is reachable from the
781
+ frame today; only signaling needs the socket. So both carry it over the bridge, under a parent-owned
782
+ template, and the two differ in the message set rather than in the mechanism — which is why the two
783
+ relays share their readers, their socket limits and their per-second budget, and differ in their
784
+ tables.
785
+
786
+ ## 7. Ending a match, and why the client has to let go
787
+
788
+ **A browser that finishes a match and stays open holds the server it was playing on.**
789
+
790
+ The obvious diagnosis is that the peers vanished silently and the server never noticed. That is
791
+ wrong, and the correction is worth keeping because it is the reason this fix lives on the client. The
792
+ runtime's departure detection works — measured directly against a real loopback DTLS/SCTP session with
793
+ the client socket pulled out from under it, no close of any kind, departure fires at 30 seconds on the
794
+ shipped configuration. What holds a pod is the opposite case: a tab left open on a **finished** match
795
+ goes on answering ICE consent checks, so the transport is genuinely alive and the player is genuinely
796
+ gone. To the server that is indistinguishable from someone sitting on their turn thinking.
797
+
798
+ So the teardown is the client's, and the signal for it is the platform's:
799
+
800
+ 1. The game server calls `match_end` when its match is over.
801
+ 2. gem-api sends `match_ended` to the room's members on their notification sockets.
802
+ 3. The parent relays it as `gem.dedicatedEvent` (`matchEnded`), scoped by game id like any other
803
+ push on that socket.
804
+ 4. The flow closes the session holding that match — the data channel, **the peer connection**, and
805
+ the signaling seam. Closing the channel alone is not enough: the ICE agent outlives it and the
806
+ consent checks keep flowing.
807
+
808
+ Four things follow from that shape and are worth saying out loud:
809
+
810
+ - **It is not reported as a disconnect.** A drop moves the room's match to `suspended`, from which
811
+ the SDK's own reconnect policy (since 4.0.0, 2026-09-10) or a `reconnectToMatch()` brings it
812
+ back; a match that has _ended_ has nothing to reconnect to, so the policy reads the match before
813
+ its first attempt and raises `matchEnded` instead of chasing it. Only the game's own `close()`
814
+ shares that suppression, and for the same reason. The session's phases and the policy are
815
+ [match-session.md](match-session.md).
816
+ - **A game that wants to know asks for `connect({ onMatchEnded })`, or listens for the room's
817
+ `matchEnded` event.** The callback is called once, with
818
+ gem-api's reason, immediately before the teardown — so the order observed is "the match ended",
819
+ then "the channel went away". Without it the only thing a game sees is `channel.onclose`, which is
820
+ also what a network drop looks like, and the two want opposite screens. The teardown happens either
821
+ way: this is how the game hears about it, not what makes it happen. `gem.match.connect({ onMatchEnded })`
822
+ is the same contract on every seat — dedicated, peer, and the host's own loopback.
823
+ - **An offer in flight is refused rather than left to time out**, with `match_ended`. A server that
824
+ no longer exists will not answer, and the alternative is spending a whole exchange timeout inside a
825
+ sixty-second credential window to learn a fact the socket already delivered. That refusal is _not_
826
+ reported as a connection failure, even though it interrupts the handshake — see §4: the platform
827
+ ended the match, so it already knows.
828
+ - **It only reaches as far as the push does.** A match whose server never calls `match_end` produces
829
+ no `match_ended`, and this SDK cannot invent one — the client has no way to distinguish "the match
830
+ is over" from "nobody has moved yet". That case is the server runtime's own liveness to answer, and
831
+ it is the platform's rather than this SDK's.
832
+
833
+ **The platform's own answer to that last case is a maximum duration, and it arrives as the same
834
+ push.** Every dedicated match has one — declared per room template in the game's config, between 30
835
+ minutes and 48 hours, and six hours if the template declares nothing. Reaching it ends the match the
836
+ way the game's own server ending it would, so what a client observes is an ordinary `match_ended`:
837
+ the teardown above runs unmodified, `onMatchEnded` is called with the reason, and there is no new
838
+ case for a game to handle. It bounds how long "nobody has moved yet" can go on rather than telling it
839
+ apart from "the match is over" — which is why it is a limit on the match and not a liveness check on
840
+ the client. The contract in full, including what the server itself is given to wind down in, is
841
+ [`server-bundles.md`](server-bundles.md#how-long-your-server-lives).
842
+
843
+ The peer-to-peer path already did all of this, from `p2p_match_ended`: `P2pSession` closes every link
844
+ it holds, and every link's connection with it. Both paths are pinned by tests, because they now owe
845
+ the same thing and a regression on either would be silent.
846
+
847
+ ## Developing against your own machine
848
+
849
+ Inside a development tool that can host — it advertises the `localHost` capability, which no
850
+ player-facing embedder carries — the platform can dispatch a dedicated match to a server running
851
+ on the developer's own machine instead of allocating one from the fleet. **Your game's source does
852
+ not participate.** Immediately before starting a match, the SDK asks the embedder for a server for
853
+ the room; the tool spawns your game's own server bundle, registers it with the platform, and the
854
+ match that follows is dispatched to it. A game published to players never runs inside such an
855
+ embedder, so there is nothing to gate and nothing to remove before shipping.
856
+
857
+ Three things are worth knowing while you develop this way:
858
+
859
+ - **The ask happens before the match starts, and a refusal arrives as `startMatch()` rejecting —
860
+ never as a hang.** The two failure shapes are deliberately different. A platform refusal (you are
861
+ not signed in as a developer on this game, or the room is not on a development channel) is
862
+ terminal, and retrying sends the same request to the same refusal. A machine that merely could
863
+ not start the process is retryable, and your tool has the log. Silence is bounded too: an
864
+ embedder that never answers is given thirty seconds, then the rejection says so. However many
865
+ times a racing Start button asks, one server is started per room.
866
+
867
+ - **The winning transport is usually WebSocket, so take the negotiated path.** A locally run server
868
+ typically offers no WebTransport certificate chain and no WebRTC signaling; the ladder works
869
+ exactly as [§4](#4-the-connect-sequence) describes and WebSocket wins. That is invisible to a
870
+ game on `gem.match.connect()` — `link.send`/`link.onMessage` speak one shape whichever rung won —
871
+ but code that reaches into WebRTC internals breaks here first: `session.channel` and
872
+ `session.peerConnection` exist **only when WebRTC won**, so a
873
+ `session.channel.readyState` read that works against the fleet throws on a locally served match.
874
+ Check `session.transport` before touching either, or better, never touch them.
875
+
876
+ - **Everything downstream is the real flow.** The dispatch is the platform's own: the join
877
+ credential, the roster, run reporting and `match_ended` all arrive exactly as this document
878
+ describes, and your server bundle runs unmodified. What you test locally is what ships, minus
879
+ the datacentre.