@indiegems/gem-web-sdk 7.4.0 → 7.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/gem-web-sdk.esm.js +1 -1
- package/dist/gem-web-sdk.game.js +1 -1
- package/dist/gem-web-sdk.min.js +1 -1
- package/dist/gem-web-sdk.sdk.js +1 -1
- package/dist/gem-web-sdk.server.js +1 -1
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +530 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +5 -5
- package/schema/capabilities.public.json +189 -0
|
@@ -0,0 +1,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.1/gem-web-sdk.tgz
|
|
84
|
+
curl -fsSL https://downloads.gemarcade.com/sdk/v7.4.1/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
|
+
```
|