@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.
- package/README.md +54 -0
- package/dist/gem-web-sdk.esm.js +1 -1
- package/dist/gem-web-sdk.game.js +1 -1
- package/dist/gem-web-sdk.min.js +1 -1
- package/dist/gem-web-sdk.sdk.js +1 -1
- package/dist/gem-web-sdk.server.js +1 -1
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +530 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +5 -5
- 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.
|