@indiegems/gem-web-sdk 7.4.0 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +530 -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,783 @@
1
+ # Peer-to-peer, and parties — the browser half
2
+
3
+ Two surfaces that look unrelated and are not. Both run into the same wall: **the
4
+ frame holds a game-scoped token and the page holds the player's account**, so
5
+ anything reaching for an account-scoped route has to happen on the far side of
6
+ the bridge. P2P hits it for signaling; parties hit it for everything. What
7
+ follows is how each one gets across, and where the web genuinely cannot look
8
+ like a native client.
9
+
10
+ Companion to [`rooms-and-matches.md`](rooms-and-matches.md), which this
11
+ builds on: rooms answer "which match", and this answers "how do I get onto it"
12
+ for the protocol the other flow refuses.
13
+
14
+ **You may not need to touch `gem.p2p` at all.** `gem.match.serve(yourServer)`
15
+ runs the same `defineServer` bundle you publish for dedicated matches in the
16
+ hosting player's browser, over the session this page describes, and
17
+ `gem.match.connect()` gives every seat — the host included, through an
18
+ in-process loopback — one `MatchConnection`. That is the whole of P2P for a
19
+ game whose authority is its server bundle;
20
+ [`server-bundles.md`](server-bundles.md#the-same-bundle-hosted-by-a-player)
21
+ shows the two calls. `gem.p2p` stays public for raw channels, host-migration
22
+ control and transport tuning, and that is what the rest of this page is about.
23
+
24
+ ---
25
+
26
+ ## 1. The surprise: almost all of P2P is already reachable
27
+
28
+ The dedicated-server flow spent a long time looking blocked
29
+ ([`dedicated-server-flow.md`](dedicated-server-flow.md) §2), and only one
30
+ of those blocks was real: the notification socket. P2P was expected to be worse,
31
+ and it is not — it has that same one and nothing else. Every route below takes gem-api's
32
+ `AuthenticatedGamePlayer` — the game-scoped play token the bridge already
33
+ delivers — and the frame can call all of them today, unmodified:
34
+
35
+ | Step | Route |
36
+ | -------------------------------------------- | ------------------------------------------------------------- |
37
+ | Bind this browser's certificate to the match | `POST /v1/games/{game_id}/matches/{match_id}/join-credential` |
38
+ | Lease a TURN relay | `POST /v1/games/{game_id}/matches/{match_id}/turn-lease` |
39
+ | Remove a peer (host only) | `POST /v1/games/{game_id}/matches/{match_id}/kick` |
40
+ | Leave | `POST /v1/games/{game_id}/matches/{match_id}/leave` |
41
+ | Report connection telemetry | `POST /v1/games/{game_id}/telemetry/p2p` |
42
+ | Verify a peer's credential | `GET /oauth2/jwks` (public) |
43
+
44
+ **One thing is not reachable, and it is the same one as before.** Signaling
45
+ rides the notification WebSocket, `POST /v1/ws/ticket` accepts only player
46
+ tokens, and the browser WebSocket API cannot set a bearer header — so there is
47
+ no route around it. That is a platform decision, settled and not being
48
+ revisited.
49
+
50
+ So signaling is the only thing this SDK routes through the parent. Nothing else
51
+ has to be, and nothing else is.
52
+
53
+ ### 1.1 What decides a match is peer-to-peer, and why no game gets one by default
54
+
55
+ Reachability is not what stops a first P2P match happening. **The room's template
56
+ decides the transport**, and nothing a game ships chooses this one until somebody
57
+ authors it.
58
+
59
+ `match_type` is a field on a room template. It takes `dedicated`, `p2p` or
60
+ `none`, and it **defaults to `dedicated`**. A room created from a template gets
61
+ that template's value; **a room created with no template at all is dedicated**.
62
+ So are both templates the platform ships globally — `standard` is dedicated and
63
+ `hub` starts no match — and those two are compiled in rather than stored, so no
64
+ config upload changes them and there is no global `p2p` template to name.
65
+
66
+ **A P2P match therefore needs a game-specific room template carrying
67
+ `match_type: "p2p"`.** That is upstream of everything else in this document, and
68
+ it fails two different ways depending on what the client asked for:
69
+
70
+ - **The client names a slug the game's config does not carry** — room create is
71
+ refused for an unknown template slug, which reads like a client bug and is not
72
+ one.
73
+ - **The client names no template at all** — it gets a room, it gets a match, and
74
+ it gets a **dedicated** one. There is no error anywhere; the first thing to
75
+ object is this SDK's own protocol check once the match reports ready. This is
76
+ the worse of the two, and it is the reason to name your template explicitly
77
+ rather than relying on a default.
78
+
79
+ ### 1.2 The template ships in your game config
80
+
81
+ Room templates are authored in the game config you upload, under a
82
+ **`game_room_templates`** key, and `match_type: "p2p"` is the field that matters
83
+ here. Upload the config, assign that version to a channel, and rooms created on
84
+ that channel can be built from it. The rest of the authoring surface — which
85
+ fields are required, how a slug resolves, and the several ways a template can
86
+ validate and still be unreachable — is
87
+ [`platform-rules.md` §5](platform-rules.md#authoring-your-own); only the P2P
88
+ half is repeated here.
89
+
90
+ The published game-config JSON Schema declares the key, `match_type` enum and
91
+ all, so a correct P2P config validates locally as well as on upload. Both
92
+ schema endpoints are public, so if a validator you are running disagrees, fetch
93
+ the schema yourself and check which copy it was holding.
94
+
95
+ Two related facts, because both send people hunting for an endpoint that is not
96
+ there:
97
+
98
+ - **The platform's template routes are read-only.** Both the global list and
99
+ your game's resolved list answer `GET` and nothing else, so a template cannot
100
+ be poked in over the API. The config upload is the only route.
101
+ - **You can check the result from inside the game.** `GET
102
+ /v1/game-room-templates?channel=<channel>` answers with what a room can
103
+ actually be built from — your config's templates first, the globals behind
104
+ them — and it takes the game-scoped token the frame already holds. So a client
105
+ can confirm the template it is about to name exists, rather than discovering it
106
+ at room create.
107
+
108
+ ---
109
+
110
+ ## 2. Signaling goes both ways, and that is new
111
+
112
+ `gem.roomEvent` is parent→frame, four event types, low rate, carrying no state.
113
+ P2P signaling is the opposite on every axis: bidirectional, per-peer, and bursty
114
+ — ICE candidate trickle arrives in clumps as the browser works through its
115
+ interfaces.
116
+
117
+ ```
118
+ frame parent gem-api
119
+ │ │ │
120
+ gem.signal ──────────────────▶ │ template + checks ─────▶ notification WS
121
+ (6 templates) │ │
122
+ │ ◀──────────────── gem.signalEvent ◀────────── 9 push types
123
+ ```
124
+
125
+ ### 2.1 The outbound direction is the whole risk of this work
126
+
127
+ The socket belongs to the **player**, not to the game. It carries friend
128
+ requests, party invites, transfer receipts and purchase completions. A frame able
129
+ to ask the parent to put an arbitrary frame on it has escaped the sandbox — so
130
+ `gem.signal` is deliberately **not** a message the parent forwards.
131
+
132
+ It is a **request the parent fills in a template from**:
133
+
134
+ - The frame names one of **six templates** (`join`, `offer`, `answer`, `ice`,
135
+ `migrationAck`, `hostUnreachable`) and supplies arguments. It never names a
136
+ socket message type.
137
+ - The parent copies **only the fields that template declares**, by name, onto a
138
+ fresh object. Anything else is dropped rather than forwarded.
139
+ - **`game_id` is the parent's**, taken from the token it issued. A frame that
140
+ could name one could ask the player's socket to join a match in another game.
141
+ - **`match_id` must be one the parent has seen on this player's own socket.**
142
+ The socket delivers only this player's events, so a match id that arrived there
143
+ is one the player is genuinely party to. Without this the frame could name any
144
+ id and use the player's socket as a probe — and gem-api's refusals would be
145
+ spent out of the _player's_ rate budget, not the game's.
146
+
147
+ That last point is what keeps this SDK's standing invariant true with a
148
+ frame→parent message added: _no frame→parent field is a value the parent trusts
149
+ or acts on beyond routing._
150
+
151
+ ### 2.2 Two limits, because the server's is not ours to spend
152
+
153
+ gem-api rate-limits P2P signaling at **30 messages per second** and caps a client
154
+ WebSocket frame at **16384 bytes**. Neither is a defence this side can lean on:
155
+ exhausting a player's server-side budget is a denial of service against _them_,
156
+ and a frame over the cap is not delivered slowly — it is not delivered, and the
157
+ connection silently fails to form afterwards.
158
+
159
+ So the parent enforces both itself:
160
+
161
+ - **24 signals per second**, in its own one-second window, below the server's own
162
+ number. The bridge's limiter is per _minute_ by construction, so a frame with a
163
+ full bucket can burst a minute's worth inside one second and be entirely within
164
+ its rights.
165
+ - **A 16384-byte cap on the serialized frame**, measured on the finished object
166
+ rather than guessed from the SDP's length. At the server's own figure rather
167
+ than below it — a frame this side refused that the socket would have accepted is
168
+ a connection that fails for no reason — so what it buys is a named refusal the
169
+ game can read in place of a socket that closes under it.
170
+
171
+ ### 2.3 What happens when the bridge is slower than the trickle
172
+
173
+ Candidates arrive in bursts; the parent paces. Dropping the difference silently
174
+ is the worst option available, because a lost candidate does not fail — it
175
+ **degrades**. The connection falls back to relay, or does not form, and nothing
176
+ says why.
177
+
178
+ So candidates are **queued and paced** at ~20/second (under the parent's 24 and
179
+ gem-api's 30), the queue is **bounded at 100** — deeper than that is buffering
180
+ candidates the far side would refuse anyway — and an overflow is
181
+ **reported** as a `candidatesDropped` event carrying the count. Offers, answers,
182
+ joins, acks and reports are never queued: there is at most one of each in flight,
183
+ they are what the connection waits on, and delaying them behind a hundred
184
+ candidates is the slow version of dropping them.
185
+
186
+ This is also why P2P **trickles** while the dedicated flow gathers to completion.
187
+ It is not a stylistic difference, and it is not the frame size — 16 KB holds a
188
+ complete gathered offer on either path. It is that this path sends the offer
189
+ before gathering has finished and keeps finding candidates afterwards: a relay
190
+ candidate once the TURN allocation completes, and an entirely new set after an ICE
191
+ restart. One gathered exchange has nowhere to put those, which is why gem-api's
192
+ P2P routes carry a candidate message and its dedicated route does not.
193
+
194
+ ### 2.4 The inbound relay carries state, unlike every other relay here
195
+
196
+ `rooms-and-matches.md` §3 makes a point of the room relay being a _hint_:
197
+ the frame re-reads the room over its own token, so a compromised parent can make
198
+ it look and not believe. **`gem.signalEvent` cannot work that way** — there is no
199
+ route by which a frame could re-read an SDP offer another player sent. The offer
200
+ has to cross.
201
+
202
+ What makes that acceptable is that nothing downstream believes it on the strength
203
+ of having arrived. **Peer identity is bound to a DTLS certificate fingerprint the
204
+ platform attests to the frame over the frame's own TLS connection to gem-api**
205
+ (§3). A parent that rewrote an SDP would break the connection; it could not
206
+ impersonate anyone. Everything else on that path — a candidate, an ICE ufrag — is
207
+ harmless if forged, because a candidate that does not work simply does not work.
208
+
209
+ One field is dropped at the parent and it is worth naming: **`jwks_url`**.
210
+ gem-api sends it on `p2p_hello` so a host knows where to fetch the keys it
211
+ verifies joiners against. A frame that took its key source from a relayed message
212
+ would let whatever is embedding it choose the keys that decide whether a peer's
213
+ claim to be player X is believed. The frame derives that URL from its own
214
+ configured API root instead.
215
+
216
+ ---
217
+
218
+ ## 3. Peer identity: what a browser can honestly establish
219
+
220
+ On a dedicated match the question does not arise for the client — the server
221
+ verifies a platform-signed credential and the client verifies nothing. Here the
222
+ other end is another player's browser, so **the peers authenticate each other**,
223
+ and it is worth being exact about the two directions because they are not equally
224
+ strong.
225
+
226
+ ### 3.1 Client verifying host — complete, and it needs no cryptography of ours
227
+
228
+ `POST /matches/{id}/join-credential` answers with the current host's DTLS
229
+ certificate fingerprint. That answer arrives over the **frame's own TLS
230
+ connection to gem-api**, so it does not depend on the parent, on the signaling
231
+ path, or on the host being honest.
232
+
233
+ The client compares it against the `a=fingerprint:` line of the SDP answer
234
+ **before applying the answer** — applying one is what starts the DTLS handshake,
235
+ so a check afterwards is a check after the decision. A DTLS handshake only
236
+ succeeds against the certificate the SDP named, so an impostor either fails the
237
+ comparison or fails the handshake. This is the platform's standard pre-DTLS
238
+ check, reproduced exactly.
239
+
240
+ On a mismatch nothing is retried and — the part that matters — **the credential is
241
+ never presented**.
242
+
243
+ ### 3.2 Host verifying client
244
+
245
+ The joiner presents a platform-signed RS256 credential as the first message on
246
+ the reliable channel, and the host verifies it: algorithm, `typ` (`p2p+jwt`, so a
247
+ platform access token cannot be presented as one), issuer, audience, match, game,
248
+ host epoch, expiry with five seconds of skew, `jti` replay, and the two
249
+ fingerprint bindings. A browser can do all of that — WebCrypto verifies
250
+ RSASSA-PKCS1-v1_5/SHA-256 natively.
251
+
252
+ **When the host refuses a joiner, the host says why.** Only the host ran the check, so
253
+ only the host's console can carry the reason: every refusal logs one `[gem]` line at warn
254
+ level naming the joiner and what failed — the verifier's own sentence (`it names a
255
+ different host epoch`, `it has already been used`, …), or that the credential was issued
256
+ to a different player, that the player is on the kicked list, or that the platform's
257
+ signing keys could not be fetched. The joiner is sent a code, and the `P2pError` its
258
+ `connect()` rejects with carries it in `reason`, so a game can branch without matching on
259
+ prose:
260
+
261
+ | The host's reason | The joiner's `reason` | Worth a reconnect? |
262
+ | --- | --- | --- |
263
+ | The credential did not verify | `peer_unauthenticated` | No — the platform issued it, and the host disagrees with the platform |
264
+ | The credential names a different player than the one who signalled | `not_expected_player` | No |
265
+ | The player is on the host's kicked list for this epoch | `kicked` | No |
266
+ | The host could not fetch the platform's keys | `keys_unavailable` | Yes — the host fetches again on the next joiner |
267
+
268
+ A refusal never fails the host's own session: the one link is dropped, the line is logged,
269
+ and hosting continues. `keys_unavailable` is additionally raised as the host session's
270
+ `error` event, and a listen host forwards every session `error` to the same sink, because
271
+ that one is host-wide — nobody can be admitted until the keys arrive.
272
+
273
+ ### 3.3 The certificate is per peer, not per connection
274
+
275
+ gem-api stores **one host fingerprint per match epoch**, taken from the host's
276
+ first SDP answer, and stamps it into every credential it issues. A host that let
277
+ the browser mint a fresh certificate per `RTCPeerConnection` would answer its
278
+ second joiner from a certificate the platform never saw, and that joiner's
279
+ credential would name the first one — a peer failing authentication for a reason
280
+ no log line would explain.
281
+
282
+ So a peer generates **one** certificate (`RTCPeerConnection.generateCertificate`,
283
+ ECDSA P-256) and passes it to every connection it makes.
284
+
285
+ ### 3.4 The admission handshake's version number is deliberately out of range
286
+
287
+ The admission handshake follows an older native wire format byte for byte: join,
288
+ admit, reject. Its `protocol_version` describes that format's **replication**
289
+ wire — snapshot flags, entity transforms — which this SDK does not implement and
290
+ which is explicitly out of scope.
291
+
292
+ The value sent here sits **outside** the engine's ascending range, so an engine
293
+ host refuses cleanly with `VERSION_MISMATCH` rather than admitting a browser peer
294
+ and then waiting for packets that never come.
295
+
296
+ That format is retired, so there are no peers left to mismatch against and this
297
+ is now inert. **Do not "reclaim" the number**: the format is kept as-is
298
+ because it is a working, reviewed handshake with a conservative default, and
299
+ re-pointing `protocol_version` at something in-range buys nothing and reopens a
300
+ settled question.
301
+
302
+ ### 3.5 What this does not do
303
+
304
+ The engine's ICE-restart budgets, oscillation damping, per-peer packet rate
305
+ limiting, DTLS rekey accounting and replication stack are all absent. They are
306
+ netcode tuning around a transport rather than the transport, and a browser game
307
+ gets a connection and three data channels from us — what it puts on them is its
308
+ own.
309
+
310
+ ---
311
+
312
+ ## 4. TURN credentials reach the game, and cannot not
313
+
314
+ The TURN lease route takes a game-scoped token, so the frame calls it directly —
315
+ and it must, because the frame owns the peer connection and the credentials go
316
+ into its ICE configuration. **On web the SDK shares a JavaScript realm with the
317
+ game, so "the SDK holds it" and "the game holds it" are the same statement.**
318
+
319
+ They are short-lived and match-scoped, which is why this is acceptable rather
320
+ than alarming. But it is the second instance of the same fact, and it generalises:
321
+ **anything that must not reach the game must not reach the frame.** That is the
322
+ rule that put the notification socket on the parent, and it is the rule that keeps
323
+ the party surface there too (§7).
324
+
325
+ The lease is taken **before the first offer**, because ICE servers have to be in a
326
+ peer connection's configuration when it is constructed. Leasing afterwards
327
+ produces a connection that works whenever both peers are directly reachable —
328
+ which passes every test on a LAN and fails for a large minority of real players.
329
+
330
+ **Which cluster to lease from is normally nobody's decision to make here.** The
331
+ parent measures the regions and hands the close ones across the bridge, and each
332
+ peer leases from its own best one — a relay near that peer, whichever cluster
333
+ the room is recorded in. See `dedicated-server-flow.md` §5 for how the measuring
334
+ got onto the page and what never comes back off it.
335
+
336
+ **The page's answer does not reach the match start.** When `connect` has to start
337
+ the match, it names no cluster, so the room stays where it was made: naming one
338
+ moves a room, and a host should not drag a room to their own region by
339
+ connecting to it.
340
+
341
+ Passing `clusterTargetId` explicitly still wins, for the lease and the start
342
+ alike — which is a decision to move the room. When nothing resolves one the
343
+ connection proceeds without a relay and says so loudly, because the failure it
344
+ causes is the one that looks like success.
345
+
346
+ **`null` names no cluster, exactly as omitting the option does.** A game picking
347
+ its target out of a list writes `targets[0]?.id ?? null`, so that is the spelling
348
+ ordinary code arrives with — and it defers to the parent's answer the same way an
349
+ absent option does. What it must never do is reach the platform as a lease
350
+ request for a cluster with no id: that costs the caller the no-relay warning and
351
+ reports "the relay was refused" for a player whose actual problem is that nobody
352
+ ever resolved a region for them.
353
+
354
+ ---
355
+
356
+ ## 5. Host migration is where a split match can happen
357
+
358
+ On a dedicated match "the authority went away" means the match ended. Here it
359
+ means the match continues under somebody else, and every peer has to agree who. A
360
+ peer still talking to the old host is not a slow peer — it is a **second match**.
361
+
362
+ ```
363
+ idle ──migration(epoch+1)──▶ preparing (this peer was elected)
364
+ ▲ reconnecting (somebody else was)
365
+ └──── completion broadcast, or admitted by the new host ────┘
366
+ ```
367
+
368
+ Three rules carry the weight:
369
+
370
+ **Tear down first, unconditionally.** An accepted migration closes every existing
371
+ connection before anything is attempted. As a client there is exactly one and it
372
+ points at a host that is gone; as the outgoing host they all point at players who
373
+ are re-signaling elsewhere.
374
+
375
+ **The epoch comparison is asymmetric, and should be.** Strictly greater is the
376
+ migration. Equal is dropped **quietly** — gem-api broadcasts to every member
377
+ including the elected host, so a second copy is an expected protocol artifact
378
+ rather than an error, and reporting it would train a game to ignore a class that
379
+ also contains real problems. Lower is refused **loudly**: it means reordering or
380
+ replay, which nothing else in the flow would surface.
381
+
382
+ **Acknowledging is not being acknowledged.** The elected host sends
383
+ `p2p_migration_ack` and then stays in its active phase until the platform's
384
+ _second_ broadcast at the same epoch says the migration completed. The platform
385
+ holds the match in `migrating` until the ack arrives, refusing credentials to
386
+ everyone meanwhile — so a lost ack is a match nobody can join and nothing is
387
+ trying to fix, and the old host is already gone. A host that settled on its own
388
+ send could not tell that apart from a healthy match. The active phase is bounded
389
+ at 30 seconds and a stall is reported as `migration_failed`.
390
+
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.
395
+
396
+ ### 5.1 What `gem.match` does with a migration
397
+
398
+ A `MatchConnection` to a hosting peer survives a migration as a **pause**: its
399
+ channels re-resolve on `hostChanged`, so the game sees a gap rather than a new
400
+ 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
403
+ new host and the link came back without a re-dial, and `matchEnded` with
404
+ `host_left` or `migration_failed` when it did not. The screens for each are
405
+ [match-session.md](match-session.md) §3 and §4; the listen host's own
406
+ `end()` / `leave()` are §6 there. Two cases are not papered over, because both
407
+ would be the second match above served authoritatively:
408
+
409
+ - **The platform elects this player.** The connection closes with
410
+ `host_migrated_here`. Becoming the authority mid-match means running the
411
+ bundle — `gem.match.serve` — and a client pretending it still has a link to
412
+ somebody else would be a second match. Picking up serving mid-match is not
413
+ something the SDK does for you.
414
+ - **This browser was the listen host, and the migration went elsewhere.** The
415
+ bundle `serve` started is **stopped**; the peers now answer to somebody else,
416
+ and a bundle kept running would be a second authority.
417
+
418
+ **A migration is not the only way back.** When the host is fine and it is this
419
+ peer's own network that moved — airplane mode, a handover from Wi-Fi to cellular
420
+ — nothing is going to elect anybody, and the WebRTC connection underneath will
421
+ never recover: its candidates name an address this device no longer has. The
422
+ reconnect loop's attempt on a peer seat therefore **dials a second signaling
423
+ session at the same host**, exactly as a dedicated seat dials a second link.
424
+
425
+ Two things make that safe, and both are the reason it could not be done from a
426
+ game's own code:
427
+
428
+ - **The stale session is closed, not left.** `leave()` would tell gem-api this
429
+ player is out of the match, and the platform would be reconnecting somebody it
430
+ no longer has a seat for. `close()` tears down the links and the signaling and
431
+ says nothing.
432
+ - **The second offer presents the first one's certificate.** The platform records
433
+ one DTLS fingerprint per player per match epoch, so a browser-minted
434
+ certificate on the second connection is refused a join credential — which is
435
+ why `gem.match.connect()` mints one for the seat and hands it to every dial.
436
+
437
+ The host needs no new code: it answers a returning player as a new session,
438
+ skipping only an offer whose signaling session id it has already answered, and
439
+ gem-api mints a fresh one for the fresh join. A migration already in flight wins
440
+ — that attempt waits, rather than tearing down the session the platform is
441
+ moving.
442
+
443
+ ---
444
+
445
+ ## 6. The connect, as the boot line reports it
446
+
447
+ `gem.boot` (see [`what-a-game-must-do.md`](what-a-game-must-do.md)) carries the
448
+ platform's share of a slow boot as named marks, and a P2P session writes its own
449
+ into the same line: the relay decision, the signaling join, the platform's hello,
450
+ and then the steps of whichever side of the star this peer is on — a joiner's
451
+ offer, credential, fingerprint check, channel and admission, or a host's
452
+ `host listening` followed by the first joiner's arrival.
453
+
454
+ **The names are not the dedicated flow's, deliberately.** That journey allocates
455
+ and is welcomed by a server; this one discovers a peer, gathers candidates and
456
+ connects directly, and the failures are different in kind. A shared vocabulary
457
+ would make the two reports look comparable when the second one's steps do not
458
+ exist on the first path at all.
459
+
460
+ Two properties are worth keeping if this code moves:
461
+
462
+ - **Marked where the step happens**, not where it is awaited. That is what makes
463
+ the order in the line the order of events, and what makes a missing mark
464
+ identify the stage that stopped — the diagnosis a support engineer performs on
465
+ a capture from a player they cannot reach.
466
+ - **A session that never runs records nothing.** The whole argument for the
467
+ instrument is that it is free for games that do not use the feature.
468
+
469
+ The connection that cannot form on a corporate or campus network — no direct
470
+ path, and a relay that was never leased or could not help — presents as a line
471
+ that reaches `host verified` and stops. Making that legible is what these marks
472
+ are for; §4 covers what the relay does and does not fix.
473
+
474
+ ### 6.1 That failure is also reported, because nothing else can see it
475
+
476
+ The boot line is for the one player holding it. The same failure is counted for
477
+ the platform, once per connect attempt, as a `connection_failures_total` event
478
+ on `POST /v1/games/{game_id}/telemetry/p2p` — and **the client is the only party
479
+ that can send it**. gem-api relays signaling and is never on the media path, so
480
+ ICE, DTLS and SCTP failures are invisible to it by construction: a player whose
481
+ network blocks direct connections outright looks, from the platform's side,
482
+ exactly like one who connected and said nothing.
483
+
484
+ **Each report names its transport — `p2p`, `dedicated` or `unknown`.** That
485
+ route is scoped by game rather than by match, so a dedicated-server client
486
+ reaches it with the same play token, and without the label the two transports
487
+ are one number with two opposite remedies behind it. The values are gem-api's
488
+ and are not ours to extend: anything else is resolved to `unknown` rather than
489
+ plotted, on this side and on the server's alike, so a label this SDK invented
490
+ would be counted as nothing and reported to nobody. A sample that names none is
491
+ stamped by the flow reporting it, which is the thing that knows.
492
+
493
+ **What is deliberately not reported: anything the platform decided itself.** A
494
+ migration it ran, a kick it issued, a match it ended, an exchange it refused —
495
+ those are already counted where they happened, and re-reporting them would put
496
+ noise in the one panel that exists to show what nothing else can see. Nor is a
497
+ cancelled connect or a misconfigured game: a player who pressed Back is not a
498
+ player whose network failed. The set of reported reasons is an inclusion list, so
499
+ a failure reason added later is not silently swept in.
500
+
501
+ **Why this is worth the code.** The only fix for players whose network blocks
502
+ UDP outright is TURN relay over TCP/443, which the platform does not offer
503
+ today. This is how that population gets counted, so the attribution matters and
504
+ the volume does not.
505
+
506
+ ---
507
+
508
+ ## 7. Parties: the surface a game gets is one question
509
+
510
+ **The platform gives a game no party surface at all.** No roster, no member list,
511
+ no join code. What the room manager does is narrower and much better: a create,
512
+ quick-join or join-by-code made **without** naming a party asynchronously resolves
513
+ the local player's current party through the launcher backend and — _if and only
514
+ if the local player is the party leader_ — attaches its id, so the server brings
515
+ the whole party into the room atomically. A missing or failed lookup means "no
516
+ party" and never errors.
517
+
518
+ So the party reaches the game exactly one way: **as room members who are already
519
+ there.** The game asks for a room; the party arrives in it.
520
+
521
+ That maps onto web almost too neatly, because the split it assumes already
522
+ exists. The launcher holds the account; here the parent page does.
523
+
524
+ ### 7.1 Two halves
525
+
526
+ **Parent half — the real surface.** The page holds the account token, so it can do
527
+ what the launcher does: read the current party, create one, join by code, invite,
528
+ leave, kick, transfer leadership, and follow the party pushes on the socket it
529
+ already holds. This is embedder-facing API, and it is where a party widget
530
+ belongs — the party spans games and the frame does not.
531
+
532
+ **Frame half — auto-attach, and nothing else.** One message out
533
+ (`gem.partyRequest`), one back (`gem.partyResult`). The answer carries a party id
534
+ **only when the local player leads one**, because that is the only case in which
535
+ the frame has anything to do with it — gem-api requires the caller to be leader,
536
+ so a non-leader that attached the id would be refused a room it could otherwise
537
+ have had.
538
+
539
+ Absence therefore means "no party, or you do not lead it" — **one state**, because
540
+ the frame's behaviour is identical in both. Splitting them would hand a game the
541
+ fact that this player is in somebody else's party, which is a social-graph fact no
542
+ game asked for.
543
+
544
+ Every failure is solo: no party, a parent that does not implement `parties`, a
545
+ request that times out (two seconds, because this sits in front of a room call a
546
+ player is waiting on). A room call broken by a feature the game never asked for is
547
+ the one outcome that cannot be allowed.
548
+
549
+ ### 7.2 Why there is no game-facing roster
550
+
551
+ This is the load-bearing exclusion, so here is the reasoning rather than the rule.
552
+
553
+ A party record is **account-level and cross-game**. It carries which game the party
554
+ is currently in, per-member suggestions of _other_ games to play, each member's
555
+ proposed platform and engine version, the roster with display names, and a join
556
+ code. Hand that to a game frame and the game learns who this player plays with,
557
+ what else those people are playing, what platforms they are on, and a credential
558
+ that lets anyone holding it join the party.
559
+
560
+ That is the same concern the room's own member list raises, with the volume turned
561
+ up: a room's members are people in _this_ room, in _this_ game, right now. A party
562
+ is the player's social graph and their activity across the whole catalogue.
563
+
564
+ ### 7.3 What replaced the party id on a room member
565
+
566
+ The room routes that name a party are on their **second version**, and that is the
567
+ one this SDK calls. A v2 member row carries `group_id`; no party id appears
568
+ anywhere in the response, at any level — not even the requester's own.
569
+
570
+ `RoomMember.groupId` is that value, read off the wire. Two members sharing it
571
+ arrived together; a member with no value arrived alone. It is opaque: draw a badge
572
+ with it, group a roster by it, compare two members' values — but do not parse it,
573
+ key anything off it, or keep it once the room is gone.
574
+
575
+ The stability across rooms is precisely what was removed: the marker is scoped to
576
+ one room, so the same party in two rooms carries two unrelated values. The useful
577
+ half — _who is on a team already_ — is a property of the **room**, and this is the
578
+ room exposing it.
579
+
580
+ **This SDK derived that label itself until gem-api grew the v2 routes**, as `g1`,
581
+ `g2`, … in first-seen order, out of the very field it was hiding. Two things
582
+ changed when the derivation moved to the server, and both are strengthenings:
583
+
584
+ - **The party id no longer enters the frame at all.** The old arrangement read it,
585
+ labelled it and dropped it — inside a JavaScript realm the game shares, which is
586
+ where a strip is worth the least. See `rooms-and-matches.md` §3.
587
+ - **Every client in a room now agrees on the label.** First-seen order is a
588
+ function of when _this_ client started looking, so a player who joined after a
589
+ party left could number the remaining groups differently from one who was there
590
+ the whole time. The marker does not depend on who is asking or when they
591
+ arrived — and it survives a member leaving and rejoining, which the old label
592
+ did not.
593
+
594
+ The field kept its name and its meaning; games see the same guarantee. What
595
+ changed is the _shape_ of the value — no longer a short ordinal, now `g` followed
596
+ by sixteen hex characters. A game that was displaying it raw was already showing
597
+ the player something meaningless, and should map it to its own team names; nothing
598
+ else needs to change.
599
+
600
+ One caveat worth recording because it is invisible from here: the platform can
601
+ relabel the groups in a room that is open at the time, so treat the value as
602
+ stable for a room rather than permanent. Cosmetic, and it cannot be observed as a
603
+ _loss_ of grouping — members of one party still share whatever the new value is.
604
+
605
+ If a game-facing group is the wrong call entirely, the alternative is to drop the
606
+ field; it should not be to pass the platform's id through.
607
+
608
+ ### 7.4 What a party MEMBER's game has to do, which is not what a solo game does
609
+
610
+ Everything above is the leader's side. The half a game gets wrong is the other
611
+ one, because nothing about it is visible from inside the member's frame.
612
+
613
+ **The member is a room member before its game does anything.** The leader's one
614
+ room call seated the whole party server-side. So the member's game boots into a
615
+ world where it is already in a room, having made no room call — and if it does
616
+ what a solo game does, it shows its own start menu and waits for a press.
617
+
618
+ ⚠️ **`seated` arrives in SDK 3.5.0, and an older build fails silently.** The
619
+ listener registers happily and then never fires — no error, no warning — which
620
+ reads exactly like the platform not sending the push, and sends the debugging
621
+ somewhere there is nothing to find. Check what your game pins before you write
622
+ the handler: it is the version of `@indiegems/gem-web-sdk` in `package.json` —
623
+ or, in a project set up before 5.9.0, the version inside its download URL, which
624
+ no dependency check reports as stale. Below 3.5.0, update the pin and reinstall
625
+ first — `getting-started.md` §3 is the install.
626
+
627
+ **Handle `seated`. That is the whole integration.**
628
+
629
+ ```js
630
+ gem.rooms.on('seated', async (room) => {
631
+ if (room.roomType !== 'skirmish') return; // myRooms() also surfaces debris
632
+ await gem.rooms.join(room.id); // idempotent for a member
633
+ enterMatch();
634
+ });
635
+ ```
636
+
637
+ `seated` says this player holds a seat in a room this game never joined. It
638
+ fires on the push gem-api sends when the party is seated, once when the game
639
+ subscribes — which covers a seat that already existed while the game was still
640
+ loading — and again whenever the tab comes back to the foreground. One handler
641
+ covers all three, and a game that writes it has nothing else to do.
642
+
643
+ ⚠️ **A boot-time check alone is not enough, and this is the ordering that proves
644
+ it.** Both players open the game and sit on their own start menus. *Then* the
645
+ leader presses play. A `myRooms()` call at boot has long since run and found
646
+ nothing; the seat appears afterwards, and without `seated` the member stays on
647
+ the menu until they press the button themselves. Reproduced on prod 2026-08-29.
648
+
649
+ `join()` on a room this player is already a member of is not a second join: the
650
+ platform answers with the room. So is `quickJoin()` for the template the party
651
+ is in. Either is safe, and a double fire — the push and the foreground check
652
+ landing together, or the event arriving as the player presses your own button —
653
+ costs one redundant request and never a second seat.
654
+
655
+ The `roomType` filter matters: this player may also hold a room left behind by a
656
+ page that closed without leaving, and that one is debris rather than an
657
+ invitation.
658
+
659
+ **What it costs when a game does not use it: nothing.** The watch behind the
660
+ event — the push, the foreground check and a slow poll for the gap between them
661
+ — is turned on by the subscription and by nothing else, so a game with no
662
+ parties never makes a request for it. `createGem({ watchSeats: false })` turns it off for a game that subscribes and
663
+ would rather ask on its own schedule; `rooms.reconcileSeats()` is then the
664
+ manual check.
665
+
666
+ **Quick-join also works, and is not the same thing.** Since 2026-08-29 the
667
+ platform reads `party_id` off the membership row, so a member quick-joining for
668
+ the template their party is in is handed that room rather than refused — the
669
+ member's game is not broken if it does what a solo game does. What it costs is
670
+ the point of having launched together: the player is sitting on a start menu
671
+ they have to press, while the leader's match counts down. Adopting puts them in
672
+ it. That is the whole difference, and it is a product difference rather than a
673
+ correctness one.
674
+
675
+ ⚠️ **Before that date the same call answered 409 `already_in_matching_room`, with
676
+ no way out.** A build shipped against an older platform — and any game reasoning
677
+ about what a member's quick-join returns — should know the refusal existed: it
678
+ was reported on prod on 2026-08-29 with a member pinned to the menu for a whole
679
+ match. The recovery in `rooms-and-matches.md` §4.3b could not rescue it, for the
680
+ reason the next paragraph gives, which is also why the fix had to be the
681
+ server's: from inside the frame a party seat and a dead page's leftover are the
682
+ same observation.
683
+
684
+ **A member cannot leave, so do not offer them a button that says they can.** Only
685
+ the party **leader** may take the group out of a room (`leave_as_group`); a
686
+ member asking to leave alone is refused too, because a group that arrived
687
+ together leaves together. Concretely, for a non-leader who has adopted the room:
688
+
689
+ | The member's game calls | What comes back |
690
+ | --- | --- |
691
+ | `rooms.leave()` (group leave, the default) | **403** — leader-only |
692
+ | `rooms.leave({ asGroup: false })` | **409** `party_member_cannot_leave_game_room` |
693
+ | `rooms.leaveAll()` | the party room stays; `leaveAll()` is best-effort per room and swallows the refusal, so it returns without that id |
694
+
695
+ So a "leave match" affordance on a member's screen is a button that cannot work.
696
+ Leaving the party is the parent page's surface (§7.1), not the frame's — and it
697
+ is where a member's exit belongs.
698
+
699
+ **And it is why `rooms-and-matches.md` §4.3b's recovery never covered this.**
700
+ That recovery clears a membership and retries once. A party seat is not clearable
701
+ by the player holding it — the table above is the whole reason — so the clear
702
+ frees nothing and the refusal it was recovering from is raised again. The
703
+ platform answering a party member's quick-join with the room takes that path out
704
+ of the ordinary case entirely. It is still reachable in an unusual one: a party
705
+ seated in a room of **one** template while the member's game quick-joins for
706
+ **another**. There the platform's own membership cap refuses, nothing can be
707
+ cleared, and the SDK raises a refusal that names the party cause — because a game
708
+ that just ran the documented recovery has every reason to think the room is gone.
709
+ Adopting is the answer there too: the room to be in is the one the party is in.
710
+
711
+ ---
712
+
713
+ ## 8. Capabilities
714
+
715
+ Two new tokens, both `implemented: true`, both gating in **both** directions:
716
+
717
+ | Capability | Gates | Advertised by a parent that… |
718
+ | ---------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
719
+ | `p2p` | `gem.signal`, `gem.signalEvent` | holds the notification socket **and** enforces the outbound allowlist, the per-second budget and the size cap |
720
+ | `parties` | `gem.partyRequest`, `gem.partyResult` | can resolve the signed-in player's party |
721
+
722
+ `p2p` is the first gated **frame→parent** message type, which makes the parent's
723
+ own receive-side capability check reachable for the first time. That check was
724
+ written for a case that did not exist yet; it exists now.
725
+
726
+ Advertising `p2p` without wiring `onSendSignal` is the one arrangement worse than
727
+ not advertising it — the game believes it has signaling and every offer vanishes —
728
+ so the parent answers that case with a named refusal rather than a silent drop.
729
+
730
+ ---
731
+
732
+ ## 9. Scope
733
+
734
+ **In:** the P2P connection lifecycle end to end — signaling over the bridge, peer
735
+ connections and data channels, trickled ICE, host migration with acknowledgement,
736
+ peer authentication in both directions, leave and kick, the TURN lease, the
737
+ reconnection story, and the telemetry the platform already accepts. Plus the
738
+ parent-side party surface, the party pushes, and the frame's auto-attach. And
739
+ the listen host: `gem.match.serve` running a `defineServer` bundle over this
740
+ session, with two honest gaps — a P2P `kick` carries no reason, and
741
+ `gem.log`/`gem.warn` go to the browser console.
742
+
743
+ **Out:** replication, state sync, prediction, rollback, interest management. A
744
+ browser game gets connections and data channels; what it sends over them is its
745
+ own.
746
+
747
+ **Out:** a game-facing party roster, and the party game-launch and room-proposal
748
+ flows. Those are launcher and Arcade orchestration, not something a running game
749
+ frame participates in.
750
+
751
+ **Out:** voice over these peer connections. The platform has voice sessions, and
752
+ the server side of a match allocates and tears them down — see
753
+ [`server-bundles.md`](server-bundles.md). Those sessions are themselves WebRTC
754
+ and the player's browser connects to one the platform runs. None of that travels
755
+ over this transport, and signaling is not a route to adding it: SDP carrying an
756
+ active audio or video section is refused in both directions.
757
+
758
+ **Out:** mesh topology. gem-api assumes a star throughout — offers are refused
759
+ from the host, answers are addressed by the host, migration elects one
760
+ replacement — and the native client hardcodes a star topology.
761
+
762
+ ## 10. What the BRIDGE bundle gets from this: nothing
763
+
764
+ ⚠️ **This section is about `gem-web-sdk.bridge.js`, and that is not the bundle a
765
+ game loads.** There are two IIFEs:
766
+
767
+ | | Carries |
768
+ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
769
+ | `gem-web-sdk.bridge.js` — **the bridge bundle**, this section's subject | the bridge and the generated protocol, and nothing else |
770
+ | `gem-web-sdk.min.js` — the classic script **older pages still load** | the unified SDK: `createGem`, rooms, the dedicated flow, `defineServer`, `gem.match`, **all of this** |
771
+
772
+ Rooms, the dedicated flow and everything here live in the game bundle and the ESM
773
+ bundle — the bridge bundle carries none of it. It is built for its size budget and
774
+ is not published.
775
+
776
+ So the statement worth being exact about is about **artifacts**, not about how a
777
+ game is delivered: **P2P is in the bundle a game imports —
778
+ `@indiegems/gem-web-sdk/sdk` — and it is not in the bridge bundle.** An earlier
779
+ version of this section phrased the distinction in terms of how a game loaded the
780
+ SDK, which was true only of the bridge bundle and read as a denial to games that
781
+ had `gem.p2p` in full. A game installs the package and imports it
782
+ ([getting-started](getting-started.md#3-add-the-sdk)); the distinction that
783
+ survives is between the two IIFEs, and neither of them is what a game bundles.