@indiegems/gem-web-sdk 7.3.1 → 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 (42) hide show
  1. package/README.md +54 -0
  2. package/dist/game.d.ts +156 -22
  3. package/dist/gem-web-sdk.bridge.js +1 -1
  4. package/dist/gem-web-sdk.esm.js +2 -2
  5. package/dist/gem-web-sdk.game.js +2 -2
  6. package/dist/gem-web-sdk.min.js +2 -2
  7. package/dist/gem-web-sdk.sdk.js +2 -2
  8. package/dist/gem-web-sdk.server.js +2 -2
  9. package/dist/index.d.ts +89 -6
  10. package/dist/sdk.d.ts +156 -22
  11. package/docs/achievements.md +232 -0
  12. package/docs/analytics.md +271 -0
  13. package/docs/api/api-reference-game.md +15308 -0
  14. package/docs/api/api-reference-server.md +4397 -0
  15. package/docs/api/reference.json +24130 -0
  16. package/docs/arcade-economy.md +263 -0
  17. package/docs/async-opponents.md +396 -0
  18. package/docs/brand-art.md +334 -0
  19. package/docs/cross-promotion.md +137 -0
  20. package/docs/daily-challenges.md +530 -0
  21. package/docs/dedicated-server-flow.md +879 -0
  22. package/docs/deprecation-policy.md +124 -0
  23. package/docs/friends.md +233 -0
  24. package/docs/game-economy-tutorial.md +326 -0
  25. package/docs/getting-started.md +552 -0
  26. package/docs/inventory-and-catalogs.md +1035 -0
  27. package/docs/leaderboards.md +277 -0
  28. package/docs/manifest.json +135 -0
  29. package/docs/match-session.md +288 -0
  30. package/docs/p2p-and-parties.md +783 -0
  31. package/docs/platform-rules.md +372 -0
  32. package/docs/player-sign-in.md +89 -0
  33. package/docs/room-shapes.md +195 -0
  34. package/docs/rooms-and-matches.md +779 -0
  35. package/docs/saving-player-progress.md +457 -0
  36. package/docs/server-bundles.md +1041 -0
  37. package/docs/serverless-functions.md +748 -0
  38. package/docs/trading-and-gifting.md +98 -0
  39. package/docs/what-a-game-must-do.md +1231 -0
  40. package/package.json +7 -2
  41. package/schema/bridge.v1.json +440 -7
  42. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,779 @@
1
+ # Rooms and the match lifecycle — the browser half
2
+
3
+ How a browser game gets from "the player pressed Play" to "everyone in this room is on the same
4
+ server". It mirrors the platform's own room manager deliberately and closely: a developer or an agent
5
+ carrying the engine's model should transfer with no friction, and where the web cannot look like the
6
+ engine this document says why.
7
+
8
+ > **Which SHAPE of room you want is decided first, on
9
+ > [`room-shapes.md`](room-shapes.md)** — a match, a hub players drift through, a custom lobby one
10
+ > player owns, or an authoritative worker whose server runs no match at all. This page is the
11
+ > mechanism underneath all four.
12
+
13
+ > **This page is the SDK's model. The platform's own rules are
14
+ > [`platform-rules.md`](platform-rules.md)** — what gem-api enforces underneath, none of which is
15
+ > visible in a type signature. The one to carry into everything below: **a match is sized by who is in
16
+ > the room the instant `startMatch()` is called**, never by the room's capacity. Two of eight seats
17
+ > filled is a two-player match, and nothing tops it up.
18
+
19
+ ## 1. Rooms are a state machine that emits events
20
+
21
+ The engine's signal list **is** the API, and it is reproduced here name for name, in this SDK's
22
+ casing:
23
+
24
+ | Engine signal | This SDK |
25
+ | ------------------------------------------------------------------ | --------------------------------------------------- |
26
+ | `game_room_created` / `_joined` / `_left` / `_closed` / `_updated` | `created` `joined` `left` `closed` `updated` |
27
+ | `game_rooms_listed` / `my_game_rooms_listed` | `listed` `mineListed` |
28
+ | `member_joined` / `member_left` / `kicked_from_game_room` | `memberJoined` `memberLeft` `kicked` |
29
+ | `ready_changed` / `host_changed` | `readyChanged` `hostChanged` |
30
+ | `match_allocation_started` / `match_ready` / `match_failed` | `matchAllocationStarted` `matchReady` `matchFailed` |
31
+ | `version_update_available` | `versionUpdateAvailable` |
32
+ | `error(operation, code, message)` | `error({ operation, code, message, … })` |
33
+
34
+ Three events have no engine counterpart.
35
+
36
+ **`matchStartWaiting`** fires while the SDK is waiting for the platform to have a server to allocate
37
+ at all. It is web-only because the wait is: the engine does not absorb a refused start, and this SDK
38
+ does. §4.4 is the whole of it.
39
+
40
+ **`matchAutoStartFailed`** tells the host the platform tried to start the room's match on its own and
41
+ was refused, with the error code. No match exists, so by default the host's SDK starts it, and the
42
+ event's `handled` says whether it did. §4.4 again.
43
+
44
+ **`seated`** says this player holds a seat in a room this game never joined — the party case, and
45
+ the only thing that reports it. Players who launch together arrive as room members who are already
46
+ there, so a member's game is put in a room by the *leader's* call and has nothing of its own to
47
+ learn from. It is **a fact, not a join**: nothing about the manager changes, `currentRoom` is
48
+ untouched, and what to do about a seat is the game's. It fires on the relayed push, once when a game
49
+ subscribes, and again whenever the tab returns to the foreground — and it costs nothing at all until
50
+ something subscribes, because the watch behind it is what a subscription turns on.
51
+ `p2p-and-parties.md` §7.4 is the flow.
52
+
53
+ Two engine signals are deliberately absent, and each absence is a rule rather than an oversight:
54
+
55
+ - **`game_room_invite_received`** — invites are Arcade surface. The line the platform draws
56
+ around the friend graph is: your game may read **who the player's accepted friends are** —
57
+ display identity only, via `gem.friends` ([`friends.md`](friends.md)) — and nothing else about
58
+ the graph crosses the frame. No presence, no pending requests, no invite events, no mutations.
59
+ An invite is something happening *in* the graph, so it is delivered and rendered where the
60
+ graph lives, on the Arcade. Derived questions also keep being answered where the data lives:
61
+ the friend view in [`leaderboards.md`](leaderboards.md) re-ranks a board server-side, and a
62
+ party crosses into a room as an opaque grouping.
63
+ - **`ping_results_ready`** — the engine's measurement is a UDP echo, which a browser cannot perform.
64
+ A browser ranks regions by timing STUN probes instead, and that happens on the Arcade page rather
65
+ than in the frame: the game is handed the few clusters close enough to be acceptable, best first,
66
+ and never the table of results, because a round trip to every region is coarse geolocation plus a
67
+ network fingerprint. So there is a ranking on web, and no signal carrying it. See
68
+ `dedicated-server-flow.md` §5.
69
+
70
+ Rooms are **not** promises with room-shaped return values. `create()` and `join()` do return the room
71
+ they acted on, because the caller asked and deserves an answer — but every subsequent fact about that
72
+ room arrives as an event, because every subsequent fact originates somewhere else: another member
73
+ readying up, the host leaving, a server coming up in Frankfurt.
74
+
75
+ ## 2. What this kills
76
+
77
+ Before this, `join()` took a `matchId` that nothing in the SDK could produce. Every game had to invent
78
+ a side channel to carry it from the host to players **who were already in a room with them** — which
79
+ is to say, every game had to rebuild the thing the room already is.
80
+
81
+ The engine has no such problem and neither does this now. The host calls `startMatch()`; every member
82
+ receives `matchAllocationStarted` the moment allocation begins and `matchReady` when it is up. Nobody
83
+ passes an id around, and `dedicatedServer.connect()` takes no match id at all.
84
+
85
+ `connect` and `join` have collapsed into one method for the same reason. The host-only/everyone-else
86
+ split existed because `start_match` 403s a non-host — but the room knows who the host is, so
87
+ `connect()` allocates when this player is the host and waits when they are not. That is a fact the
88
+ game should never have had to carry.
89
+
90
+ One fact remains that `dedicatedServer.connect()` and `p2p.connect()` still make the game carry:
91
+ **which of the two flows this room's template chose**. `gem.match` removes that one too — `serve()`
92
+ offers to run your server bundle wherever the template says the authority lives, and `connect()`
93
+ resolves one link shape to it, dedicated or peer-hosted alike
94
+ ([`server-bundles.md` § The same bundle, hosted by a
95
+ player](server-bundles.md#the-same-bundle-hosted-by-a-player)). The two lower flows stay public for
96
+ games that want their specifics; `gem.match` is the surface to reach for first.
97
+
98
+ **The first-call race is gone too.** Every room route is scoped by your game's id, and the id
99
+ arrives with the first play token — moments after the handshake, but not at the instant your game
100
+ starts. A room call made before it landed used to throw `misconfigured: the game id is not known
101
+ yet`, which made a game whose first act was `quickJoin()` a race: green on a fast handshake, a
102
+ refusal on a slow one, and nothing in the error naming the wait that would have fixed it. From SDK
103
+ 3.8.0 a room call made in that gap **waits for the token** — the same bounded wait
104
+ `gem.awaitIdentity()` performs, with the same rejection if no token ever comes. `awaitIdentity()` is
105
+ still how you read the ids yourself; it is no longer a gate you must pass before the first room call.
106
+ On an older SDK, keep awaiting it first.
107
+
108
+ ## 3. Where the socket lives, and what crosses the bridge
109
+
110
+ **The parent page holds the notification WebSocket.** It holds the player token, and
111
+ `POST /v1/ws/ticket` accepts only player tokens — by design, and not something to widen. The frame
112
+ never holds that socket and never holds a credential for it. That is a platform decision, settled and
113
+ not being revisited.
114
+
115
+ So room pushes reach the game the same way signaling is meant to: over the bridge, with the parent
116
+ deciding what crosses.
117
+
118
+ ```
119
+ notification socket ──▶ GemHost.relayRoomEvent(raw) ──▶ gem.roomEvent ──▶ RoomManager
120
+ (the allowlist) (the bridge) (authoritative refresh)
121
+ ```
122
+
123
+ ### The allowlist is one allowlist
124
+
125
+ `GemHost.relayRoomEvent` takes a raw socket frame and either sends a `gem.roomEvent` or returns
126
+ `false`. It drops:
127
+
128
+ - any `type` outside the five allowlisted room events — **an unrecognised type is a drop, not a
129
+ passthrough**, so friend requests, party invites, transfer receipts and purchase completions can
130
+ never reach a game by the parent forgetting to filter them;
131
+ - any event for a different `game_id` than the token this host issued — pinned from the first
132
+ `sendToken`, so relaying cannot begin before the parent has said which game is running;
133
+ - any event whose fields do not survive projection. Fields are **copied by name onto a fresh object**,
134
+ never spread, which is the same rule `sendToken` and `sendPlayer` already follow and for the same
135
+ reason: an API record passed through carries whatever else it happened to hold.
136
+
137
+ **One more room push crosses, as a message of its own.** gem-api tells a room's host when it tried to
138
+ start the match on its own and was refused — `game_room_auto_start_failed`, with the error code — and
139
+ the parent relays it as `gem.autoStartFailedEvent`, the room id and the code and nothing else, to a
140
+ frame that advertises `autoStartFailure`. It is not a sixth `gem.roomEvent` because an older frame
141
+ refuses an enum value it does not know, and reports that to the game as a protocol error; an unknown
142
+ message type it ignores. The game sees it as `matchAutoStartFailed` (§4.4).
143
+
144
+ There is exactly one filtering point and this is it. The engine keeps a full match event in native
145
+ code and hands its scripting layer an allowlisted subset, because native code can hold a secret
146
+ from a sandboxed script. **The frame-side SDK cannot** — it shares a JavaScript realm with the game,
147
+ so anything it holds the game can read. Reproducing the engine's strip _inside the frame_ would be
148
+ theatre. The trust boundary that actually exists on web is parent↔frame, so that is where the strip
149
+ lives, and it is the stronger placement: the parent decides before the bytes cross an origin, and the
150
+ values it never sends cannot be read out of a closure.
151
+
152
+ What the SDK hands the game from a match status response is still projected by an allowlist,
153
+ and that is an **API contract** — the shape a game may depend on — not a confidentiality
154
+ control. Said plainly here so nobody later mistakes it for one.
155
+
156
+ The same reasoning decides where a member's _group_ is worked out, and it used to be decided the
157
+ other way. A room roster once carried the platform's real party id, and this SDK read it, replaced it
158
+ with a room-scoped label and dropped the original — a strip inside the frame, which is the thing the
159
+ paragraph above calls theatre. It was the only option at the time and it was the weak version:
160
+ the id still crossed into the game's realm on its way to being discarded. gem-api now issues the
161
+ room-scoped marker itself and sends no party id at all, so there is nothing here to strip. **A value
162
+ the frame never receives is one a game cannot read out of it** — which is what "no interior" means
163
+ when you take it seriously rather than work around it.
164
+
165
+ ### Which room routes are v2
166
+
167
+ That marker arrived with a second version of the routes that named a party, so this SDK calls both
168
+ versions. The line is not arbitrary: `/v2` is every route that answers with a member roster, plus
169
+ leave, whose v1 _request_ asked the caller to name a party. Everything else mentions a party in
170
+ neither direction and was left where it was.
171
+
172
+ | `/v2` | `/v1` |
173
+ | -------------------------------------------------------------------------------- | ------------------------------------------------------ |
174
+ | create, join, join-by-code, quick-join, get, update, ready, transfer host, leave | list, my rooms, close, kick, start match, match status |
175
+
176
+ The one place that costs something is **kick**. It never mentioned a party, so it was not versioned,
177
+ and its response is therefore a v1 roster with no group markers in it — taking that as the room's new
178
+ state would blank every member's group until the next poll. So the SDK discards what kick answers
179
+ with and re-reads the room on `/v2`: one extra GET on a host action a player performs by hand,
180
+ against a bug a player would see.
181
+
182
+ ### The relay is a hint, not the truth
183
+
184
+ `gem.roomEvent` carries a room id, an optional match id, an optional room version, an optional
185
+ failure reason and an optional platform. It carries **no room state**: no member list, no display
186
+ names, no join code.
187
+
188
+ That is exactly what the engine does — its push handler triggers an authoritative HTTP refresh
189
+ rather than applying the push — and on web it pays twice over. The push crosses an origin boundary and
190
+ would otherwise be a route by which a compromised parent writes the game's model of who is in the
191
+ room. Instead the frame re-reads the room with its own game-scoped token and diffs, so the parent can
192
+ make the frame _look_, and cannot make it _believe_.
193
+
194
+ `version` is the debounce: a hint whose version the frame already holds is dropped without a request.
195
+ That is the engine's ETag/`If-None-Match` behaviour expressed in the one field the push already
196
+ carries.
197
+
198
+ ### One of the five is forwarded rather than re-read
199
+
200
+ `version_stale` is **platform-scoped, not room-scoped**. It says a build line the room requires is
201
+ ahead of one a member is running, and it adds no version and no match id. There is nothing to re-read
202
+ behind it, so receiving one triggers no refresh, no poll retune and no change to any state this SDK
203
+ holds. It is handed to the game as `versionUpdateAvailable` and that is all that happens.
204
+
205
+ **Expect no `platform` on it.** A browser client joins as `web` and gem-api names that platform when
206
+ it raises the push, and the bridge does not carry the value, so the field arrives empty. Nothing is
207
+ lost: a browser game has one build line and this event is about that line whether or not it is named.
208
+ Branch on the event, not on its payload.
209
+
210
+ This mirrors the engine deliberately rather than approximately, because the alternative is a
211
+ divergence a developer would hit rather than read about: the engine answers this event before its
212
+ "is this for my current room" guard and emits a signal without triggering its refresh, so a native
213
+ game observes a forwarded platform and nothing else. A web build should observe the same thing on the
214
+ same push — which is also why the Arcade page does not act on it instead. What to do about a stale
215
+ build (prompt, reload at a safe moment, carry on) is a decision only the game can make, and a
216
+ platform layer that made it would be making it for every game.
217
+
218
+ The platform is **validated at the parent and blanked when the bridge does not carry it**, which is
219
+ again the engine's handling and for the engine's stated reason: the string arrives from a server push,
220
+ so it is treated as untrusted rather than as a value the receiver can rely on. That is also what keeps
221
+ the event alive for `web`, and for whatever gem-api adds next — the frame's validator is generated
222
+ from a frozen schema, so a frame built before a value existed would reject the whole message and the
223
+ game would be told nothing at all.
224
+
225
+ ### Why a browser game needs it at all
226
+
227
+ This event was once deliberately absent here, and the argument for leaving it out was that a browser
228
+ game is updated by the platform serving new bytes rather than by a player running an installer — so
229
+ there is nothing to prompt. What that misses is that a tab which has been open a while is running
230
+ bytes nobody is serving any more.
231
+
232
+ gem-api raises this push by re-checking each member's stored version against the channel, scoped to
233
+ the platform of the join that triggered it. A browser game reports `web`, which is the dimension it
234
+ publishes into, so **every** verdict reaches it: a build you blacklisted, one that was never released
235
+ on this channel, and one that aged out after you promoted a replacement. This push means the version
236
+ this tab is running is no longer one the channel will serve, and the tab has been open since before
237
+ that changed.
238
+
239
+ Reloading is what fixes it, and only the game knows whether the player is between matches or mid-boss.
240
+ Which is the whole argument for forwarding rather than acting.
241
+
242
+ ### What ages a version out, and the three levers the game's owner has
243
+
244
+ The push above is the tail end of a lifecycle the _owner_ of the game drives, from a signed-in
245
+ developer account rather than from the running game. None of it is reachable with the token the
246
+ frame holds, and none of it is something the SDK can do for you — but a game's own error handling is
247
+ written against its results, so it is worth knowing what they are before a player meets one.
248
+
249
+ **Expiry — a new build starts a clock on the old one.** Assigning a version to a channel
250
+ (`PUT /v1/games/{game_id}/packages/{platform}/channels/{channel}`) makes it the head and _dethrones_
251
+ whatever was there. A dethroned version stays playable for that channel's expiry window and then
252
+ fails as `expired`. The window defaults to **24 hours** and is set per game and channel with
253
+ `GET` / `PUT /v1/games/{game_id}/channels/{channel}/version-policy`, body
254
+ `{ "expiry_ttl_secs": <seconds> }` (minimum 1). **It is applied when the check runs rather than
255
+ stamped in at the moment a version was dethroned**, so raising it during an incident immediately
256
+ extends the remaining window of every already-dethroned build, and lowering it immediately shortens
257
+ them. The same window decides how long an older _server_ build keeps serving its line, which is what
258
+ the `expired` reason on `no_compatible_server` means.
259
+
260
+ **Blacklist — the kill switch, and it is blunt on purpose.**
261
+ `POST /v1/games/{game_id}/versions/{version}/blacklist` with a reason (1–500 characters), cleared
262
+ with the matching `DELETE`. Two properties decide how you use it:
263
+
264
+ - **It is channel-agnostic.** A blacklisted version is refused on dev, test _and_ release at once.
265
+ The only narrowing available is by platform, so there is no "block it on release, keep it on dev".
266
+ - **It is not retroactive.** A match that has already allocated keeps running and nobody is kicked.
267
+ What changes is the _next_ check — including a reconnect, so a player already inside a running
268
+ match is bounced the moment their client next polls status, while members on a still-good version
269
+ carry on normally.
270
+
271
+ **Rollback is re-assignment, not an undo.** `PUT` the previous good version back onto the channel:
272
+ that clears its dethroned mark and makes it the head again immediately, however long ago it was
273
+ superseded and whether or not it had already expired. There is no separate un-dethrone. A version
274
+ that is _also_ blacklisted stays blocked even as head — clearing the blacklist is a second,
275
+ independent step.
276
+
277
+ **What the player's client actually gets** is a **409 `bad_version`**, carrying `details.reason`,
278
+ the version that was rejected and the channel it was checked against. It fires at room create, join,
279
+ quick-join, match start, reconnect and party version submission — so a player in a lobby meets it
280
+ before they play and a player mid-match meets it on their next status poll. The four reasons are
281
+ evaluated in a fixed order and mean different things:
282
+
283
+ | `details.reason` | What it means |
284
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `blacklisted` | Somebody killed this version. Checked first, ahead of every case below |
286
+ | `unknown_version` | No such version was ever published for this game — a client reporting a version that does not exist |
287
+ | `not_released` | The version exists but carries no lifecycle row, which normally means a channel assignment landed without one. Re-assigning the same version fixes it |
288
+ | `expired` | It was dethroned longer ago than the channel's window allows |
289
+
290
+ **All four reach a browser build**, and `unknown_version` is the one that used to be easiest to cause:
291
+ it fires when the reported version is not one you uploaded for the `web` platform on the channel being
292
+ played. The arcade now reports the version of the build it routed the player to, so a number typed into
293
+ your source can no longer disagree with the number the build was published under — the one case that
294
+ produced this reason for a game that had done nothing wrong. It still fires for a build deleted out
295
+ from under a live session, and for a game still declaring its own version under an arcade too old to
296
+ send one. The SDK reports the platform itself — see
297
+ [`dedicated-server-flow.md`](dedicated-server-flow.md) §1 — so all four controls are live, where a
298
+ client naming a dimension it publishes nothing in would sail past every one of them.
299
+
300
+ ### Polling is the floor, not the fallback
301
+
302
+ The manager polls. Push makes it poll less — the interval relaxes when the parent has advertised the
303
+ `rooms` capability and pushes are arriving, exactly as the engine drops to 60s when its push
304
+ connection is up. A game running against a parent that never relays anything still works; it is just
305
+ slower. A design where push is the only path has a silent failure mode, and the silent failure mode is
306
+ "the lobby never updates".
307
+
308
+ **The relaxation expires.** It lasts two relaxed intervals past the last push and then the base
309
+ interval is back, so the relaxed one is a ceiling on how long this side goes without looking rather
310
+ than a switch that turns looking off.
311
+
312
+ 🔴 **It used to be permanent.** The interval was chosen when a push arrived and re-armed unchanged
313
+ forever after, so one push relaxed the poll to a minute for the rest of the room's life — and a relay
314
+ that then stopped delivering was never noticed, because nothing was ever going to look sooner. A game
315
+ waiting on a room change whose push was dropped sat through its entire matchmaking window without
316
+ re-reading the room. A push is an optimisation; it is never the thing that licenses not looking.
317
+
318
+ `startPolling(ms)` **pins** the interval. The cadence is then the game's, and no push moves it —
319
+ which used to be untrue in the same way, and in the worse direction: a game that asked for a
320
+ one-second poll was retuned to the relaxed default by the first push and never came back.
321
+
322
+ ## 4. Match connection state, and the four things that are load-bearing
323
+
324
+ ```
325
+ DISCONNECTED ──startMatch/adopt──▶ CONNECTING ──ready──▶ CONNECTED
326
+ ▲ ▲ │
327
+ └────────── failed / left ─────────┴── reconnect ── SUSPENDED ◀── the link dropped
328
+ ```
329
+
330
+ `matchConnectionState` is this coarse view. Underneath it, `matchSession` carries the finer one —
331
+ `none`, `starting`, `ready`, `connected`, `suspended`, `ended`, with the match id and, once it is
332
+ over, the reason — and the events that move it (`matchEnded`, `matchSuspended`,
333
+ `matchReconnecting`, `matchReconnected`). That whole contract, including play-again and what to show
334
+ the player in each phase, is [match-session.md](match-session.md); this section covers the four
335
+ properties of the connection itself.
336
+
337
+ ### 4.1 The late subscriber
338
+
339
+ `matchReady` fires once, and a game that attaches its handler afterwards would wait forever. A
340
+ promise-shaped API hides this; an event-shaped one exposes it, so the semantics are chosen out loud:
341
+
342
+ **`matchReady` is retained and replayed.** A handler attached after the match came up is invoked with
343
+ the retained match info on the next microtask, exactly once. `matchAllocationStarted`, `memberJoined`
344
+ and the rest are **not** replayed — they are transitions, and a transition you missed is not a state
345
+ you are in. The retained value is cleared when the match fails, when the room is left, and when a new
346
+ allocation starts.
347
+
348
+ Two other ways in exist for the same reason, because different games are written differently:
349
+ `rooms.currentMatch` is the pull model (the engine's `get_last_match_event`), and `rooms.awaitMatch()`
350
+ is the promise — it resolves immediately when a match is already ready, and is bounded, because this
351
+ SDK ships no promise that can never settle.
352
+
353
+ ### 4.2 Reconnection is first-class, and by default it is the SDK's
354
+
355
+ A backgrounded tab, a laptop lid, a train tunnel. `SUSPENDED` means "the link dropped and the
356
+ platform has not said the match is over". Since the SDK's 4.0.0 release (2026-09-10) the way out
357
+ is, by default, nothing you write: a connection from `gem.match.connect()` reconnects itself — six
358
+ attempts, the wait doubling from half a second to eight, a read before the first attempt so a link
359
+ that dropped because the match ended produces `matchEnded` and no attempt at all, and nothing while
360
+ the page is hidden. The room raises `matchSuspended`, `matchReconnecting` per attempt and
361
+ `matchReconnected` when the link is back; the connection's `onClose` fires only when the attempts
362
+ run out, or when the platform ended the match.
363
+
364
+ `reconnectToMatch()` is the manual takeover — calling it stops the policy's loop and forces an
365
+ immediate status poll rather than waiting out the interval — and `gem.match.connect()` again on a
366
+ suspended session does the same through the unified surface. `createGem({ autoReconnect: false })`
367
+ turns the policy off, and a connection then closes on every drop. The policy, its knobs and what to
368
+ show the player while it runs are [match-session.md](match-session.md) §4.
369
+
370
+ The SDK does not guess when to suspend from browser events: `visibilitychange` fires when a player
371
+ alt-tabs to read a wiki, and treating that as a disconnect would tear down live matches. What it does
372
+ do is wire the one signal that is unambiguous — the transport closing without the game having
373
+ closed it, which is what moves the session to `SUSPENDED`. A game that dials its own transport says
374
+ the same thing with `notifyMatchDisconnected()`.
375
+
376
+ ### 4.3 Leaving is an explicit notification
377
+
378
+ `notifyLeavingMatch()` posts `POST /v1/games/{game_id}/matches/{match_id}/leave`, best-effort and
379
+ quiet, so the backend broadcasts the departure and the match authority evicts the peer immediately
380
+ instead of waiting out an ICE recovery budget.
381
+
382
+ **gem-api refuses that route for a dedicated match** with `match_not_p2p` — it is a P2P lifecycle
383
+ route, which the engine's own comment says (`p2p_peer_left`). So on web the honest picture is: for a
384
+ P2P match the notification is the mechanism; for a dedicated match the mechanism is the data channel
385
+ closing, which the browser does on unload anyway, and leaving the room is what ends the allocation.
386
+ The call is made in both cases and quiet-fails in one, which is what the engine does too.
387
+
388
+ ### 4.3a Tab close leaves the ROOM, and that is the part that matters
389
+
390
+ `pagehide` fires two keepalive requests: **the room leave first**, then the match departure above.
391
+ Neither is awaited — they are keepalive requests, which the browser has already undertaken to
392
+ deliver, and awaiting inside an unload handler is how the second one never gets issued at all.
393
+
394
+ 🔴 **This used to send the match departure alone.** On this platform that route is a guaranteed 400
395
+ (see 4.3), so the effect was a doomed round trip and a room membership that outlived the page every
396
+ time. Measured on a live release channel, 2026-08-04: the arcade still showed the player as a member,
397
+ and their next session met a quick-join **409** that locked them out of the game entirely — gem-api
398
+ refuses to quick-join a player who is already in a room, so the leftover does not merely get handed
399
+ back. The game that hit it wrote both halves of the recovery by hand.
400
+
401
+ `keepalive` matters because a `fetch` issued during `pagehide` without it is cancelled by the browser
402
+ — an unload notification that is not `keepalive` is a silent no-op wearing a feature's clothes. It is
403
+ an ask rather than a contract, which is why the 409 recovery below still exists as a backstop.
404
+
405
+ Nothing is sent when `pagehide` fires into the **bfcache** (`event.persisted`). The page may well come
406
+ back to the same match, and leaving there evicts a player who did nothing but switch tabs.
407
+
408
+ ### 4.3b A membership that outlived its page
409
+
410
+ `myRooms()` is the only thing that can see one: the leftover is the backend's, so the manager holds no
411
+ room and `leave()` has nothing to act on. Two calls answer it:
412
+
413
+ - **`leaveAll()`** leaves every room this player still holds, best-effort per room — one unleavable
414
+ room must not stop the others, since the refusal stands while any remain. Best-effort **per room**
415
+ is the whole of it: if the listing that finds those rooms fails, the call **rejects**. It does not
416
+ answer with an empty array, because that is also what a player holding nothing gets, and the two
417
+ want opposite handling. See the warning below before wiring it to a button.
418
+ - **`quickJoin()` recovers on its own, by default.** A 409 clears the memberships and retries
419
+ **once**; any other refusal is not a membership problem, and a 409 that survives the clear means
420
+ something the manager does not model, so it is raised rather than looped on.
421
+ `createGem({ recoverStaleMembership: false })` turns this off for a game whose players are in
422
+ rooms on purpose — and the refusal that game then meets says so, naming `myRooms()` and
423
+ `leaveAll()` beside gem-api's own sentence, because "already in a game room" reads as nonsense to
424
+ a caller whose manager is in no room at all.
425
+
426
+ **No game has to ask for this.** The recovery is the default and `leaveAll()` is the manual tool
427
+ beside it, not a call a correct integration is obliged to make.
428
+
429
+ **A party seat is not debris, and quick-join no longer treats it as such.** A party reaches a game
430
+ as room members who are already there — the leader's single room call seats every member
431
+ (`p2p-and-parties.md` §7) — so a member's game boots already in a room it never asked for. Its own
432
+ `quickJoin()` for that template is neither refused nor answered with a second room: the platform
433
+ reads `party_id` off the membership row and hands back the room the leader put them in. Debris
434
+ keeps its 409. **Only the server can draw that line** — from inside the frame a party seat and a
435
+ dead page's leftover are the same observation — which is why this is not something the recovery
436
+ above was ever going to get right, and why it was fixed in the platform rather than here.
437
+
438
+ ⚠️ **So quick-join has three outcomes, not two: a room created, a room joined, and a room you were
439
+ already a member of.** A game that assumes a fresh or newly-joined room — clearing per-match state
440
+ on the strength of the call returning, say — is surprised by the third. `Room` reports `joined` for
441
+ all three, and cannot do better: gem-api answers 200 and 201 through one route, so the client sees
442
+ one body. Reporting `created` for half of them would be wrong half the time.
443
+
444
+ A member's game is better off not asking at all. **`seated`** reports the seat as soon as it exists
445
+ — on the relayed push, when the game subscribes, and whenever the tab returns to the foreground —
446
+ so the handler can put the player straight into the leader's match with no start menu and no press,
447
+ which quick-join can only do after the player has pressed something. §1 introduces the event and
448
+ `p2p-and-parties.md` §7.4 is the flow, including the one thing a member still cannot do: leave.
449
+ `leaveAll()` cannot clear a party seat, because only the leader may take a party out of a room.
450
+
451
+ 🔴 **This was once got wrong in a test fixture, which is how it was found.** gem-api answers 409
452
+ `already_in_matching_room` when a player already holds a joinable room matching the template, and it
453
+ answers it _before_ it looks for a candidate — but the fixture had no such guard, so its quick-join found
454
+ the player's own leftover in the ordinary sweep, joined a room they were already in and answered
455
+ **200**. Locally, that is a player silently returned to the dead room they never left, the recovery
456
+ path never running against the fiction it was written for. The guard is modelled now, cluster and
457
+ all: gem-api compares the template and the channel only, so a leftover in a cluster this quick-join
458
+ did not list blocks a quick-join the candidate sweep would never have matched it to.
459
+
460
+ ### 4.4 There may be no server to start on, and `startMatch` waits
461
+
462
+ **A start can be refused because no server is free.** The platform allocates a server when a match
463
+ starts, and a start that arrives when none is available is answered **503**. That is a normal
464
+ condition to design for, and the SDK absorbs it rather than handing it to the game.
465
+
466
+ So `startMatch()` retries a 503 rather than handing it to the game, and the wait is
467
+ `matchStartWaiting`:
468
+
469
+ ```ts
470
+ rooms.on('matchStartWaiting', ({ attempt, elapsedMs, budgetMs, nextAttemptInMs }) => {
471
+ ui.showFindingServer({ elapsedMs, budgetMs }); // "Finding a server… 12s"
472
+ });
473
+ await rooms.startMatch();
474
+ ```
475
+
476
+ It fires once per refusal, **before** `matchAllocationStarted` — which cannot fire yet, since there
477
+ is no match id — and only for the host, since only the host allocates. A game that renders nothing
478
+ here has a Start button that stalls for up to half a minute, which is a worse experience than the
479
+ refusal it replaced.
480
+
481
+ **The budget is 30 seconds, and the player is what sets it.** This is the one number here that is
482
+ _not_ derived from the platform: a player who has pressed Start is watching a spinner, and thirty
483
+ seconds is the ceiling on what they will tolerate.
484
+
485
+ > 🔴 **It will give up on some starts that a longer budget would have caught.** That cost is
486
+ > accepted deliberately, in exchange for not stalling the player. A game whose own players will wait
487
+ > longer sets `retry: { budgetMs }`; the default is not going to grow to meet the platform.
488
+
489
+ **The other two numbers are measured against the platform.** The gap between attempts is exponential
490
+ from 1s and **capped at 20s**, because gem-api meters match starts at **ten per player per minute**
491
+ and fails closed: a schedule that outran the limiter would manufacture the 429 it then had to handle.
492
+ The cap holds the steady state to three requests a minute at any budget.
493
+
494
+ The gap is **jittered** — half the nominal, plus a random share of the other half. The failure being
495
+ absorbed is several clients starting matches on one fleet at the same instant, and an unjittered
496
+ backoff re-synchronises exactly those callers into a second collision.
497
+
498
+ `retry: { budgetMs }` sets the budget per call, on `startMatch()` and on `dedicatedServer.connect()`;
499
+ `budgetMs: 0` stands the retry down and hands back gem-api's own error, for a game that would rather
500
+ run its own loop. A budget that runs out raises `no_server_ready` — saying how long it waited and
501
+ what the platform said last, and carrying that refusal as its `cause` — which is a different thing
502
+ from any 4xx gem-api answers, and should be presented differently to a player.
503
+
504
+ **Retrying is safe because of what the route guarantees, not because 503 sounds transient.** Starting
505
+ a match is idempotent for a room — a repeat gets the match that is already running, never a second
506
+ one — and a 503 is answered before any match is recorded at all. Waiting cannot leave a room with two
507
+ matches.
508
+
509
+ Two refusals are deliberately **not** retried, because waiting changes nothing: any 4xx about the
510
+ request itself, and the 429 `too_many_concurrent_matches`, which gem-api's own documentation says
511
+ clears only when one of this player's matches ends.
512
+
513
+ **A cluster that keeps having no server is left, once.** A start runs in the room's own cluster, and
514
+ two `capacity_unavailable` refusals in a row move it to the next cluster this player can play well
515
+ in, where it keeps waiting inside the same budget. A start in a cluster the platform does not run —
516
+ `invalid_cluster_target` — is not waited on either: the SDK starts once in this player's best other
517
+ cluster. Both name a cluster, so both move the room there for its later matches, and neither runs on
518
+ a `clusterTargetId` you passed yourself. `dedicated-server-flow.md` §5 has the whole rule.
519
+
520
+ **A match the room was meant to start on its own can be refused too, and then the host's SDK starts
521
+ it.** When a template starts the match as the room fills to `min_players`, nobody pressed Start and
522
+ nobody is waiting on a call — so the refusal arrives as `matchAutoStartFailed`, to the host only, with
523
+ the error code a manual start would have had. No match exists and the room stays open. By default
524
+ the host's SDK then starts the match itself, running the recoveries above, and the event carries
525
+ `handled: true`. What follows is what follows any start: `matchStartWaiting` while no server is free,
526
+ `matchAllocationStarted` when one is, `error` if the start fails.
527
+
528
+ It does that for `invalid_cluster_target` and `capacity_unavailable` only — the refusals a start from
529
+ the host can get past. The others are about the build (a version no server accepts, an engine line
530
+ no fleet serves, a server size not configured), and the same start would meet them again.
531
+
532
+ **Once per room until a match begins in it, and never beside another start.** The start the SDK makes
533
+ already carries every recovery it has, so a second one would be the same attempt against the same
534
+ fleet. A later refusal in the same room comes to the game with `handled: false`; the platform itself
535
+ tries the automatic start again whenever another player joins. A start already in flight — the host
536
+ pressed Start, or `gem.match.connect()` is starting one — is left to finish, and the event says
537
+ `handled: true`.
538
+
539
+ A game that wants to decide passes `createGem({ retryAutoStart: false })`. The event then always says
540
+ `handled: false`, nothing is started for you, and starting it yourself runs the same recoveries:
541
+
542
+ ```ts
543
+ rooms.on('matchAutoStartFailed', () => rooms.startMatch());
544
+ ```
545
+
546
+ **The SDK's 5.11.0 and 5.12.0 releases (2026-09-24) raised this event and started nothing.** A game
547
+ written for them whose handler calls `startMatch()` straight away, as above, still works: the SDK
548
+ sees that start and makes none of its own. A game whose handler holds the start back — for the host
549
+ to press Start, or to decide by the code — needs `retryAutoStart: false`, or the SDK starts the match
550
+ before the host has chosen.
551
+
552
+ The event reaches a game only when the Arcade page relays it. Where it does not, a game sees nothing,
553
+ and the room waits for a start that is not coming.
554
+
555
+ #### A peer-to-peer room never waits, because nothing is being allocated
556
+
557
+ gem-api answers **503** for two unrelated things: a fleet with no server to give, and a backend with
558
+ no P2P configured. A P2P room that waited the second one out would spend the whole budget on a
559
+ server that was never going to be allocated, because a P2P match allocates none.
560
+
561
+ **The refusal says which it is.** The 503 carries an error code: `capacity_unavailable` when a fleet
562
+ has nothing ready, and `p2p_unavailable` when the backend has no P2P. On `p2p_unavailable` the
563
+ refusal is raised immediately as **`p2p_unavailable`**, carrying gem-api's sentence and its error as
564
+ the `cause`. Nothing waits and nothing is rendered, because there is nothing to wait for.
565
+
566
+ The two refusals were once byte-identical, and the SDK settled it by fetching the room's template
567
+ and reading its `match_type` — a request on every refused start, plus the fail-open paths that made
568
+ depending on it safe. gem-api separated the codes, so that lookup is gone and a refused start now
569
+ costs nothing beyond the refusal.
570
+
571
+ Two things about the shape of that, each load-bearing:
572
+
573
+ - **Every other 503 keeps its wait.** The route documents `capacity_unavailable`,
574
+ `match_start_fence_expired`, `match_state_race`, `p2p_unavailable` and `service_unavailable`, and
575
+ reserves the right to add more; anything sitting between the game and the platform can answer with
576
+ no error body at all. So one code exactly short-circuits and everything else — including a
577
+ refusal carrying no code — waits as it always did. The cost of a wrong "keep waiting" is the wait
578
+ a P2P game had before any of this existed; the cost of a wrong "p2p" is a dedicated game that can
579
+ no longer wait for a server at all.
580
+ - **`p2p_unavailable` is not retryable**, because the usual cause is a platform configuration and no
581
+ amount of asking changes one. Two rare fence races on the P2P branch answer 503 as well — they
582
+ carry their own codes, so they keep the retry they always had.
583
+
584
+ A `match_type` of `none` needs nothing here: gem-api refuses `start_match` for it with **400**
585
+ `match_type_not_supported`, which was never retried.
586
+
587
+ ### 4.5 The refusals from the dedicated-server flow are preserved
588
+
589
+ Both findings that flow reported still hold and are still enforced:
590
+
591
+ - **A match whose protocol is not `webrtc_dedicated` is refused, loudly.** `host` and `port` are
592
+ populated and advisory; dialling them would also make gem-api's lenient protocol read invisible from
593
+ both ends. See `dedicated-server-flow.md` §4.
594
+ - **Signaling is a seam**, and where nothing is wired to carry an offer it throws naming what is
595
+ missing rather than hanging. Unchanged by this work: the reason it is a seam at all is the same
596
+ `POST /v1/ws/ticket` fact that puts the notification socket on the parent, so the shipped
597
+ implementation relays through the parent as P2P signaling does.
598
+
599
+ ### 4.6 Starting a match is bounded per player, and the bound refuses fail-closed
600
+
601
+ **A player may only have so many matches running at once**, and a start beyond that is a **429
602
+ `too_many_concurrent_matches`** — deliberately without naming the ceiling. The limiter in front of it
603
+ fails **closed**, so a start it cannot meter is refused rather than allowed. Neither is a queue: this
604
+ is a refusal to handle, not a wait to retry into, and the way out is for one of this player's own
605
+ matches to end.
606
+
607
+ ## 5. Player-authored text
608
+
609
+ `GameRoomMemberResponse` carries `display_name`, `discriminator` and `display_tag` for every member,
610
+ and every one of them is **text another player typed**. Nothing scrubs them the way the platform
611
+ scrubs the identity fields on `gem.player`, so treat them the way you would treat any untrusted
612
+ input: escape them where they land, and never put them into a page as markup.
613
+
614
+ What this SDK does about it, so it is hard to get wrong rather than merely documented: a member's
615
+ `displayName` and `displayTag` arrive as `PlayerText`, not `string`, so
616
+ `el.innerHTML = member.displayName` does not compile. `displayTag` is
617
+ platform-composed but embeds the display name, so it is wrapped for the same reason `gem.player`'s
618
+ is. The discriminator alone stays a plain string. A member also carries a portrait
619
+ (`avatarIconUrl`) and `badges` — the identity fields every surface that shows a player carries;
620
+ [Showing a player](what-a-game-must-do.md#showing-a-player-one-identity-on-every-surface) is how to render them. A member with an empty `playerId` is one whose identity the
621
+ platform withholds from you; the seat still counts.
622
+
623
+ Key member rows on `playerId`. Names are not identifiers.
624
+
625
+ ## 5a. Room args: how a room is configured
626
+
627
+ A room's configuration is its **room args** — a flat object of typed values: strings, numbers,
628
+ booleans, and homogeneous arrays of those. The shape is not invented per room. Your game's published
629
+ config declares a schema on the room's template naming every entry, its type, its allowed values, its
630
+ default, whether you fixed the value and whether it may change once the room exists; the room takes a
631
+ **frozen copy of that schema** when it is created, so promoting a new config never moves the ground
632
+ under a room that is already open.
633
+
634
+ **The schema decides who may set what.** Whoever creates the room supplies the entries you left
635
+ choosable, in the create request. Every room you read carries the resolved values and the room's own
636
+ frozen schema on it — `room.roomArgs` and `room.roomArgsTemplate` — so a settings screen is rendered
637
+ from what this room actually allows rather than from a copy of the config your build shipped with, and
638
+ costs no request of its own. The host changes them with `rooms.update({ roomArgs })`.
639
+
640
+ ```ts
641
+ // Configured at creation. A `required` entry has no fallback, so this is the
642
+ // only moment it can be given one — and so is an entry your config marked
643
+ // `mutable: false`, which is choosable here and locked for the room's life.
644
+ const room = await gem.rooms.create({
645
+ name: 'Match 1',
646
+ templateSlug: 'skirmish',
647
+ roomArgs: { map: 'dust', rounds: 5 },
648
+ });
649
+
650
+ // A settings screen, drawn from the room's own schema rather than from yours.
651
+ for (const entry of room.roomArgsTemplate ?? []) {
652
+ // `fixed` is the studio's and nobody may write it; `mutable: false` was
653
+ // settable at creation and is locked now. The rest are the host's to change
654
+ // while the room is open.
655
+ drawControl(entry, room.roomArgs?.[entry.name]);
656
+ }
657
+
658
+ await gem.rooms.update({ roomArgs: { ...room.roomArgs, rounds: 3 } });
659
+ ```
660
+
661
+ **A write is a replacement, not a patch**, and the spread above is not decoration. The object you send
662
+ is your complete intent for every entry you may choose: an optional one you leave out reverts to its
663
+ default or to absence, and a required one has to be sent again. Leaving `roomArgs` out of an update
664
+ altogether is the different thing — it changes nothing, so renaming a room does not quietly reset its
665
+ configuration. Host only, and refused once a match is running (**409 `room_not_open`**) — the server
666
+ was handed its snapshot at match start, and a later edit would be invisible to it.
667
+
668
+ **Absent is not empty.** A room whose template declares no schema carries neither field, which is how
669
+ you know there is no settings screen to draw; `roomArgs: {}` is a room that has a schema and is sitting
670
+ at its defaults. Both fields are withheld from a player who is not a member, so a public room in a
671
+ browser listing says nothing about how it was configured.
672
+
673
+ **Every refusal names the entry it is about.** `unknown_room_arg` (a key your template does not
674
+ declare), `room_arg_fixed` (a key the studio fixed), `missing_required_room_arg`, `invalid_room_arg` (a
675
+ value outside the entry's type, bounds or allowed set) and, on a write, `room_arg_immutable` (a
676
+ set-once entry handed a new value). All 400, and all of them refuse the whole call — nothing is
677
+ partially applied and nothing is quietly dropped.
678
+
679
+ ⚠️ **`quickJoin` cannot configure a room**, and that is where a required entry bites. Its request
680
+ carries no configuration in either direction — it may join a room somebody else already configured, and
681
+ a room it creates takes your fixed values and defaults alone. So a template with a **required** entry
682
+ cannot be quick-joined into existence: the call is refused, **400 `missing_required_room_arg`**,
683
+ whether or not there was a room to join. Give every entry a default if your game quick-joins; make one
684
+ required only if your rooms are created with `rooms.create()`.
685
+
686
+ **Every member can read every value, including the ones you fixed.** Nothing whose safety depends on
687
+ a player not knowing it belongs here; that is what a server holding authority is for.
688
+
689
+ Both halves of a game read one published shape, **off the room itself, differing only in the token
690
+ presented**. In the browser that is `room.roomArgs` under the player's own token — a **P2P
691
+ listen-host** included, since it has no server to be handed anything. A dedicated bundle presents a
692
+ game-server token instead, and the SDK's server entry pre-binds the room read to the bundle's own room
693
+ as `server.room.get()` ([`server-bundles.md` § What your server reads at
694
+ startup](server-bundles.md#what-your-server-reads-at-startup)). The push that bundle used to rely on,
695
+ `host.roomArgs`, is deprecated but still delivered — [`server-bundles.md` §
696
+ Room args](server-bundles.md#room-args). The dedicated-server runtime image is what delivers it,
697
+ so how long it keeps arriving is that image's to say rather than this package's — [the
698
+ deprecation policy](deprecation-policy.md#3-runtime-delivered-pushes-are-a-separate-question).
699
+
700
+ The platform used to answer both halves from a `/room-args` sub-resource beside the room. That pair is
701
+ deprecated in favour of the room's own read, which is why there is no `getRoomArgs` on the browser
702
+ surface to call.
703
+
704
+ ⚠️ **`gameArgs` and `templateArgs` are gone.** The two opaque `key=value&key=value` strings a room and
705
+ its template used to carry were removed from the platform, and a create or update still sending one is
706
+ refused — **400 `game_args_removed`**, naming room args as the replacement. They are now gone from
707
+ this SDK too: `Room.gameArgs`, `Room.templateArgs`, `CreateRoomOptions.gameArgs` and
708
+ `UpdateRoomOptions.gameArgs` no longer exist, and the SDK no longer sends `game_args` on a create or
709
+ an update.
710
+
711
+ If you are upgrading, a `gameArgs` you were still passing was **breaking the call**, not merely
712
+ being ignored — every create carrying one was refused outright. TypeScript callers now get a compile
713
+ error naming the property, which is the loudest form this can take; a JavaScript caller's value is
714
+ simply dropped before the request, which turns a refused create back into a working one.
715
+
716
+ `settings` is a different thing and still works: free-form game-authored JSON on the room, capped at
717
+ 64 KB, which the platform stores and never inspects. Room args are the part it validates and the part
718
+ a server receives. Neither is on the server browser's wire — `RoomSummary` is gem-api's narrower
719
+ projection and omits `settings` along with `members` and `joinCode`. Room args ride a room's **own**
720
+ read — this SDK reads them off the v2 route it already calls — and only for members and game
721
+ servers; a non-member's read does not carry them.
722
+
723
+ ## 5b. The template decides the seats, and a create that asks for its own is refused
724
+
725
+ ⚠️ **A create cannot set a seat count.** The room's size comes from the template it names, and a
726
+ request carrying one is refused — `400 max_players_not_settable`, with the value echoed back. A room
727
+ created without naming a template seats 8. `quickJoin` never offered the option: its request has no
728
+ seat field, and a body that invents one is refused before it looks for a room to join.
729
+
730
+ **`CreateRoomOptions.maxPlayers` is deprecated and is not sent.** Setting it logs a warning naming the
731
+ replacement, and the field is stripped before the request leaves — so a call site that still sets it
732
+ creates a room of the template's size rather than failing outright. Delete it when you see the warning.
733
+ It stays on the type deliberately: removing it would stop a TypeScript caller compiling, but it would
734
+ leave a JavaScript one passing a field that vanishes without a word, which is the failure this whole
735
+ rule exists to remove. It keeps working for the rest of `3.x`, and `4.0.0` is the earliest release
736
+ that may take it off the type — [the deprecation
737
+ policy](deprecation-policy.md#2-the-rule-for-the-packages-api-surface).
738
+
739
+ **To size a room, resize it after it exists** — `update({ maxPlayers })`, host-only, refused below the
740
+ room's current member count, and bounded 2–64 whatever the template allows. The seat count you want
741
+ every room to have belongs in the template instead, where it costs no extra round trip and no window
742
+ in which the room is briefly the wrong size.
743
+
744
+ **The SDK does not create-then-patch for you**, and the omission is the decision. Folding it into
745
+ `create()` would put a workaround for another service's shape inside this one, where nobody looking
746
+ for it would find it, and the room would still be briefly the wrong size — a visible hazard replaced
747
+ by a hidden one. The platform refuses the field outright, for this reason: a
748
+ developer meets the rule locally rather than as a 400 in production.
749
+
750
+ ## 6. Scope
751
+
752
+ **In:** rooms, membership, host, ready state, the match lifecycle, and the dedicated-server connect
753
+ flow reshaped around them. P2P _allocation_ falls out of the same shape — `startMatch` is one route
754
+ and a P2P match simply reports `ready` at once.
755
+
756
+ **Out:** matchmaking beyond the engine's find-or-create. `quickJoin()` is exactly `quick_join`: join
757
+ an open public room matching the template, in a cluster this player can play well in, or create one.
758
+ There is no ranked
759
+ system, no skill rating and no queue, and inventing one is not a small addition to this.
760
+
761
+ **Elsewhere:** P2P _connection_ and parties. Both are
762
+ [`p2p-and-parties.md`](p2p-and-parties.md). Three things surface here:
763
+
764
+ - **A member's group, not their party.** `RoomMember.groupId` says who arrived together, as an opaque
765
+ label that is stable for the life of this room and unrelated to the label the same people carry in
766
+ any other room. It is gem-api's, read off the wire; it replaced a pass-through of the real
767
+ `party_id`, which is account-level and cross-game and therefore a social-graph key rather than a
768
+ room fact. See §7.3 of that document.
769
+ - **`partyId` is normally omitted on create, join and quick-join.** The manager resolves the local
770
+ player's party through the parent and attaches it when this player leads one, which is what
771
+ the platform's room manager does. Passing one explicitly still works and overrides that. This is the
772
+ one direction a party id still travels, and it is the caller naming a party to act on rather than
773
+ the platform exporting one.
774
+ - **`leave()` takes the group out by default.** The room call that brought the party in has a
775
+ counterpart, and gem-api will not let a member of a party that is in the room leave alone — it
776
+ answers 409 `party_member_cannot_leave_game_room`. So `leave()` states the intent
777
+ (`leave_as_group`) and the server resolves which party that was from the membership row; a player
778
+ who arrived alone is unaffected either way. `leave({ asGroup: false })` asks for exactly the solo
779
+ path, and therefore for that 409 when a party is in the room.