@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.
- package/README.md +54 -0
- package/dist/game.d.ts +156 -22
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +2 -2
- package/dist/gem-web-sdk.game.js +2 -2
- package/dist/gem-web-sdk.min.js +2 -2
- package/dist/gem-web-sdk.sdk.js +2 -2
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/index.d.ts +89 -6
- package/dist/sdk.d.ts +156 -22
- 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 +440 -7
- 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.
|