@indiegems/gem-web-sdk 7.4.0 → 7.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +54 -0
  2. package/dist/gem-web-sdk.esm.js +1 -1
  3. package/dist/gem-web-sdk.game.js +1 -1
  4. package/dist/gem-web-sdk.min.js +1 -1
  5. package/dist/gem-web-sdk.sdk.js +1 -1
  6. package/dist/gem-web-sdk.server.js +1 -1
  7. package/docs/achievements.md +232 -0
  8. package/docs/analytics.md +271 -0
  9. package/docs/api/api-reference-game.md +15308 -0
  10. package/docs/api/api-reference-server.md +4397 -0
  11. package/docs/api/reference.json +24130 -0
  12. package/docs/arcade-economy.md +263 -0
  13. package/docs/async-opponents.md +396 -0
  14. package/docs/brand-art.md +334 -0
  15. package/docs/cross-promotion.md +137 -0
  16. package/docs/daily-challenges.md +583 -0
  17. package/docs/dedicated-server-flow.md +879 -0
  18. package/docs/deprecation-policy.md +124 -0
  19. package/docs/friends.md +233 -0
  20. package/docs/game-economy-tutorial.md +326 -0
  21. package/docs/getting-started.md +552 -0
  22. package/docs/inventory-and-catalogs.md +1035 -0
  23. package/docs/leaderboards.md +277 -0
  24. package/docs/manifest.json +135 -0
  25. package/docs/match-session.md +288 -0
  26. package/docs/p2p-and-parties.md +783 -0
  27. package/docs/platform-rules.md +372 -0
  28. package/docs/player-sign-in.md +89 -0
  29. package/docs/room-shapes.md +195 -0
  30. package/docs/rooms-and-matches.md +779 -0
  31. package/docs/saving-player-progress.md +457 -0
  32. package/docs/server-bundles.md +1041 -0
  33. package/docs/serverless-functions.md +748 -0
  34. package/docs/trading-and-gifting.md +98 -0
  35. package/docs/what-a-game-must-do.md +1231 -0
  36. package/package.json +7 -2
  37. package/schema/bridge.v1.json +5 -5
  38. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,1041 @@
1
+ # Writing the server for a dedicated match
2
+
3
+ What a game publishes as its dedicated server, and how a browser talks to it once the SDK has opened
4
+ the channel. Everything here is game-facing: the lifecycle a server bundle implements, the surface it
5
+ is handed, and the first message on the wire.
6
+
7
+ The browser half — allocating the match, negotiating WebRTC, getting a channel — is
8
+ [`dedicated-server-flow.md`](dedicated-server-flow.md), and the SDK does all of it for you. This
9
+ document starts where that one ends.
10
+
11
+ > **Where this text comes from.** The dedicated-server runtime is the platform's own, and it is not
12
+ > something you can read — so the game-facing half of its contract is **copied** here rather than
13
+ > linked, taken from that runtime's documentation and source on 2026-08-13. That sync covers the
14
+ > `roomArgs` settings object (which replaced the removed `gameArgs`/`templateArgs` string channels),
15
+ > game-server token delivery (`apiBaseUrl`/`serverToken`/`onServerToken`), the move from pushed room
16
+ > data to [reads your server makes itself](#what-your-server-reads-at-startup), and the lifespan
17
+ > contract — how long a match may run, and how long a server gets to wind down.
18
+ >
19
+ > Two copies can drift, and no automated check guards this one. **If this text and the runtime
20
+ > disagree, the runtime is right** — report it, and this page is what gets corrected.
21
+
22
+ ## A bundle is required
23
+
24
+ A game that has published no server package does not get a dedicated server. gem-api refuses the
25
+ match at start — **409, `no_compatible_server`**, which the SDK raises as a `GemApiError` with
26
+ `reason: 'conflict'` — upstream of any allocation, so no pod is ever started to fail later.
27
+
28
+ This is the contract rather than a limitation. There is no default server behaviour to fall back on
29
+ and no mode of the runtime that runs without game code: "connect players to each other and let them
30
+ sort it out" is what [P2P](p2p-and-parties.md) is for, and it has different properties on purpose.
31
+
32
+ A server package is a package like any other: upload a version under the `server` platform, then
33
+ assign it to the channel the match will run on
34
+ (`PUT /v1/games/{game_id}/packages/server/channels/{channel}`) — the same path and the same developer
35
+ role as a client build. The runtime resolves whatever that channel points at, downloads it, and
36
+ verifies it against the digest the download endpoint returns before running a byte of it.
37
+
38
+ ## What you publish
39
+
40
+ **One JavaScript file.** Not an archive, not a directory, not a `package.json` — the runtime writes
41
+ the download to a single path and imports it directly. Anything your server needs must be bundled
42
+ into that file.
43
+
44
+ | | |
45
+ | ------------- | ---------------------------------------------------------------------------------------------------------- |
46
+ | Module format | From the published file's extension: `.mjs` is ESM, `.cjs` is CommonJS, anything else is treated as `.js` |
47
+ | Runtime | Node 22, on a distroless image — no shell, no package manager, no network fetch of dependencies at runtime |
48
+ | Size ceiling | 256 MB |
49
+ | Environment | `PATH`, `NODE_ENV=production`, `HOME`, and nothing else |
50
+
51
+ That environment is deliberate and it is worth knowing why, because it is the one thing that
52
+ surprises people. Your bundle's process is started with an **explicit** environment rather than
53
+ inheriting the one around it, so there is nothing to read out of `process.env` — no platform
54
+ configuration, and none of yours either. Everything arrives on the object the runtime hands your
55
+ `init` instead: the room's [settings object](#room-args), and — for calling gem-api — a **derived,
56
+ game-scoped token** that [the SDK wires up for you](#calling-gem-api-from-the-bundle). No
57
+ platform-wide credential ever enters your process; the derived token is the whole reason it does not
58
+ have to.
59
+
60
+ ## Where the server half of the SDK comes from
61
+
62
+ **From the package, not from a URL.** The unified CDN artifacts (`gem-web-sdk.min.js`,
63
+ `gem-web-sdk.sdk.js`) do carry `defineServer` — that is what lets
64
+ [a player's browser host the same bundle](#the-same-bundle-hosted-by-a-player) — but a dedicated
65
+ bundle is compiled under Node, where imports resolve from the **package, installed from npm**:
66
+ your bundler compiles `@indiegems/gem-web-sdk/sdk` into the one file you
67
+ publish, and nothing is fetched where the server runs.
68
+
69
+ **It is the same one dependency the browser half installs.** If you followed
70
+ [the getting-started page](getting-started.md#3-add-the-sdk) it is already in your
71
+ `package.json` and there is nothing to add here — one install, in `dependencies`, and the
72
+ same `@indiegems/gem-web-sdk/sdk` import from every seat. That page owns the install and is
73
+ the only place it is written out.
74
+
75
+ **Publish your client build and your server bundle at versions on the same compatibility
76
+ line.** The two halves of one game must agree about the wire, and nothing checks that for
77
+ you.
78
+
79
+ To check the download yourself: the package npm serves is, byte for byte, the file each release
80
+ also publishes on its download host, with the hash beside it:
81
+
82
+ ```bash
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.4.2/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v7.4.2/gem-web-sdk.tgz.sri
85
+ openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
+ ```
87
+
88
+ The last line prints what the `.sri` file holds, without its `sha384-` prefix.
89
+
90
+ **Know what that check does and does not prove.** The `.sri` is served from the same place as the
91
+ package file, so it catches a corrupted, truncated or cached-wrong download — not a compromised origin,
92
+ which would serve you a matching pair. It is a checksum. Do not compute your own hash from a copy
93
+ you already downloaded either: that certifies whatever you downloaded, which is the thing the check
94
+ exists to test. **The control that actually pins bytes for you is the lockfile**, and it does that
95
+ without you doing anything.
96
+
97
+ Neither npm nor the download host lets a published version be overwritten, so a version you pin
98
+ keeps serving what you checked.
99
+
100
+ **`dependencies` is the right home** — your bundler compiles the SDK into the file you upload, so
101
+ an install that skips dev dependencies must still resolve it. **Pin one version** across the builds
102
+ you publish.
103
+
104
+ ### The package ships minified, and your stack traces will show it
105
+
106
+ The JavaScript in the package is compiled and minified, so frames that fall inside the SDK carry
107
+ one- or two-letter names and a column offset on line 1 rather than a function name and a line:
108
+
109
+ ```
110
+ at Module.P (node_modules/@indiegems/gem-web-sdk/dist/gem-web-sdk.sdk.js:1:43903)
111
+ ```
112
+
113
+ Your own frames are unaffected — they come from your bundle, not ours. Two things a crash usually
114
+ turns on still work normally: every error the SDK raises itself carries its full written message,
115
+ and the types you build against are complete.
116
+
117
+ The case worth knowing is a `TypeError` whose text names one of our identifiers — `t is not a
118
+ function` rather than `setup is not a function`. That is a mangled name, not a variable of yours.
119
+ Read the message the SDK wrote and the frames in your own code; if neither explains it, quote the
120
+ whole trace and the version you pinned when you ask us.
121
+
122
+ ### Building the one file
123
+
124
+ Everything your server imports is compiled into the file, because the runtime image has no
125
+ `node_modules` and fetches nothing. One command does it:
126
+
127
+ ```bash
128
+ npx esbuild src/server/main.js --bundle --platform=node --format=esm \
129
+ --target=node22 --outfile=server-dist/my-game-server.mjs
130
+ ```
131
+
132
+ - `--bundle` inlines the SDK and every other import.
133
+ - **The output extension selects the module system** the runtime loads it under — `.mjs` is ESM,
134
+ `.cjs` is CommonJS, anything else is treated as `.js` — so `--format` must agree with the
135
+ extension you choose.
136
+ - `--platform=node --target=node22` matches the runtime the platform runs.
137
+ - Emit into a directory of its own: the upload below takes a **directory**, and its contents are
138
+ the package.
139
+
140
+ ### Publishing it
141
+
142
+ ```bash
143
+ gemctl build upload --dir server-dist --platform server --version 0.4.7 \
144
+ --server-size small --server-protocol webrtc_dedicated
145
+ ```
146
+
147
+ ⚠️ **`--server-size` and `--server-protocol` decide whether the package can ever allocate, and both
148
+ are immutable after upload.** Size is required and `small` is the only class a fleet is deployed
149
+ for. A browser game must declare `webrtc_dedicated` — an undeclared protocol resolves to UDP at
150
+ allocation, which a browser cannot reach — and neither value can be added to an already-published
151
+ version: the failure surfaces at the players' first match, not at publish. The version you pass
152
+ must land on the same compatibility line as the client build it serves
153
+ ([`platform-rules.md`](platform-rules.md#4-the-compatibility-line)).
154
+
155
+ ## The game half
156
+
157
+ **Write your bundle with `defineServer`.** It is the shape this page uses throughout, the shape the
158
+ published example uses, and the one to reach for unless you have a specific reason not to:
159
+
160
+ ```js
161
+ import { defineServer } from '@indiegems/gem-web-sdk/sdk';
162
+
163
+ export default defineServer((gem) => ({
164
+ init() {}, // once, before any player
165
+ join(playerId, meta) {}, // a verified player connected
166
+ leave(playerId, meta) {}, // their channel closed
167
+ message(playerId, data, isText, meta) {}, // data: string when isText, else Buffer
168
+ shutdown() {}, // the match is ending
169
+ }));
170
+ ```
171
+
172
+ Those five methods are the **platform's** contract rather than this SDK's: a bundle's default export
173
+ is called **once** and returns an object with any of them. All are optional, and each may be `async`
174
+ — the runtime awaits it before delivering the next event. `defineServer` returns exactly that object,
175
+ so nothing described on this page is a wrapper's behaviour you would lose by assembling it yourself.
176
+
177
+ What it saves you is the wiring every bundle otherwise repeats. One `gem` carries both halves of the
178
+ job — the runtime surface your match runs on, and the gem-api surface your results go through — so
179
+ the five methods share one scope, the runtime's `host` never has to be captured in a variable of your
180
+ own, and a misspelled `mesage` is a build error rather than a handler that is silently never called.
181
+ [What `gem` carries](#what-gem-carries) is the full list.
182
+
183
+ **Assembling it by hand is still supported, and is the exception.** `createMatchServer(host)` builds
184
+ the same `gem` from inside an `init` you wrote yourself, and `createGemServer(host)` builds only the
185
+ gem-api half for a bundle that wants nothing else. Both are exported and neither is deprecated — they
186
+ are there for the bundle that has a reason to own its own lifecycle object. If you do not have one,
187
+ use `defineServer` and read the rest of this page as written.
188
+
189
+ ### The same bundle, hosted by a player
190
+
191
+ The module you write on this page is not tied to the dedicated fleet. When a room template declares
192
+ `match_type: "p2p"`, the platform allocates no pod — the room's host **player's browser** runs the
193
+ match — and the SDK runs **this same bundle** there, over the P2P connections it opens for you.
194
+ Nothing about the lifecycle changes: the same five methods, delivered one at a time in the same
195
+ order, the same `gem` surface.
196
+
197
+ Wire it from your browser code with two calls, and make them unconditionally — every player, every
198
+ match:
199
+
200
+ ```js
201
+ import server from './server-logic.js'; // the same default export you publish
202
+
203
+ const hosting = await gem.match.serve(server); // runs it HERE only if this player must host
204
+ const link = await gem.match.connect(); // the authority, whoever that turned out to be
205
+ ```
206
+
207
+ `serve` answers which case happened — `dedicated` (the platform runs your published bundle), `peer`
208
+ (another player's browser is hosting), or `listen` (your bundle is now running in this browser).
209
+ `connect` resolves the same link shape in all three: the dedicated server's negotiated transport,
210
+ the hosting peer's data channels, or — on the host itself — an in-process loopback to the bundle
211
+ `serve` started. **Which case a match takes is a fact of your room template, not of your source**,
212
+ so retuning a game between dedicated and player hosting is a config change.
213
+
214
+ One fact does not fit in `onClose`: whether the link **dropped** or the **platform ended the
215
+ match**. Since the SDK's 4.0.0 release (2026-09-10) a drop is the SDK's to recover — the
216
+ connection goes `suspended`, the room raises `matchSuspended`, and the policy re-dials on its own —
217
+ so `onClose` fires only when the match is over or the attempts ran out. Ask for the end on
218
+ `connect`, on every seat, and key nothing on `onClose`:
219
+
220
+ ```js
221
+ const link = await gem.match.connect({
222
+ onMatchEnded: (reason) => {
223
+ showMatchOver(reason); // the platform's reason: 'explicit', 'expired', 'stale_cleanup', …
224
+ },
225
+ });
226
+ link.onClose(() => releaseTheScreen()); // cleanup only; never "the match ended"
227
+ ```
228
+
229
+ `onMatchEnded` is called once, with the platform's reason, immediately **before** the connection
230
+ closes — the same contract as `gem.dedicatedServer.connect({ onMatchEnded })`. It is not called
231
+ for your own `close()`. The room's `matchEnded` event carries the same fact with `roomReopened`
232
+ beside it; the phases, the reason vocabulary and the reconnect policy are
233
+ [match-session.md](match-session.md). On a dedicated connection `link.transport` also names which
234
+ wire won the ladder (`webtransport`, `webrtc` or `websocket`); it is absent on `p2p` and
235
+ `loopback`.
236
+
237
+ **Ending a match from this seat.** The handle `serve` returned is the authority when `mode` is
238
+ `listen`: `hosting.end()` ends the match for everyone — the platform first, then your bundle's
239
+ `shutdown` with `match_ended` / `explicit` — and `hosting.leave()` hands the match to another host
240
+ without ending it. Both are refused on any other `mode`.
241
+
242
+ Three things are honestly different on a player-hosted match, and your bundle can read
243
+ `gem.sizeClass` (`listen_host`) if it needs to know:
244
+
245
+ - **The credential is the hosting player's own play token**, not a game-server token. The calls on
246
+ `gem.api` are the same calls; gem-api is the authority on what each credential may do, so an
247
+ operation the play token does not cover fails at the platform with the same errors your `catch`
248
+ already handles.
249
+ - **`kick(playerId, reason)` drops the reason** — the P2P kick carries none. The kick itself works.
250
+ - **`gem.log` / `gem.warn` reach the game's own log sink**, not the platform's match logs — there is
251
+ no platform process to own a log stream. That sink is the browser console unless `createGem` was
252
+ given a `log` of its own, and each line is prefixed `[gem host]`.
253
+
254
+ The `meta` arguments are what a **newer** runner knows beyond the id and the bytes, and they are
255
+ optional end to end — an older runner passes nothing, so default before use, the same rule as every
256
+ other version-skew member on `host`:
257
+
258
+ | Callback | `meta` may carry |
259
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
260
+ | `join` | `playerNumber` — the seat the runtime assigned; `transport` — which transport carried the player (`webrtc`, `webtransport`, `websocket`; on a listen host `loopback` for the hosting player and `webrtc_p2p` for a peer); `rejoin: true` when the platform has met this player in this match before — restore what you kept for them; `superseded: true` when their earlier connection was still attached as this one landed, so the `leave` you just heard (its `cause` is `superseded`) was the platform swapping links, not the player leaving. Both keys are absent, not `false`, otherwise, and both come from a dedicated runtime and a listen host alike |
261
+ | `leave` | `cause` — why the connection ended, as the runtime saw it; `superseded` means the same player is already back on a newer connection and the `join` that says so follows at once |
262
+ | `message` | `reliable` / `ordered` — [the delivery class the sender asked for](#delivery-classes-the-flags-are-the-senders-request); `sessionId`; `playerNumber` |
263
+
264
+ A complete working example is [`examples/server-bundle/echo.mjs`](../examples/server-bundle/echo.mjs)
265
+ — a chat relay in one file, exercising every lifecycle method, the startup reads and most of the
266
+ runtime surface. This repo's suite drives it against the contract below, so it is a starting point
267
+ rather than a sketch.
268
+
269
+ ### The host
270
+
271
+ The runtime hands its surface over at `init` and nowhere else. `defineServer` takes it from there and
272
+ gives you `gem`, which carries the table below with three changes and nothing else moved:
273
+
274
+ - `matchId`, `gameId`, `channel` and `gameRoomId` are grouped as **`gem.identity`**;
275
+ - `profile` answers `null` rather than `undefined`, and wraps the display name — [below](#what-gem-carries);
276
+ - the three **token members are consumed by the SDK** rather than passed on, because
277
+ [wiring them into gem-api](#calling-gem-api-from-the-bundle) is the whole of what they are for.
278
+
279
+ Everything else keeps its name, so `players()` in this table is `gem.players()` in your bundle and
280
+ `send` is `gem.send`. A bundle assembling its own lifecycle captures the `host` argument that `init`
281
+ is called with and reads the table unmodified. Neither spelling is valid before `init` has run.
282
+
283
+ | | |
284
+ | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `matchId` | The match this process is serving |
286
+ | `gameId` | Your game |
287
+ | `channel` | The release channel the match is running on — the same one whose `server` package supplied this bundle |
288
+ | `gameRoomId` | The game room backing this match — the id the [startup reads](#what-your-server-reads-at-startup) are about. `undefined` under an older runtime image, so default before use, the same way the token members are handled |
289
+ | `sizeClass` | Which fleet size class this pod was allocated from |
290
+ | `roomArgs` | The room's **settings object** — typed values, not a string. [Below](#room-args). **Deprecated, still delivered:** the room-args read through the SDK is its replacement. Delivery is the runtime image's call, not this package's — [the deprecation policy](deprecation-policy.md#3-runtime-delivered-pushes-are-a-separate-question) |
291
+ | `roster` | The room's members **at allocation time**, as player ids — a snapshot the platform never revises, and it may be empty. **Deprecated, still delivered:** the room read returns the live membership, with display identities the snapshot never had. Delivery is the runtime image's call, not this package's — [the deprecation policy](deprecation-policy.md#3-runtime-delivered-pushes-are-a-separate-question) |
292
+ | `players()` | The player ids connected right now, as a fresh array. Runtime truth, and **not** deprecated — see [members versus players](#members-versus-players) |
293
+ | `profile(playerId)` | `{ displayName, displayTag, discriminator }`, resolved by the platform from the match roster; `undefined` while the fetch is pending, or when the player has no display name. **Deprecated, still delivered:** the room read carries display identity plus `role`, `is_ready` and `avatar_icon_id`. Delivery is the runtime image's call, not this package's — [the deprecation policy](deprecation-policy.md#3-runtime-delivered-pushes-are-a-separate-question) |
294
+ | `playerNumber(playerId)` | The 1-based seat number the runtime assigned a connected player, or `undefined` — for an id it has not seated, and equally on an older runtime image that has no such member |
295
+ | `send(playerId, data, options)` | To one player. `data` is a string (sent as text) or a `Uint8Array`/`Buffer` (sent binary). `options` is `{ reliable, ordered }`, both defaulting true — [the delivery class you ask for](#delivery-classes-the-flags-are-the-senders-request). An older runner ignores the extra argument harmlessly |
296
+ | `broadcast(data, { except, reliable, ordered })` | To every connected player, optionally excluding one, with the same delivery options |
297
+ | `kick(playerId, reason)` | Disconnect a player |
298
+ | `log(message)` | An info line in the match's logs |
299
+ | `warn(message)` | A warning line |
300
+ | `apiBaseUrl` | The backend's API origin. [Below](#calling-gem-api-from-the-bundle) |
301
+ | `serverToken()` | The current game-server token, as `{ token, expiresAt }`. [Below](#calling-gem-api-from-the-bundle) |
302
+ | `onServerToken(listener)` | Subscribe to token replacement; returns an unsubscribe function. [Below](#calling-gem-api-from-the-bundle) |
303
+
304
+ **Use `log`/`warn`, not `console.log`.** The bundle's stdout is the protocol channel between it and
305
+ the platform half of the runtime. A line written directly to it is not lost — it is reported as "the
306
+ game runner wrote a non-protocol line" — but it arrives as a warning about your bundle rather than as
307
+ the log you wanted.
308
+
309
+ ### Delivery classes: the flags are the sender's request
310
+
311
+ A newer runtime serves a match over more than one transport — WebRTC, WebTransport, WebSocket — and
312
+ the transports differ in what they _can_ deliver. The `reliable`/`ordered` options on `send` and
313
+ `broadcast`, and the same fields on `message`'s `meta`, are how both halves talk about it, and one
314
+ rule makes them safe to use unconditionally:
315
+
316
+ **The flags name what the sender ASKED for, not what the network did.** A transport upgrades
317
+ delivery when it has nothing weaker — a player connected over WebSocket receives an
318
+ unreliable-flagged message reliably, because TCP has nothing else — and the flags still cross
319
+ untouched, so the receiver always knows the ask. Both default true, which is the only reading every
320
+ transport honours exactly.
321
+
322
+ Use `{ reliable: false, ordered: false }` for state that is superseded by the next update anyway —
323
+ position snapshots, effect triggers — and leave the defaults for anything a player would miss:
324
+
325
+ ```js
326
+ message(playerId, data, isText, meta) {
327
+ // meta?.reliable === false means the SENDER marked this expendable.
328
+ gem.broadcast(data, { except: playerId, reliable: meta?.reliable !== false });
329
+ }
330
+ ```
331
+
332
+ An older runner ignores the options argument harmlessly and passes no `meta`, so a bundle written
333
+ this way runs unchanged on both. Nothing about the envelope changes either: the join/welcome
334
+ exchange below is identical on every transport, and everything after the welcome is still yours.
335
+
336
+ ### Room args
337
+
338
+ `gem.roomArgs` is the room's **settings object**, and that object is the sole configuration
339
+ channel. (Older revisions of this runtime carried two opaque strings, `gameArgs` and `templateArgs`;
340
+ both were removed along with the platform's `game_args`/`template_args` — everything they carried
341
+ now travels through the room's schema.) The push itself is **deprecated, still delivered**: today
342
+ your server [reads the same object itself](#what-your-server-reads-at-startup) through the SDK, and
343
+ the push survives as a compatibility fallback for bundles that have not moved yet. **How long it
344
+ keeps arriving is not this package's to say** — the dedicated-server runtime image delivers it, so
345
+ that answer is made where the image is built. [The deprecation
346
+ policy](deprecation-policy.md#3-runtime-delivered-pushes-are-a-separate-question) carries both
347
+ halves: the property on the type is package surface, and delivery is the image's.
348
+
349
+ The shape is declared by you, up front: the game's published config declares a schema
350
+ (`room_args_template`) on the room's template — each entry's type, allowed values, default, and
351
+ whether you fixed the value or left it for the room's owner to choose — and the room's owner fills
352
+ in what they were allowed to, through gem-api. The values are frozen at the moment the match
353
+ starts, and every one of them was type- and range-checked against your schema before it could reach
354
+ the pod.
355
+
356
+ **It is an object, not a string.** Values arrive already typed as the schema declared them, so
357
+ nothing on the path parses them and your bundle should not either:
358
+
359
+ ```js
360
+ const settings = gem.roomArgs ?? {}; // owner's choices, bounded by your schema
361
+ const rounds = settings.rounds ?? 3; // already a number
362
+ ```
363
+
364
+ - **`undefined` is not `{}`.** `roomArgs` is `undefined` when the room's template declares no
365
+ schema, which is deliberately distinguishable from a schema whose entries are all unset. An older
366
+ runtime image, or an older room, also reads as `undefined` — default before use.
367
+ - **It is frozen at match start.** Nothing changes it for a live match.
368
+ - **It is not a secret channel.** Every room member can read every value, including the ones you
369
+ fixed. Nothing whose safety depends on players not knowing it belongs here — that is what a
370
+ server holding authority is for.
371
+ - **The browser sees the same values**, so a dedicated bundle and a listen-host can read one
372
+ published shape. It reads them off the room itself — `room.roomArgs`, on the v2 room read —
373
+ not from `settings`, which is a different, unvalidated field and does not carry them;
374
+ `rooms-and-matches.md` §5a is the browser half. The trust differs — the bundle runs on the
375
+ platform, a P2P host is a player's machine — but the data does not.
376
+
377
+ A bundle can also read the object from gem-api rather than wait for the push: the room read
378
+ carries it and admits a game-server token, and `gem.room.args()` is that object read off your own
379
+ room, with a 404 `room_args_not_defined` when the template declares no schema. One read at startup is authoritative
380
+ for the whole match, because the object is write-locked while a match runs — [What your server
381
+ reads at startup](#what-your-server-reads-at-startup) is the full contract.
382
+
383
+ ### Calling gem-api from the bundle
384
+
385
+ The bundle gets a **game-server token** for its own gem-api calls, delivered and kept fresh by the
386
+ platform half — so a server can hold real authority: write a save, grant an item, move a
387
+ progression, end the match, all from code players cannot reach.
388
+
389
+ Three members on `host` carry it:
390
+
391
+ | | |
392
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
393
+ | `apiBaseUrl` | The backend's API origin. `undefined` under an older runtime image; so is everything else here, so default before use |
394
+ | `serverToken()` | `{ token, expiresAt }` (`expiresAt` in unix seconds), or `undefined`. Read it **per request**: the platform half replaces the token well before expiry, so the current value is always the right one and your bundle never refreshes, decodes or schedules anything |
395
+ | `onServerToken(listener)` | Called with `{ token, expiresAt }` immediately if a token is already held, and again on every replacement. Returns an unsubscribe function. This is the hook an SDK token source wires itself to; a bundle reading `serverToken()` per request never needs it |
396
+
397
+ The first token is already on `host` when your `init(host)` runs; replacements arrive on the
398
+ platform half's own schedule (the token lives 15 minutes and is re-delivered roughly two minutes
399
+ before it would expire).
400
+
401
+ **Use the SDK rather than hand-rolling fetch.** The same package that serves the browser ships a
402
+ server entry, so both halves of your game share one set of wire types:
403
+
404
+ ```js
405
+ import { defineServer } from '@indiegems/gem-web-sdk/sdk';
406
+
407
+ export default defineServer((gem) => {
408
+ const saves = new Map();
409
+
410
+ return {
411
+ async leave(playerId) {
412
+ // Server-authoritative persistence: the player's client never sees a write path.
413
+ const state = saves.get(playerId);
414
+ if (!state) return;
415
+ const grant = await gem.api.requestSaveUploadUrl('players', playerId, 'match-state', {
416
+ size_bytes: state.byteLength,
417
+ content_type: 'application/octet-stream',
418
+ });
419
+
420
+ // SEND what `grant.upload_headers` NAMED, through a list of what you are
421
+ // willing to send. Storage requires these and the grant is the only thing
422
+ // that knows which — see below, including why this is a check and not a
423
+ // spread.
424
+ const headers = { 'content-type': 'application/octet-stream' };
425
+ for (const [name, value] of Object.entries(grant.upload_headers ?? {})) {
426
+ // By name AND by value: a header you did not expect does not go on a
427
+ // request to someone else's host, and neither does one whose value is
428
+ // not a string.
429
+ if (name.toLowerCase() !== 'x-ms-blob-type') continue;
430
+ if (typeof value !== 'string') continue;
431
+ headers['x-ms-blob-type'] = value;
432
+ }
433
+
434
+ // TRY THE UPLOAD TWICE. A keep-alive connection the other side closed
435
+ // while it sat idle fails with no status at all, and nothing underneath
436
+ // re-sends it. The same bytes to the same signed URL are the same blob,
437
+ // so repeating this hop is safe — a REFUSAL is not retried, only a
438
+ // connection that reached nothing.
439
+ let upload;
440
+ for (let attempt = 1; ; attempt++) {
441
+ try {
442
+ upload = await fetch(grant.upload_url, { method: 'PUT', headers, body: state });
443
+ break;
444
+ } catch (error) {
445
+ if (attempt === 2) throw error;
446
+ }
447
+ }
448
+ // CHECK IT. Finalizing an upload that answered 400 stores nothing and
449
+ // reports success, which is the failure this whole section is about.
450
+ if (!upload.ok) throw new Error(`save upload answered ${upload.status}`);
451
+
452
+ // Nothing is stored until this returns. An upload that is never finalized
453
+ // is staged and swept, and the player's save stays whatever it was.
454
+ await gem.api.finalizeSaveUpload('players', playerId, 'match-state', {
455
+ session_id: grant.session_id,
456
+ });
457
+ },
458
+ async shutdown() {
459
+ await gem.match.end(); // a server-authoritative match ends when the server says so
460
+ },
461
+ };
462
+ });
463
+ ```
464
+
465
+ **`grant.upload_headers` is not optional.** The upload goes to object storage, not to the
466
+ platform, and what storage requires on that `PUT` is not the same everywhere — one storage backend
467
+ refuses the write without `x-ms-blob-type: BlockBlob` and answers `400 MissingRequiredHeader`,
468
+ another requires nothing and sends you an empty object. Which one your save reaches is not
469
+ something a bundle can see, so hardcoding either is right only by luck and sending neither fails
470
+ outright.
471
+
472
+ Send what the grant names — and **only** what you are willing to send. Spreading the object
473
+ wholesale puts whatever the platform names onto a request your server makes to a host it did not
474
+ choose, which admits headers that change what the request _does_ rather than how it is framed. The
475
+ browser half of the SDK refuses that for the same reason, and a server bundle runs with more
476
+ authority than a browser, not less. There is no wrapper on the server side to do this for you, so
477
+ this example is the whole of the guidance.
478
+
479
+ It previously wrote that hop as `// ... PUT the payload to grant's signed URL ...` and left the
480
+ headers to the reader, which is the omission worth naming: the browser SDK made the same one in
481
+ code, and saves could not store a byte from a browser for three releases while its tests stayed
482
+ green.
483
+
484
+ A server bundle meets no browser preflight on this request, so the header is the only part of that
485
+ history which reaches it.
486
+
487
+ **Only the middle hop needs a retry you write.** The two `gem.api` calls go through the SDK's
488
+ client, which already re-sends what is safe to re-send — and asking for an upload URL is safe,
489
+ because it stores nothing and a second ask is a second URL. Finalizing is not, and the SDK does not
490
+ retry it: that hop is where the save becomes the player's, so repeating one that may already have
491
+ been applied is a decision for you rather than for the SDK. The `PUT` in between leaves the platform
492
+ and inherits none of that, which is why the example above loops around it.
493
+
494
+ A complete, runnable version of this — with the slot's access rules, the room template that makes a
495
+ dedicated server exist at all, and a real match's logs — is
496
+ [`examples/server-saves/`](../examples/server-saves/README.md) in the SDK repository. It refuses the
497
+ upload outright where the snippet above merely skips an unexpected header, because a grant naming
498
+ something else is a platform bug worth surfacing rather than working around.
499
+
500
+ The setup function runs once, before the match starts, and the object it returns is your lifecycle —
501
+ so a `const` declared there is state your five methods share. The gem-api half is built for you as
502
+ the runtime hands the host over, which is also where a runtime image too old to deliver tokens is
503
+ refused: at startup, and not on your first call.
504
+
505
+ #### Typing your own helpers against gem-api's shapes — `3.0.0` moved this
506
+
507
+ **If you only call `gem.api.*` and let TypeScript infer, nothing here affects you.** This is about
508
+ writing a gem-api response type down by name.
509
+
510
+ Until `3.0.0`, `@indiegems/gem-web-sdk/server` re-exported the whole generated client — 145
511
+ declarations — from its top level. It no longer does. The entry now exports a named list: the server
512
+ API itself, the errors, the token and transport types, and the types its own signatures expose.
513
+
514
+ **It moved 95 types AND 50 functions.** If you imported an operation function —
515
+ `getEntityInventory`, anything in the table below — directly from `@indiegems/gem-web-sdk/server`,
516
+ that is a build break, not just a typing one. Everything is still published, one level down:
517
+
518
+ | was | now |
519
+ | --------------------------------------------------------------------------- | ------------------------------------------- |
520
+ | `import type { ItemInstanceResponse } from '@indiegems/gem-web-sdk/server'` | `…from '@indiegems/gem-web-sdk/server/api'` |
521
+ | `import { getEntitySave } from '@indiegems/gem-web-sdk/server'` | `…from '@indiegems/gem-web-sdk/server/api'` |
522
+
523
+ So the fix is an import line, not a port. The deep path exists precisely so that narrowing the top
524
+ level takes nothing away — and a second import is the honest price for reaching generated internals,
525
+ which change when gem-api's document changes rather than when this SDK decides something.
526
+
527
+ ⚠️ **`/server/api` is a subpath export**, so your `tsconfig.json` needs `"moduleResolution":
528
+ `"bundler"`or`"node16"`. On the older `"node"`resolution it fails as`Cannot find module`, which
529
+ reads as a missing package rather than a config setting.
530
+
531
+ **Two types genuinely improved rather than moved.** `ClientOptions` — the options for the
532
+ `GemClient` this entry exports as a constructible class — and `HostTokenSourceOptions` were never
533
+ exported at all, under the wildcard or before it, so a bundle could call those and had no way to
534
+ write down what they took. Both are exported now.
535
+
536
+ **The break is confined to `/server`.** The major bump takes the whole package to `3.0.0` because
537
+ that is what package-level semver means, but nothing changed for `@indiegems/gem-web-sdk` or
538
+ `@indiegems/gem-web-sdk/game`.
539
+
540
+ That wiring binds your `game_id` and `channel` on every request (from the allocation — your code
541
+ never types either, in a path or a query) and maps failures into the SDK's closed error set
542
+ (`GemApiError.reason`). The callable surface, `gem.api.*`, is **generated from gem-api's own OpenAPI
543
+ document**, so the call shapes provably match the contract:
544
+
545
+ | Area | Operations |
546
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
547
+ | Saves | `listEntitySaves`, `getEntitySave`, `deleteEntitySave`, `requestSaveUploadUrl`, `finalizeSaveUpload` |
548
+ | Settings | `listEntityGameSettings`, `getEntityGameSetting`, `putEntityGameSetting`, `deleteEntityGameSetting` |
549
+ | Inventory | `getEntityInventory`, `getEntityCurrency`, `getEntityInventoryItem`, orders (`createEntityOrder`, `createEntityBatchOrder`, `simulateEntityOrder`, `listEntityOrders`, `getEntityOrder`), progressions (`listEntityProgressions`, `getEntityProgression`, `addEntityProgression`, `maxEntityProgression`, `resetEntityProgression`, `claimEntityProgressionRewards`), recipes (`executeEntityRecipe`, `getEntityRecipeUsage`, `getEntityRecipeAvailability`) |
550
+ | Loot | `getDropRates` |
551
+ | Leaderboards | `getLeaderboard` (the whole board — seed a match from current ranks), `getEntityLeaderboard` (one entity's own runs, best first, with its personal best — the read for deciding a reward from what the player actually did). Not `getFriendLeaderboard`: that view is per viewer, so a credential with no player identity is refused |
552
+ | Transfers | `listTransfers`, `initiateInventoryTransfer`, `getTransferDetails`, `acceptTransfer`, `cancelInventoryTransfer`, `rejectTransfer` |
553
+ | Platform-funded purchases | `getPurchaseCatalog` — the catalog read only. A **player client** may use `gem.me.mutations.purchase` for a direct platform-currency order because its token fixes the player, game and channel. A dedicated server token names no player and cannot start one; have the player's client initiate it. |
554
+ | Game rooms | `getGameRoomV2`, `updateGameRoomV2`, `getRoomArgs`, `putRoomArgs`, `listGameRooms`, `closeGameRoom`, `kickPlayer`, `transferHostV2` — the reads pre-bound to your own room as `gem.room.get()` / `gem.room.args()`. [Below](#what-your-server-reads-at-startup) |
555
+ | Game config | `getChannelAssignment` — which config version your server's own channel is running, and a short-lived signed URL for that version's JSON. Pass `gem.identity.channel`: the read is fenced to your own game and channel |
556
+ | Room templates | `listResolvedTemplates`, `getResolvedTemplate` |
557
+ | Events | `listEvents`, `getEvent` — reads only; creating and scheduling events is a developer surface |
558
+ | Showcase | `getPlayerGameProfile`, `getShowcaseValue`, `putShowcaseValue`, `deleteShowcaseValue` — a player's display values. A server reads a slot that declares `access_game_server_read` and writes or clears any player's value in one that declares `access_game_server_write`; a slot without the flag refuses it |
559
+ | Voice | `createSession`, `deleteSession`, `adminAction` |
560
+ | Analytics | `ingestAnalyticsEvents` — prefer `gem.analytics`, which queues, batches, attributes each event to a player and reports refusals; this is the bare request underneath it |
561
+ | Functions | `invokeFunction` — prefer `gem.functions`, which sends your input, decodes the answer and reads your function's own error text; this is the bare request underneath it |
562
+ | Challenges | `listChallenges` — prefer `gem.challenges`, which requires the player whose period you mean, since a read that names nobody is answered in UTC, and reads each row defensively; this is the bare request underneath it |
563
+
564
+ Entity operations take `entity_type` (`'players'` or `'characters'`) and the target's id — the
565
+ proxy path, a game-server acting **on** a player. Conditional reads return the SDK's
566
+ `VersionedResponse` and writes take `ifMatch`, so the ETag concurrency model survives generation.
567
+
568
+ **Which operations are here, and why not others.** gem-api declares a `game_server_token` security
569
+ scheme on the routes that accept one, so this table is derived from its document rather than
570
+ guessed. What it leaves out, it leaves out on purpose: calls the **runtime** owns (below), and the
571
+ ones the local runner owns when you serve a match from your own machine; routes whose own
572
+ description refuses a server's token, such as a player's purchase receipt and the run submission a
573
+ serverless function makes outside any match; surfaces that belong to a person rather than to a
574
+ running match — creating or activating an event needs an Owner or Admin, and a bundle has neither;
575
+ and routes the platform has **superseded itself**, where
576
+ an older room read and an older roster read are both replaced by the v2 room read above, and the
577
+ older path spellings are kept beside them as deprecated aliases. That last group takes nothing
578
+ away: every replacement is on the table.
579
+
580
+ Voice is worth calling out: those three are declared for game-server tokens and **nothing else**,
581
+ so allocating and tearing down a match's voice rooms is a bundle's job by design — `createSession`
582
+ at match start, `deleteSession` at match end, `adminAction` for mute/move/disconnect.
583
+
584
+ **What the token is, exactly.** A gem-api access token naming your game's server identity for this
585
+ game + channel. It is **game-wide, not match-wide**: gem-api will act on any player of your game
586
+ with it, not only the players on this match's roster. That breadth is intended rather than
587
+ provisional, so build on it — the roster is a discipline you keep, not a fence the token enforces.
588
+ Address players by the ids the runtime verified (`join()`, `gem.players()`).
589
+
590
+ **What it is not.** A platform credential. Nothing platform-wide ever enters your process; the
591
+ derived token is why it does not have to.
592
+
593
+ Two more rules of the road:
594
+
595
+ - **The runtime owns the match lifecycle calls it already makes.** Match heartbeats, allocation
596
+ readiness, signaling tickets and the match-roster read are the platform half's job; the SDK's
597
+ server surface deliberately does not expose them. You lose nothing by that: `gem.room.get()`
598
+ returns a strict superset of the roster — every member with display identity, plus `role`,
599
+ `is_ready` and `avatar_icon_id` the roster never carried. `gem.match.end()` is the one
600
+ lifecycle call that is yours — it is how a server-authoritative game finishes — and even there
601
+ the platform half sends the request, carrying the proof that this server is the one allocated
602
+ to the match. There is no raw end-match operation on `gem.api`, and on a platform image too old
603
+ to send the end for you, `gem.match.end()` throws rather than sending one without that proof.
604
+ - **Never write to `process.stdout`.** It is the protocol pipe to the platform half. The SDK's
605
+ server entry never logs for this reason; your code uses `gem.log`/`gem.warn`.
606
+
607
+ ### What your server reads at startup
608
+
609
+ Room data used to arrive only as a push: the platform half wrote `roomArgs`, `roster` and the
610
+ profile cache into your process at match start, and what it did not push you could not know. That
611
+ paradigm is being retired. Your server now holds a token that can **read its own room**, so the
612
+ contract is pull-shaped: you fetch what you need, when you need it, and the pushes survive only as
613
+ a compatibility fallback for bundles that have not moved.
614
+
615
+ The lifecycle contract, in one table:
616
+
617
+ | What | Call | When | Changes mid-match? | On failure |
618
+ | ------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
619
+ | The room — status, membership, display identities | `gem.room.get()` | In `init`, and again when [`join` delivers an id you do not recognise](#members-versus-players) | **Yes** — members join and leave, hosts transfer, and the platform never revises a push to tell you | Retry and degrade — run the match with what you have. A room read failing is **never** a reason to abort a match |
620
+ | Room args — the settings object | `gem.room.args()`, or `room_args` off the room read | Once, in `init` | **No** — the object is write-locked while a match runs, so the startup read is authoritative for the whole match | 404 `room_args_not_defined` is the **normal** answer for a template that declares no schema — distinct from an empty object, and not an error to retry. Anything else: retry, then fall back to defaults |
621
+ | Who is connected to this process | `gem.players()` | Whenever you need it — it is a local, current array | Yes, and it is always current | It cannot fail; it is runtime state, not a network call |
622
+
623
+ `gem.room` is the room reads pre-bound to your own room's id, which the runtime delivers as
624
+ `gem.identity.gameRoomId`. Under an older runtime image that id is `undefined` and `gem.room` is
625
+ absent — check before use — and the deprecated pushes are your fallback on a pod running one:
626
+
627
+ ```js
628
+ import { defineServer } from '@indiegems/gem-web-sdk/sdk';
629
+
630
+ export default defineServer((gem) => {
631
+ let seats = [];
632
+ let settings = {};
633
+
634
+ return {
635
+ async init() {
636
+ if (gem.room) {
637
+ const [room, args] = await Promise.all([
638
+ gem.room.get(), // members, status, display identities
639
+ gem.room.args().catch((error) => {
640
+ if (error.reason === 'not_found') return null; // template declares no schema — normal
641
+ throw error;
642
+ }),
643
+ ]);
644
+ if (room.modified) seats = room.body.members;
645
+ settings = args?.room_args ?? {};
646
+ } else {
647
+ // Older runtime image: no room id delivered. The deprecated pushes still work.
648
+ seats = gem.roster ?? [];
649
+ settings = gem.roomArgs ?? {};
650
+ }
651
+ },
652
+ };
653
+ });
654
+ ```
655
+
656
+ **That fallback is not on a package clock.** The pushes come from the dedicated-server runtime
657
+ image, so how long a pod still delivers them is settled where that image is built rather than by
658
+ this package's release numbers — [the deprecation
659
+ policy](deprecation-policy.md#3-runtime-delivered-pushes-are-a-separate-question). Keep the
660
+ `gem.room` check either way: it is what makes one bundle correct on both images.
661
+
662
+ Three habits worth keeping:
663
+
664
+ - **Revalidate rather than refetch.** The room read supports conditional requests: pass the last
665
+ read's `etag` as `ifNoneMatch` and an unchanged room answers without a body
666
+ (`{ modified: false }`), so polling or re-reading on events stays cheap.
667
+ - **Re-read the room when a stranger joins.** A player who entered the room after the match started
668
+ holds a valid credential and arrives at your `join(playerId)` without warning — the case the
669
+ runtime's push model used to paper over. The room read is how you learn their name and seat.
670
+ - **Writes are yours too, within bounds.** `gem.room` covers the reads; the write half is on
671
+ `gem.api` — `updateGameRoomV2` (which takes `room_args` in the body, refused with 409
672
+ `room_not_open` while a match runs), `kickPlayer`, `transferHostV2`, `closeGameRoom` — a
673
+ dedicated server holds host-level authority over its game's rooms on its channel.
674
+
675
+ ### Members versus players
676
+
677
+ Two different questions, and the retirement of the roster push sharpens the distinction rather than
678
+ blurring it:
679
+
680
+ - **`gem.players()`** answers _who is connected to this process_ — peers whose join credential the
681
+ runtime verified. Runtime truth. It is local, always current, and **never deprecated**.
682
+ - **The room's members** (`gem.room.get()`) answer _who holds a seat_ — membership as the
683
+ platform tracks it, with display identities, `role` and `is_ready`. API truth.
684
+
685
+ The two sets overlap and neither contains the other: a member who lost their connection is a member
686
+ and not a player, and — the one that surprises — **a backfill player is a member before they are a
687
+ player**: they take the seat first and reach your `join` seconds later. Which is why an unknown id
688
+ arriving at `join` means "re-read the room", never "reject".
689
+
690
+ ## What `gem` carries
691
+
692
+ Everything above, on one object. Here it is doing the whole job at once:
693
+
694
+ ```js
695
+ import { defineServer } from '@indiegems/gem-web-sdk/sdk';
696
+
697
+ export default defineServer((gem) => ({
698
+ async init() {
699
+ gem.log(`match ${gem.identity.matchId} on ${gem.identity.channel}`);
700
+ // The startup reads. `gem.room` is absent under an older runtime image —
701
+ // see the section above for what to fall back to.
702
+ if (gem.room) seat((await gem.room.get()).body.members);
703
+ },
704
+ join(playerId) {
705
+ gem.send(playerId, JSON.stringify({ t: 'hello', you: playerId, players: gem.players() }));
706
+ gem.broadcast(JSON.stringify({ t: 'joined', playerId }), { except: playerId });
707
+ },
708
+ message(playerId, data, isText) {
709
+ gem.broadcast(data, { except: playerId });
710
+ },
711
+ async shutdown() {
712
+ await gem.match.end();
713
+ },
714
+ }));
715
+ ```
716
+
717
+ Two halves, one object. The **match surface** is [the host table](#the-host) — `players()`, `send`,
718
+ `broadcast`, `kick`, `log`, `warn`, `sizeClass`, and the deprecated `roster`/`roomArgs` pushes — and
719
+ the **gem-api surface** is the sections after it: `gem.api.*`, `gem.room`, `gem.client`, `gem.match`
720
+ and `gem.identity`, plus `gem.functions`, which calls your game's serverless function as this server
721
+ ([serverless-functions.md](serverless-functions.md)), and `gem.challenges`, which reads one
722
+ player's daily-challenge period, date and seed ([daily-challenges.md](daily-challenges.md)).
723
+
724
+ `gem.room` is [the startup reads](#what-your-server-reads-at-startup), pre-bound to your own room,
725
+ and it is where a bundle written today gets its membership and its settings object.
726
+
727
+ `gem.roomArgs` is the runtime's push passed straight through, so [§ Room args](#room-args) above is
728
+ the whole contract — including the part that bites: **`undefined` is not `{}`**, and the SDK defaults
729
+ neither to the other. `const settings = gem.roomArgs ?? {}` in your own bundle is the place to make
730
+ that choice. It is the fallback for a pod running an older image, not the path to write first.
731
+
732
+ **It is not valid until `init`** — that is the one place the platform hands anything over — so reading
733
+ `gem.identity.matchId` at the top of your module throws a message saying so rather than handing you
734
+ `undefined`.
735
+
736
+ Three things it adds over the surface the runtime hands across:
737
+
738
+ - **`gem.profile(playerId)`** — the display identity the platform resolved for a verified player, or
739
+ `null`. `displayName` arrives wrapped rather than as a plain string, because it is player-authored
740
+ and your server is the one place in your game that relays a name **to every other player**;
741
+ `text.toPlain(name)` unwraps it where you have decided that is safe. `displayTag` — the
742
+ precomposed `Name#0001` — contains the name, so it is wrapped too; only `discriminator` is
743
+ platform-assigned and an ordinary string. Prefer relaying the player id and letting each client
744
+ render the name it already has.
745
+ - **`gem.playerNumber(playerId)`** — the seat number the runtime assigned, or `undefined` for an id
746
+ it has not seated — and equally on an older runtime image, which a bundle need not tell apart.
747
+ - **A `shutdown` you do not have to remember.** The SDK defines one whether or not you do, and
748
+ unsubscribes from the token there. Yours runs first, and it still runs if you only want
749
+ `gem.match.end()`.
750
+ - **One import for both halves.** The same package, the same error types, the same vocabulary. A
751
+ `catch` in your bundle reads like a `catch` in your browser code.
752
+
753
+ ⚠️ **A runtime image that predates token delivery fails at `init`, whatever your bundle does with
754
+ gem-api.** `defineServer` builds the gem-api half before your `init` runs, and that is where the
755
+ "predates token delivery" error comes from — so a message-relay bundle that never calls gem-api will
756
+ also refuse to start on an old image. Better at startup than at `shutdown`, when the result you meant
757
+ to write is the thing that fails.
758
+
759
+ That is the one behaviour the hand-assembled route buys you, and it is the reason it exists: a bundle
760
+ that calls `createGemServer(host)` itself decides when — and whether — to build the gem-api half, so
761
+ it can serve an old image with the deprecated pushes instead of refusing to start. Every fleet this
762
+ SDK targets delivers tokens, so that is a fallback to reach for deliberately and not a default worth
763
+ carrying.
764
+
765
+ **It is still one file.** Your bundle is bundled — the SDK is a dependency of your build, not of the
766
+ runtime, and nothing is installed where your server runs. The `/sdk` entry carries both halves and
767
+ is safe to bundle for Node — nothing in it touches a browser global at module scope — so what you
768
+ publish is your code plus whatever of the SDK it reached. No API origin is baked in either way;
769
+ that arrives on `host.apiBaseUrl` at runtime. (`/server` remains as the server-only door for a
770
+ bundle that wants nothing of the browser half in its graph.)
771
+
772
+ ### What is not on the server surface, and why
773
+
774
+ `gem.p2p`, `gem.dedicatedServer`, `gem.me`, `gem.characters` and `gem.gameConfig` are browser-only.
775
+ For the first four that is not a restriction on servers — every one of them is scoped by a
776
+ **player** token, and your server is not a player. It has no player identity, and the routes behind
777
+ those namespaces would refuse it.
778
+
779
+ `gem.gameConfig` is the exception, in one direction only. The namespace is browser-only, but the
780
+ platform read underneath it is not refused to a server: the channel assignment admits a game-server
781
+ token, fenced to that token's own game and channel. Your bundle reaches it as
782
+ `gem.api.getChannelAssignment(gem.identity.channel)`, and fetches the returned `config_url` itself.
783
+
784
+ The short version: the browser half is what a player's **session** can do, and the server half is
785
+ what a **match** can do. `gem.rooms.join()` has no meaning to a process that exists because a match
786
+ was already allocated.
787
+
788
+ **Rooms are the case where those two answers differ**, so they are worth stating separately rather
789
+ than filed under "browser-only". `gem.rooms` — join, leave, ready up, quick-join — is a player
790
+ acting on their own membership, and your server has none. Reading and administering the room your
791
+ match is running in is a different question, and one your token does answer:
792
+ `gem.room.get()`/`gem.room.args()` for the reads, and `gem.api.*` for the writes
793
+ ([above](#what-your-server-reads-at-startup)). So the boundary is not "rooms are a player thing";
794
+ it is that a server has authority over rooms and no membership in one.
795
+
796
+ ### Two orderings that are easy to get backwards
797
+
798
+ - The joining player is **already** in `players()` when `join` runs. A roll-call built there includes
799
+ them, which is usually what you want: it is also how a client learns the id the platform verified
800
+ for it.
801
+ - The leaving player is **already** gone from `players()` when `leave` runs. A broadcast there needs
802
+ no exclusion.
803
+
804
+ ### Failure
805
+
806
+ - **A method that throws does not take the match down.** The exception is logged and the runtime
807
+ keeps serving — a bundle that dies mid-match would fail an allocation that is otherwise healthy.
808
+ - **Except in `init`.** A throw there fails startup and the allocation is reported failed, because a
809
+ server that never initialised has nothing to serve.
810
+ - `init` has **60 seconds** to return. Load what you need and hand back; do not wait on a player.
811
+ - `shutdown` has **ten minutes** when the match is over — your own end, or an expired idle window —
812
+ before the link closes and the process goes with it. Anything that must outlive the match, a
813
+ result to report or a save to write, happens there. The budget is long on purpose: what a bundle
814
+ does in `shutdown` is save what just happened, and a process killed part-way through that costs a
815
+ player progress with nothing left to reconstruct it from. It is still a deadline, so bound your
816
+ work rather than assuming the window is the whole story.
817
+ - **A node drain is not bounded by that ten minutes.** When the pod is being taken away rather than
818
+ the match ending, the platform waits for your `shutdown` however long it takes — a match with
819
+ people in it plays out rather than dying because a node is draining. The only limit is the pod's
820
+ own two hours, after which everything in it is killed regardless. Both budgets, what sets them,
821
+ and the reason a bundle never sees the signal that starts the longer one, are
822
+ [below](#how-long-your-server-lives).
823
+
824
+ ## How long your server lives
825
+
826
+ Two limits decide it, and every bundle meets both. The first bounds how long the match may run. The
827
+ second bounds what you can still do once something has decided the match is ending.
828
+
829
+ Your `shutdown(info)` is told which it is: `info.reason` is `match_ended` (with `info.endedReason`
830
+ naming why, in the same vocabulary the client's `matchEnded` carries), `idle_timeout`, or
831
+ `signal` — and it is absent on older platform images, so default before use. Call
832
+ `gem.match.end()` from `shutdown` unless the reason is `signal`: on `match_ended` it is already
833
+ over and on `idle_timeout` the platform ends it itself, so the call is harmless on both, and on
834
+ `signal` the players are still in it. What the players see for each reason is
835
+ [match-session.md](match-session.md) §3 and §6.
836
+
837
+ ### The match has a maximum duration, and you set it
838
+
839
+ A dedicated match runs for at most `max_match_duration_seconds`, declared **per room template** in
840
+ your published game config:
841
+
842
+ | | |
843
+ | -------------------------------- | ----------------------------------------------------------- |
844
+ | What you may declare | **30 minutes** to **48 hours** |
845
+ | A template that declares nothing | **6 hours** |
846
+ | Applies to | `match_type: dedicated` — the only type that holds a server |
847
+
848
+ **Omitting it is not "unbounded".** A template naming no limit gets six hours, and a value outside
849
+ the range **fails the config upload**, naming the field and both bounds, rather than being quietly
850
+ clamped to the nearer one. Declaring it on a `p2p` or `none` template is refused for the same reason:
851
+ neither holds a server, so the number would do nothing.
852
+
853
+ **The deadline is fixed when the match starts**, from the template as it stood at that moment.
854
+ Promoting new config does not move a running match's deadline — only matches started afterwards get
855
+ the new number.
856
+
857
+ **Reaching it ends the match exactly as your own server ending it would**, along the same path
858
+ `gem.match.end()` takes: the room's members get the `match_ended` push they already handle, the
859
+ room reopens, and the server is released. The only difference is the reason recorded against the
860
+ match, `expired` rather than an ordinary end.
861
+
862
+ So it is a **backstop, not a schedule**, and a game that ends its own matches never reaches it. What
863
+ it catches is the match nobody ended — most often a finished match whose last browser tab stayed
864
+ open, which a server cannot tell apart from a player still thinking about their turn.
865
+
866
+ ### Winding down, and the ceiling on it
867
+
868
+ Your server can be asked to stop **at any time**. Two different things ask, and they do not give you
869
+ the same budget:
870
+
871
+ | Why your server is stopping | How long `shutdown()` gets |
872
+ | ------------------------------------------------------------------------------------- | ---------------------------------------------------- |
873
+ | The match is over — your own end, the duration limit above, or an expired idle window | **10 minutes** |
874
+ | The platform needs the machine your server is running on | **as long as you take**, to a ceiling of **2 hours** |
875
+
876
+ **The first row is the one nearly every match meets, and ten minutes is long on purpose.** What a
877
+ bundle does there is save what just happened — a result to report, progress to write — and a process
878
+ killed part-way through that costs a player something nobody can reconstruct afterwards. It is still
879
+ a deadline and it is enforced: past it the link closes and your process goes with it, so bound the
880
+ work rather than treating the window as the whole story.
881
+
882
+ The second row is the more generous of the two, and also on purpose. When the platform reclaims a
883
+ machine, **a match with players still in it is not killed for it**: the runtime calls your
884
+ `shutdown()` and then waits for you with no deadline of its own, so a match that is nearly over can
885
+ finish, and one that is not can move its players somewhere else first.
886
+
887
+ **What bounds that wait is the pod's termination grace period, and it is 2 hours.** Past it every
888
+ container is killed outright. A bundle that has not returned from `shutdown()` by then loses whatever
889
+ it was still doing — the save it had not written, the result it had not reported, the players it had
890
+ not moved.
891
+
892
+ ⚠️ **The signal does not reach your code as a signal.** A reclaim arrives at the pod as `SIGTERM`,
893
+ and what is signalled is the platform half, not you. Your bundle hears about it through `shutdown()`
894
+ and through nothing else: a `process.on('SIGTERM', …)` handler in a bundle is not the hook, is not
895
+ guaranteed to run, and must not be what your wind-down depends on.
896
+
897
+ ## What counts as a player
898
+
899
+ **A peer that has presented a join credential the runtime verified.** Not a peer that completed ICE,
900
+ DTLS and SCTP — that peer has proved it can reach the server and nothing else, so counting it would
901
+ hold a pod open for an unauthenticated stranger.
902
+
903
+ This is why `playerId` is trustworthy. It is always the subject of a credential the platform issued
904
+ and the runtime verified, so a client cannot claim to be someone else. That is a property a host peer
905
+ in a P2P star **cannot** offer, and it is the main reason to want a dedicated server at all: with
906
+ authenticated identity on the server, authority over score, inventory and outcome can live somewhere
907
+ the players do not control.
908
+
909
+ A pod that has held zero verified players for its idle window reports unhealthy and is recycled. The
910
+ window is generous relative to a join — the clock starts at allocation rather than at pod start, and
911
+ a join credential's own lifetime is 60 seconds — so a slow legitimate join cannot trip it.
912
+
913
+ ### Membership is not a guest list
914
+
915
+ **Admission is by credential, and that is the whole rule.** Anyone who reaches your bundle has
916
+ presented a join credential the runtime verified against the platform's keys, and the platform issues
917
+ one to **any member of the room**, including a player who joined after the match started. Your server
918
+ is obliged to honour it.
919
+
920
+ So no list — not the deprecated `gem.roster` snapshot, and not the membership a room read returned
921
+ a moment ago — is the set of players you are allowed to serve. A bundle that checks arrivals against
922
+ either and drops the ones it cannot find will reject real players. It will do it _after_ their
923
+ connection is up, which is the reason this is worth a section: to the player it looks like your game
924
+ dropping them, not like an admission decision anybody made, so it gets reported as a platform fault
925
+ and chased in the wrong place.
926
+
927
+ ```js
928
+ async join(playerId) {
929
+ // WRONG. No membership list can answer this: a player who joined the room
930
+ // after your last read holds a valid credential and is not on your copy.
931
+ if (!seats.has(playerId)) gem.kick(playerId, 'not invited');
932
+
933
+ // RIGHT. An unknown id means your copy of the room is stale — re-read it.
934
+ if (!seats.has(playerId)) await refreshSeats(); // gem.room.get()
935
+ }
936
+ ```
937
+
938
+ Membership answers the questions it actually describes — who holds a seat, what to call them, who
939
+ is host, who is ready — and the room read keeps the answer current in a way the retired snapshot
940
+ never could. **The live connection set is a different question**: `gem.players()` is the players
941
+ connected right now, correct at every moment, including inside `join` where the arriving player is
942
+ already in it. [Members versus players](#members-versus-players) is the full distinction.
943
+
944
+ ## The DataChannel wire format
945
+
946
+ The SDK's flow ends at "the channel is open, here is your join credential". This is what goes on it.
947
+
948
+ **Client → server, the first message, and nothing else is accepted before it:**
949
+
950
+ ```json
951
+ { "t": "join", "credential": "<join credential JWT>" }
952
+ ```
953
+
954
+ **Server → client:**
955
+
956
+ ```json
957
+ { "t": "welcome", "player_id": "<uuid>", "match_id": "<uuid>" }
958
+ { "t": "rejected", "reason": "invalid_credential" }
959
+ ```
960
+
961
+ The seven rejection reasons, and what to do about each:
962
+
963
+ | `reason` | What happened | What the client should do |
964
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
965
+ | `expected_join` | The first message was not a `join` frame — unparseable, or some other `t` | Fix the client. Retrying sends the same thing |
966
+ | `invalid_credential` | The JWT failed verification: signature, `alg`, audience, issuer, or one of its bindings | Not retryable. A fresh credential fails the same way |
967
+ | `credential_expired` | The JWT verified, but its lifetime had run out — past the leeway allowed for clock skew | **Retryable.** Call `connect()` again: it fetches a fresh credential and renegotiates |
968
+ | `credential_reused` | The JWT verified, and had already been presented as many times as one credential is good for (ten) | **Retryable with a NEW credential.** The SDK fetches one and presents it on the same transport before reporting this |
969
+ | `credential_not_for_this_channel` | It verified, but its subject is not the player who offered this channel | Not retryable. Somebody is presenting a credential on a channel negotiated in another player's name |
970
+ | `credential_timeout` | 30 seconds passed on an open channel with no join | Retryable, but the fault is almost always a client that never sent |
971
+ | `join_rate_limited` | The JWT verified, and this player has joined too often — the runtime admits 30 joins a minute, 10 in a burst | **Retryable, but slow down.** The SDK waits at least two seconds and presents the same transport again, three times, before reporting this |
972
+
973
+ `credential_expired` is a reason of its own rather than a flavour of `invalid_credential`, because it is
974
+ the only refusal here a client can act on. Folded into "invalid" it reads as a broken token, which
975
+ sends the search to the signing key and the JWKS cache instead of to the clock.
976
+
977
+ **A rejection is written before the transport is torn down**, with a short grace so it actually
978
+ arrives. A channel that vanishes with no explanation is indistinguishable from a network fault, and
979
+ sends debugging to the wrong place.
980
+
981
+ **After `welcome`, every message on the channel is opaque to the platform** and belongs to your game.
982
+ The JSON envelope above is the platform's; what you do afterwards — JSON, binary, a schema of your
983
+ own — is entirely yours. Nothing above the transport inspects it, and nothing above the transport
984
+ caps its size either, which is a reason for a server to bound what it will accept.
985
+
986
+ ## Presenting the credential, from the browser
987
+
988
+ **The SDK does this for you.** `connect()` sends the join frame and resolves once the runtime has
989
+ answered `welcome` — so a session it hands back is one the server has already accepted, not merely a
990
+ transport that came up.
991
+
992
+ ```js
993
+ const session = await gem.dedicatedServer.connect({ clusterTargetId });
994
+ // The server verified this player. Nothing above is the game's to write.
995
+ startPlaying(session.welcome.playerId);
996
+
997
+ session.channel.onmessage = (event) => handleGameMessage(event.data);
998
+ ```
999
+
1000
+ A rejection fails the connect, tears the session down, and carries the reason: the seven above map
1001
+ onto `credential_expired`, `credential_reused`, `join_rate_limited` and `timeout` — all `retryable`,
1002
+ so calling `connect()` again is the right answer, and on the negotiated path the SDK has already
1003
+ refreshed the credential or waited out the rate limit once before it reports the first three — and
1004
+ `join_refused`, which is not, because retrying sends the same thing.
1005
+
1006
+ **The gap this leaves, and how to close it.** A data channel with no listener drops what arrives;
1007
+ nothing buffers anywhere. Between the welcome and the line that attaches `onmessage` there is a
1008
+ window, and your bundle's `join` callback runs inside it — so an opening snapshot sent from there is
1009
+ the message most likely to be lost. Attaching in the continuation as above is normally fast enough.
1010
+ To remove the window entirely, hand the SDK the handler instead and it attaches before the join frame
1011
+ goes out:
1012
+
1013
+ ```js
1014
+ const session = await gem.dedicatedServer.connect({
1015
+ clusterTargetId,
1016
+ onMessage: handleGameMessage, // post-welcome frames only; the envelope is consumed
1017
+ });
1018
+ ```
1019
+
1020
+ Do not do both — two handlers see every message twice.
1021
+
1022
+ **Doing it yourself.** `presentCredential: false` hands back the raw channel, `session.welcome` is
1023
+ null, and the exchange above is yours to write. Send it immediately: `session.credentialExpiresAt` is
1024
+ a Unix-milliseconds deadline and the credential's lifetime is 60 seconds from the moment the SDK asked
1025
+ for match status — most of which the handshake has already spent.
1026
+
1027
+ ```js
1028
+ const session = await gem.dedicatedServer.connect({ clusterTargetId, presentCredential: false });
1029
+
1030
+ session.channel.onmessage = (event) => {
1031
+ // Binary is yours alone — the platform's two replies are always text.
1032
+ if (typeof event.data !== 'string') return handleBinary(event.data);
1033
+
1034
+ const frame = JSON.parse(event.data);
1035
+ if (frame.t === 'welcome') startPlaying(frame.player_id);
1036
+ else if (frame.t === 'rejected') giveUp(frame.reason);
1037
+ else handleGameMessage(frame);
1038
+ };
1039
+
1040
+ session.channel.send(JSON.stringify({ t: 'join', credential: session.joinCredential }));
1041
+ ```