@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,372 @@
1
+ # The platform rules a game has to find out for itself
2
+
3
+ Every other page here describes **this SDK**. This one describes the **platform underneath it** — the
4
+ rules gem-api enforces that no client-side type signature states, that mostly produce no error when you
5
+ get them wrong, and that each cost a working developer a day to reconstruct from behaviour.
6
+
7
+ Each rule below was read out of gem-api's source on **2026-08-06** rather than inferred from the API
8
+ surface, because the whole point of the list is that the surface does not say these things. Rule 3 was
9
+ re-read on **2026-08-10**, when the platform changed it, and it is the exception to the silence above:
10
+ it refuses outright rather than letting you get it wrong quietly. Where a rule is an **implementation
11
+ fact** rather than a contract — true today, not promised — it is marked 🔧. Do not build on a 🔧
12
+ without saying so in your own code.
13
+
14
+ - [1. A match is sized by who is in the room when it starts](#1-a-match-is-sized-by-who-is-in-the-room-when-it-starts)
15
+ - [2. Room lifecycle: when one goes away, and what a leftover does to you](#2-room-lifecycle-when-one-goes-away-and-what-a-leftover-does-to-you)
16
+ - [3. A room's seats come from its template, and a create cannot ask for its own](#3-a-rooms-seats-come-from-its-template-and-a-create-cannot-ask-for-its-own)
17
+ - [4. The compatibility line](#4-the-compatibility-line)
18
+ - [5. `templateSlug`: what it resolves against, and what you can pass today](#5-templateslug-what-it-resolves-against-and-what-you-can-pass-today)
19
+ - [6. The demos that already exist](#6-the-demos-that-already-exist)
20
+ - [7. A dedicated match does not run forever, and neither does its server](#7-a-dedicated-match-does-not-run-forever-and-neither-does-its-server)
21
+ - [8. A player's Boost is for display, never a gate](#8-a-players-boost-is-for-display-never-a-gate)
22
+
23
+ ---
24
+
25
+ ## 1. A match is sized by who is in the room when it starts
26
+
27
+ **`startMatch()` reads the room's member list at that instant, and that list is the match.** The
28
+ room's seat count is never consulted. It bounds who may **join the room**; it says nothing about who
29
+ is **in the match**.
30
+
31
+ Start with two of eight seats filled and you have a two-player match. The other six seats reserve
32
+ nothing, wait for nobody, and are not communicated to the server.
33
+
34
+ This is the fact that costs days, because every wrong theory it produces is plausible: that the
35
+ platform waits for a full room, that the seat count is a target the matchmaker fills, that a match
36
+ started early will be topped up. None of that exists. **Holding players until you have enough of them
37
+ is your game's job**, and the room is where you hold them.
38
+
39
+ Three consequences worth having in writing:
40
+
41
+ - **Nothing enforces a minimum on a manual start.** A template's `min_players` is read by exactly one
42
+ feature — `auto_start_host`, where the server itself starts the match the moment membership reaches
43
+ that number. A game that calls `startMatch()` by hand can start a match with one member in the
44
+ room, and gem-api will allocate for it.
45
+ - **What happens to somebody who arrives _after_ the start depends on the match type**, and the two
46
+ are not alike:
47
+ - **Dedicated** — they are admitted, and **whatever membership your server last read may not
48
+ mention them yet**. Admission is not decided by any list: the platform issues a join credential
49
+ to any member of the room, including one who joined after the start, and your server is obliged
50
+ to honour it. A bundle that treats its copy of the membership as a guest list rejects real
51
+ players, _after_ their connection is already up, which presents to them as your game being
52
+ broken — an unknown arrival means "re-read the room", never "reject". See
53
+ [`server-bundles.md`](server-bundles.md#membership-is-not-a-guest-list).
54
+ - **P2P** — the roster is frozen when the match starts, and here it _is_ the gate. A room member
55
+ who is not on it is added when they first reach signaling **only if the room's template sets
56
+ `allow_join_in_progress`**; otherwise the platform refuses them with `not_match_member`, and the
57
+ symptom in your game is a peer that joined the room fine and can never connect.
58
+ - **The server's size is not sized from the room at all.** Which machine class a dedicated match gets
59
+ comes from the `server_size` declared on the published server build. A bigger room does not get a
60
+ bigger server, and a room of two on a build that declares no size fails the start with 422
61
+ `server_size_not_configured` regardless of how many players are present.
62
+
63
+ ## 2. Room lifecycle: when one goes away, and what a leftover does to you
64
+
65
+ ### An empty room is deleted immediately
66
+
67
+ When the last member leaves, gem-api deletes the room and its memberships in the same transaction.
68
+ Not closed, not archived — **gone**, and the next read of it is a 404.
69
+
70
+ 🔧 **`auto_close_when_empty` does not change this.** The flag is stored on a template, returned by the
71
+ template endpoints, and read by nothing: the delete is unconditional. The built-in `hub` template sets
72
+ it to `false` and still loses its room when the last player leaves. Treat the flag as inert rather
73
+ than as a lever you can pull.
74
+
75
+ ### A room with members in it expires four hours after its last activity
76
+
77
+ 🔧 Every mutation refreshes that four hours, and so does a read taken within two hours of expiry. A
78
+ room explicitly closed by its host gets a day instead. Expired rows are swept on a five-minute cycle,
79
+ so "expired" and "gone" are up to five minutes apart.
80
+
81
+ 🔧 `game_room_ttl_seconds` and `idle_timeout_seconds` on a template are carried and never read. The
82
+ interval is fixed. Nobody notices the first of those because the default template value is `14400`,
83
+ which is the same four hours by coincidence rather than by wiring.
84
+
85
+ ### A membership that outlives its page is the one that bites
86
+
87
+ A tab that closes without leaving leaves the player in the room. The room is not empty, so it is not
88
+ deleted, and the player is **still in it** on their next visit — with nothing in their new page's
89
+ state that knows about it.
90
+
91
+ What that does to the next session:
92
+
93
+ | What you call | What you get |
94
+ | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
95
+ | `quickJoin()` on a template matching the leftover | 409 `already_in_matching_room` — gem-api refuses to quick-join a player already in a joinable room of that type |
96
+ | Creating or joining, once the player holds **10** rooms in this game | 409 `game_room_limit_per_game` — the cap is checked before the call, so the tenth membership is the last one that works |
97
+ | …or **25** rooms across the whole platform | 409 `game_room_limit_global` |
98
+
99
+ The 409 is the good case. It is loud, and this SDK recovers from it: `quickJoin()` clears the
100
+ memberships and retries **once** by default, which `createGem({ recoverStaleMembership: false })`
101
+ turns off. The bad
102
+ case is a leftover the guard does not match — it keys on room type and channel — where the player is
103
+ simply back in a room whose match died with the tab that started it.
104
+
105
+ **How to clear one.** `rooms.myRooms()` is the only thing that can see a leftover, because your
106
+ manager holds no room and `leave()` therefore has nothing to act on. `rooms.leaveAll()` leaves every
107
+ room this player still holds, best-effort per room — one room that refuses is simply absent from the
108
+ ids it answers with. If the listing itself fails, it **rejects** rather than answering with an empty
109
+ list a caller cannot tell apart from "held nothing". Both take the game-scoped token the frame
110
+ already has.
111
+
112
+ **The platform's own clean-shutdown route is not reachable from a game.** `POST
113
+ /v1/players/me/client-shutdown` does exactly this fanout server-side, honouring
114
+ `auto_leave_on_client_shutdown` per template — but it lives on the `/v1/players/me/` surface and takes
115
+ a **player** token, which a frame does not hold and should not. On web the mechanism is this SDK's
116
+ `pagehide` leave; see [`rooms-and-matches.md` §4.3a](rooms-and-matches.md).
117
+
118
+ ## 3. A room's seats come from its template, and a create cannot ask for its own
119
+
120
+ **You do not choose a room's size when you create it.** The seat count comes from the room template —
121
+ the block of room settings you publish in your game config — and a create whose body carries a seat
122
+ count is **refused**: `400 max_players_not_settable`, with the value you sent echoed back so you can
123
+ tell which of your calls carried one.
124
+
125
+ A room created without naming a template seats **8**, and there is no way to ask for a different
126
+ number on that path either.
127
+
128
+ **Quick-join never had a seats field**, and a body that invents one is refused the same way — before
129
+ it looks for a room to join, so the answer never depends on whether one was found.
130
+
131
+ **This SDK does not send it.** `CreateRoomOptions.maxPlayers` is deprecated: setting it logs a warning
132
+ naming the replacement, and the field is dropped before the request goes out, so an old call site
133
+ still creates a room rather than starting to 400. Delete it when you see the warning. The field
134
+ keeps working for the rest of `3.x`, and `4.0.0` is the earliest release that may remove it —
135
+ [the deprecation policy](deprecation-policy.md#2-the-rule-for-the-packages-api-surface).
136
+
137
+ **The one way to set a seat count is to resize a room that already exists** — `update({ maxPlayers })`,
138
+ host-only, and never accepting a value below the room's current member count. Mind the bound: 🔧 **that
139
+ route validates 2–64 whatever the room's template allows**, while a template may declare up to 10,000.
140
+ So the `hub` template's 500 seats cannot be reached through it at all, and a templated room can only be
141
+ patched _into_ the 2–64 range, never within its own.
142
+
143
+ **Why it is a refusal and not a shrug.** Until 2026-08-10 a seat count was accepted and then quietly
144
+ discarded whenever a template resolved: no warning, no 400, nothing in the response saying it had
145
+ happened, and a room that was simply a different size than the one you asked for. The platform now
146
+ refuses the field instead. It costs you a visible error on a call that used to look like it worked,
147
+ which is the point — a size that silently was not honoured is the failure it replaced.
148
+
149
+ ## 4. The compatibility line
150
+
151
+ A **line** is the compatibility group two builds must share to meet. It is derived, never declared —
152
+ from the version the build was published under, which the arcade reports to your game rather than
153
+ asking it to say:
154
+
155
+ | Published version | Line |
156
+ | ------------------ | ------------------------------------------------------- |
157
+ | `1.4.2` | `1` — major version, for anything ≥ 1.0.0 |
158
+ | `0.5.3` | `0.5` — major _and_ minor, while you are pre-1.0 |
159
+ | `1.2.0-beta.1` | `1-beta.1` — a prerelease is its own line, tag included |
160
+ | `1.2.3+nightly` | `1` — build metadata is ignored |
161
+ | `1.2` | 400 — it is parsed as strict semver or refused |
162
+
163
+ **While you are on `0.x`, every minor bump is a new line.** `0.5.9` and `0.6.0` cannot play together.
164
+ That surprises people, and it is the single most common way to split a player base by accident.
165
+
166
+ Three rules follow from it:
167
+
168
+ 1. **A room is pinned to its creator's line, and joiners must match.** Join a room on the wrong line
169
+ and you get 409 `version_mismatch`, naming the expected line and yours.
170
+ 2. **Quick-join does not fail on a line mismatch — it silently creates a second room.** Rooms on other
171
+ lines are filtered out as non-candidates, so the create fallback runs. Two versions of your game
172
+ quick-joining at the same moment do not collide and do not error; they end up in separate rooms and
173
+ each player sees an empty lobby. This is the failure that looks like "matchmaking is broken".
174
+ 3. **Both halves ship together.** Starting a dedicated match resolves the highest playable **server**
175
+ build whose own version derives to the room's line. Nothing in that line means 409
176
+ `no_compatible_server`, with a reason that names the remedy: `none_on_channel` (no servable server
177
+ build at all), `none_in_line` (server builds exist, none in this line) or `expired` (the build that
178
+ served this line aged out). A client release without its matching server release is a game that
179
+ cannot start a match.
180
+
181
+ **Drift _within_ a line is silent, and that is deliberate.** Any client versions in the same line share
182
+ a room without complaint, and the match takes the highest playable server build in that line. Nothing
183
+ checks that they are the same number. Your protocol compatibility inside a line is yours to keep.
184
+
185
+ **Do not send a version at all.** The arcade routed this player to one of your builds and tells your
186
+ game which one; that is the version every room and match call reports, and it is the number the line
187
+ above is derived from. `clientVersion.gameVersion` is deprecated and still read, which is what keeps a
188
+ build working under an arcade too old to send one.
189
+
190
+ **Do not send `engineVersion` either.** A browser game is not an engine export and has none; the SDK
191
+ reports one for you, and a value you supply derives an engine line that matches no fleet and fails at
192
+ allocation, long after the call that decided it. Rooms enforce engine-line agreement the same way they
193
+ enforce the game line.
194
+
195
+ ## 5. `templateSlug`: what it resolves against, and what you can pass today
196
+
197
+ A slug is resolved in two steps: **your game's config for that channel first, then the platform's
198
+ built-in globals.** An id resolves the same way. An unknown slug is a 400; an unknown id is a 404.
199
+
200
+ There are exactly two built-in templates, and their properties decide what a game with no config of
201
+ its own can do:
202
+
203
+ | Slug | Seats | Match type | Notes |
204
+ | ---------- | ----- | ---------- | ---------------------------------------------------- |
205
+ | `standard` | 20 | dedicated | public, late join allowed, min 2 |
206
+ | `hub` | 500 | **none** | public, late join allowed, notifications server-only |
207
+
208
+ **`hub` starts no matches.** Its match type is "no match" and `startMatch()` on a `hub` room is a 400
209
+ `match_type_not_supported`. It is a place to put players, not a way to play.
210
+
211
+ **No built-in slug gives you a P2P match.** `standard` is dedicated, `hub` starts nothing. A browser
212
+ game that wants peer-to-peer play needs a **game-specific** room template, which comes from your game's
213
+ config.
214
+
215
+ ### Authoring your own
216
+
217
+ Room templates are a section of the game config, read from the published JSON Schema on **2026-08-09**
218
+ and re-read on **2026-08-11** — which is a later reading than the gem-api source date at the top of
219
+ this page, and a different source. The schema endpoints are **public**: no credential, so anything
220
+ below can be checked in a browser.
221
+
222
+ `GET /v1/schemas/game-config/v1/schema.json` declares `game_room_templates` as one of the config's
223
+ top-level sections.
224
+
225
+ **The `v1` in that path is not the document's identity.** There is a
226
+ `GET /v1/schemas/game-config/versions`, and it does not answer the question worth asking: every
227
+ backend reports `v1` as both the only version and the latest one, and goes on reporting exactly that
228
+ while the document behind the path is replaced by a deploy. So a consumer that pins to `v1` has
229
+ pinned to a path and not to a document — it validates against whatever the backend it last fetched
230
+ from is serving today, and finds out when a field it relies on is not there.
231
+
232
+ Two things identify the document, and both travel on the document you fetched:
233
+
234
+ - **`$id`** is stamped from the origin that served it. Fetch from the API origin your game actually
235
+ talks to and compare the two: a `$id` naming anywhere else means what you are holding is not what
236
+ that backend validates your config against.
237
+ - **`ETag`**, alongside `Cache-Control`, with `If-None-Match` answered by a `304` and no body. That is
238
+ the staleness check — keep the ETag beside the copy you vendored, send it back, and a `304` means
239
+ your copy is still live. It is per document, so it changes when the document changes, which is the
240
+ thing a version number stamped once does not do.
241
+
242
+ **`match_type` accepts `dedicated`, `p2p` or `none`** (default `dedicated`), so a P2P match is a
243
+ template with `match_type: p2p`.
244
+
245
+ A template is config, so it ships the way config ships — no new build, versioned, rollback-able:
246
+
247
+ ```bash
248
+ gemctl config schema # the authoritative field list
249
+ gemctl config init # scaffolds a config from it
250
+ gemctl config upload --wait # registers a version; validation is ASYNC
251
+ gemctl config promote <version> --channel <channel> # this is what makes it live
252
+ ```
253
+
254
+ **Uploading is not shipping.** `upload` registers a version with `validation_status: pending`; the
255
+ version exists and is not usable, and a channel cannot point at one that has not validated — hence
256
+ `--wait`. Until a `promote` puts it on the channel your game is running, the slug resolves against the
257
+ globals only, and `quickJoin` on your own slug is a 400. That matches the resolution rule at the top of
258
+ this section: your game's config **for that channel** first.
259
+
260
+ Five things worth knowing before you write one, none of which the field list makes obvious:
261
+
262
+ - 🔧 **Declaring a slug that already exists as a global shadows it for your game.** Resolution prefers
263
+ a config template over a global one sharing its slug, so `slug: standard` replaces the platform
264
+ default _for this game_ rather than colliding with it. That is the intended way to override a
265
+ built-in — and it means `quickJoin({ templateSlug: 'standard' })` in a game that later declares its
266
+ own `standard` silently starts resolving somewhere else, with no client change.
267
+ - **`slug` is NOT required, and a template without one is unreachable from a game.** Only `id`, `name`
268
+ and `game_room_type` are required. Every client call names a template by `templateSlug`, and the
269
+ shadowing rule above is keyed on the slug, so a template authored with the required fields alone
270
+ exists and can never be selected.
271
+ - **`id` is required and is yours to generate** — a UUID. 🔧 The schema asks that it not be the
272
+ all-zero UUID reserved for the globals, but declares no constraint that enforces it; the rejection,
273
+ if any, is server-side.
274
+ - **`game_room_type` is `^[A-Za-z0-9 ]+$`, max 64** — alphanumerics and spaces only, unlike `slug`. A
275
+ `team-deathmatch` copied across from the slug fails validation. It is also what the stale-membership
276
+ guard keys on; see §2.
277
+ - **`max_players` runs 2–10000 (default 20); `min_players` floors at 1.** There is no one-seat _room_,
278
+ but solo play is supported and intended — the schema's own note on `min_players` is "set to 1 to
279
+ allow solo play and solo testing", so `max_players: 2, min_players: 1` is the solo shape. Note that
280
+ `min_players` only bites when `auto_start_host` is set; a manual `startMatch()` can already start
281
+ with one member in the room, per §1.
282
+
283
+ ⚠️ **Four fields the schema accepts will not do what they say.** `auto_close_when_empty`,
284
+ `game_room_ttl_seconds` and `idle_timeout_seconds` are carried and never read;
285
+ `auto_leave_on_client_shutdown` _is_ read, but only on a route a browser game cannot reach. All of it
286
+ is in §2 above. The schema describes all four as if live. Authoring them is not an error; expecting
287
+ them to do something is.
288
+
289
+ > Do not re-derive the field list from this page. `gemctl config schema` is versioned and
290
+ > authoritative, and a list copied into prose goes stale where nobody is looking. The per-field facts
291
+ > above are the ones the field list does not tell you.
292
+
293
+ ## 6. The demos that already exist
294
+
295
+ Both of these are working code in this repository. Neither was linked from anywhere until this page
296
+ existed, which is how integrators end up rebuilding them.
297
+
298
+ - **[`examples/e2e/`](../examples/e2e/README.md) — the bridge, end to end, in a real browser.** A real
299
+ parent page and a real game frame, served from two different origins, exchanging a real
300
+ `MessagePort`. `pnpm e2e` builds the artifacts and serves it. This is the thing to run when a
301
+ handshake is not landing and you need to know whether the problem is the SDK or your page:
302
+ `?sandbox=strict` reproduces the sandbox failure on demand.
303
+ - **[`examples/server-bundle/echo.mjs`](../examples/server-bundle/echo.mjs) — a complete dedicated
304
+ server for a match, in one file.** The lifecycle a bundle implements and the first message on the
305
+ channel, running. Its contract is [`server-bundles.md`](server-bundles.md).
306
+
307
+ ## 7. A dedicated match does not run forever, and neither does its server
308
+
309
+ Read on **2026-08-13**, which is later than the date at the top of this page and covers this rule
310
+ only.
311
+
312
+ Nothing in the client surface says how long a match may last, so the assumption people arrive with is
313
+ that it lasts until somebody ends it. It does not. **Every dedicated match has a maximum duration**,
314
+ declared per room template in your published config — 30 minutes to 48 hours, and **six hours if the
315
+ template declares nothing**. Reaching it ends the match the way your own server ending it would, so
316
+ the members get the `match_ended` push they already handle and the room reopens.
317
+
318
+ That is a backstop rather than a schedule: a game that ends its own matches never meets it. The one
319
+ that does is the match nobody ended, which is common enough to be worth the rule — a finished match
320
+ whose last tab stayed open looks, from the server, exactly like a player taking their time.
321
+
322
+ The **server's** half of this is separate and easier to get wrong, because the budget it gets to wind
323
+ down in depends on why it is stopping, and the two budgets are nothing like each other — minutes in
324
+ one case, hours in the other. Both, and the reason a bundle never sees the signal that starts the
325
+ longer one, are in [`server-bundles.md`](server-bundles.md#how-long-your-server-lives).
326
+
327
+ ## 8. A player's Boost is for display, never a gate
328
+
329
+ Decided by the platform on **2026-09-26**, and a contract rather than an implementation fact.
330
+
331
+ The **Boost** is the only thing the Arcade sells for real money. While a player has one, they earn
332
+ **Gems and Tickets** faster — every grant is multiplied — and their earning limits and holdings cap
333
+ are raised by the same tier. It **never** touches achievement points. The platform applies all of
334
+ it on its own side, when it pays: a game does nothing to make a boost work, and cannot make one
335
+ work harder.
336
+
337
+ A game **may show** it. `gem.player.boost` carries each boostable currency's multiplier and the
338
+ whole days left, so a reward screen can say "2× Tickets"; see
339
+ [what a game must do](what-a-game-must-do.md#the-signed-in-players-boost-show-it-never-gate-on-it).
340
+ A dedicated server can read the same effective boost for a player in its match, and the same rule
341
+ holds there.
342
+
343
+ A game **must not gate** anything on it:
344
+
345
+ - **No content or power behind it.** No level, item, character, mode, stat, difficulty setting or
346
+ extra attempt may depend on whether the player is boosted. A boosted and an unboosted player play
347
+ the same game.
348
+ - **No prices that depend on it**, and no discount or surcharge keyed on boost status.
349
+ - **No matchmaking on it.** Never exclude, separate or wait on a player because of their boost.
350
+ - **No nagging.** No "you're not boosted" loops, interstitials or countdown pressure. The Arcade owns
351
+ the Boost store; a game does not sell or advertise boosts.
352
+
353
+ Three reasons, any one of which would be enough. It is **advisory**: the browser value is pushed on
354
+ connect and on change, so it can lag a boost that just started or ended, and the platform's own
355
+ figure is what pays. It is **forgeable** inside your page, so a gate on it is a gate anyone can
356
+ open. And gating on it turns a faster faucet into pay-to-win, which is the line the Arcade's economy
357
+ is drawn on.
358
+
359
+ It carries **a day count, never an instant — and capped at 7**, so `7` means "7 or more" — and
360
+ nothing about the tier, the price or whether the player paid at all: free and paid boosts look
361
+ identical to a game. A second-precision expiry would be a near-unique value per player that two
362
+ games could join on, and an exact day count would jump by a purchase's term the moment the player
363
+ paid. Show `7` as "7+ days", never as an end date.
364
+
365
+ ## Where the rest of it is
366
+
367
+ - [`what-a-game-must-do.md`](what-a-game-must-do.md) — what the SDK does not do for you, and the boot
368
+ sequence that decides whether the handshake lands at all.
369
+ - [`rooms-and-matches.md`](rooms-and-matches.md) — the SDK's room and match model, and why it is
370
+ shaped like the engine's.
371
+ - [`dedicated-server-flow.md`](dedicated-server-flow.md) and [`p2p-and-parties.md`](p2p-and-parties.md)
372
+ — getting onto a server, and getting onto a peer.
@@ -0,0 +1,89 @@
1
+ # Asking the player to sign in
2
+
3
+ Some of what your game offers is only worth something to a player with an account — progress that
4
+ follows them to another device, a name on a leaderboard, friends. When a player reaches for one of
5
+ those, your game can ask the Arcade to sign them in. The Arcade shows sign-in in its own chrome, the
6
+ player decides, and the Arcade tells you how it went.
7
+
8
+ ```js
9
+ signInButton.onclick = async () => {
10
+ await saveProgress(); // see section 1
11
+ const { outcome } = await gem.account.requestSignIn();
12
+ if (outcome === 'signed_in') {
13
+ await reloadProgress(); // see section 2
14
+ hideUpsell();
15
+ }
16
+ // `declined` and `unavailable`: carry on, and say nothing.
17
+ };
18
+ ```
19
+
20
+ That is the whole surface. Your game never draws a sign-in form and never handles a password; it
21
+ could not be trusted with either, and it does not need to be.
22
+
23
+ ## 1. Save before you ask
24
+
25
+ The Arcade may sign the player in **by taking the whole page to its sign-in screen**. When it does:
26
+
27
+ - your game is unloaded, and `requestSignIn` never settles — there is nothing left to settle it;
28
+ - `onWillTerminate` fires first, with a short grace period, exactly as it does for every other
29
+ teardown;
30
+ - after signing in, the player lands back in your game, which starts again from the top under
31
+ their account.
32
+
33
+ So anything the player would mind losing has to be saved **before** the call. The example above
34
+ awaits its save first for that reason. Do not rely on the code after the `await` running at all.
35
+
36
+ ## 2. On `signed_in`, the player may have changed
37
+
38
+ When the Arcade signs the player in without unloading your game, the promise resolves `signed_in` —
39
+ and by then `gem.identity`, `gem.player` and `gem.me` already answer for the account. The SDK has
40
+ also dropped everything it cached for whoever was playing before.
41
+
42
+ Your own variables are yours to refresh. A player who signs in to an account they already had is a
43
+ different player from the one your game loaded for, with their own saves and their own inventory.
44
+ So on `signed_in`:
45
+
46
+ - re-read `gem.identity` rather than keeping an id you read earlier;
47
+ - reload what you loaded from `gem.me` — saves, settings, inventory, currencies;
48
+ - leave and re-join any room you were holding a seat in under the old identity.
49
+
50
+ `signed_in` is also the answer when the player was signed in all along. Nothing is shown to them,
51
+ and nothing has changed; hide the upsell.
52
+
53
+ ## 3. The three answers, and nothing rejects
54
+
55
+ | outcome | what happened | what your game does |
56
+ |---|---|---|
57
+ | `signed_in` | the player is signed in | re-read, then hide the upsell |
58
+ | `declined` | the player closed the prompt, or the Arcade chose not to show one | return to the game silently |
59
+ | `unavailable` | the Arcade does not offer this here, or never answered | return to the game silently |
60
+
61
+ `requestSignIn` always resolves when it resolves at all. There is no error branch and no
62
+ `try`/`catch` to write.
63
+
64
+ - **Say nothing on `declined`.** The player saw the prompt and closed it. A "sign-in failed"
65
+ message after a deliberate Cancel reads as a bug.
66
+ - **You are told no reason**, and one will not be added. Why a sign-in did not happen is between
67
+ the player and the Arcade.
68
+ - **`unavailable` is ordinary**, not a fault. It is what every call returns outside the Arcade, and
69
+ anywhere the Arcade has no sign-in to offer. Never retry it on a timer; only a fresh press from
70
+ the player may ask again.
71
+
72
+ ## 4. Ask from a button, and only from a button
73
+
74
+ Call `requestSignIn` from something the player pressed — never at start-up, never on a timer, and
75
+ never as the price of continuing to play. The Arcade decides whether a prompt appears at all, shows
76
+ one at a time, and is free to answer `declined` without showing anything to a game that asks too
77
+ often.
78
+
79
+ Pressing two upsell buttons does not open two prompts. A second call made while the first is still
80
+ open joins it, and both resolve with the same answer.
81
+
82
+ ## 5. You pass nothing, deliberately
83
+
84
+ There is no message to customise and no address to return to. The words on a sign-in screen are
85
+ the Arcade's, so a player can trust them whichever game they came from; and the Arcade already
86
+ knows which game to bring the player back to.
87
+
88
+ Say what the account is *for* in your own game, on your own button — "Sign in to keep this run" —
89
+ before the player presses it.
@@ -0,0 +1,195 @@
1
+ # Room shapes — the four things a game room is for
2
+
3
+ A game room is one mechanism with four useful shapes. The same browser surface (`gem.rooms` to get
4
+ in, `gem.match.connect()` to reach the authority) and the same server entry point (`defineServer`)
5
+ serve all four; what differs is the room template and what the hosting server chooses to do with
6
+ its time.
7
+
8
+ Only three of the four need a second player. The fourth is for a game that has no multiplayer at
9
+ all and still needs something a browser cannot be trusted to do.
10
+
11
+ This page is the decision. [rooms-and-matches.md](rooms-and-matches.md) is the mechanism behind
12
+ it — the room state machine, the event stream, room args, reconnection — and is what to read once
13
+ you know which shape you want.
14
+
15
+ ## Pick a shape
16
+
17
+ Answer in this order. The first yes wins.
18
+
19
+ 1. **Does the server need to run a match at all**, or only answer requests from clients who are
20
+ not playing each other? Only answer requests → **authoritative worker**.
21
+ 2. **Does one player decide when a round starts, and with what settings?** → **custom**.
22
+ 3. **Do players arrive and leave continuously, with the world outliving any one of them?** →
23
+ **hub**.
24
+ 4. Otherwise a lobby fills and plays one match → **match**.
25
+
26
+ ## The four
27
+
28
+ ### Match
29
+
30
+ The ordinary shape. Players are matchmade into a room, the room fills, a match starts, the match
31
+ ends. One lobby, one match — what most competitive and co-op games want.
32
+
33
+ The join that brings membership to `min_players` starts the match, so nobody has to press a
34
+ button labelled "go". **A match is sized by who is in the room the instant it starts**, and
35
+ nothing tops it up afterwards; if you want late joiners, that is `allow_join_in_progress`, and it
36
+ is a genuinely different game to write — the server has to place a player mid-match and hand them
37
+ current state rather than a start-of-match snapshot.
38
+
39
+ ### Hub
40
+
41
+ A shared world that outlives any one player. Members arrive and leave continuously and the world
42
+ does not restart around them: a persistent zone, a town, a social lobby players visit between
43
+ games.
44
+
45
+ A hub is **served** or **unserved**, and `match_type` decides which:
46
+
47
+ - `dedicated` with `allow_join_in_progress: true` — a server runs the world, players see each
48
+ other move, the server is the authority.
49
+ - `none` — the room starts no match at all. It is a place to be rather than a world to simulate:
50
+ somewhere players gather, see a roster, agree on something through room args, and leave for a
51
+ real match. No allocation, no server bundle, no duration ceiling.
52
+
53
+ Reach for `none` when nothing needs simulating. A lobby that needs a member list and a settings
54
+ object does not need a server, and a served hub costs one.
55
+
56
+ ### Authoritative worker
57
+
58
+ A room whose server **runs no match**. It accepts no player input, replicates no world state,
59
+ simulates nothing. It answers requests from members who are not playing each other, and each
60
+ member's session is unrelated to every other member's.
61
+
62
+ This is how a browser game reaches an execution context a modified client cannot rewrite. Use it
63
+ when something must be decided on your behalf rather than the player's, and the platform has no
64
+ built-in rule for it:
65
+
66
+ - Confirming an offline, single-player run really was finished, and to the score claimed.
67
+ - Granting an award whose condition is game state — level reached, boss defeated, run completed
68
+ under a time — that only your game code can evaluate.
69
+ - Checking a score before it counts.
70
+
71
+ **A game with no multiplayer can need this shape.** The room is not a lobby; it is a way to get
72
+ your own code running somewhere the player does not control.
73
+
74
+ If a check out of the player's reach is all you want the room for, a serverless function is the
75
+ smaller tool: no room, no seat and no connection, one call at a time. See
76
+ [serverless-functions.md](serverless-functions.md).
77
+
78
+ Two things decide whether you need it at all. See
79
+ [what a worker owes you, and what it does not](#what-a-worker-owes-you-and-what-it-does-not).
80
+
81
+ ### Custom
82
+
83
+ A room one player owns, where a group gathers and plays round after round. The owner picks the
84
+ settings, members see what was picked, the owner starts. When the match ends everyone comes back
85
+ to the room and does it again, possibly with different settings.
86
+
87
+ The room outlives each match, which is what separates it from a match room. Players arrive by
88
+ join code or, if the room is public, from the room browser. What the owner may choose is declared
89
+ by the template's room-args schema, so a settings menu should render from that schema rather than
90
+ from a list hard-coded in your client — then the config can add a mode without a new build.
91
+
92
+ ## The settings that produce each
93
+
94
+ | | Match | Hub | Worker | Custom |
95
+ | ------------------------ | ----------------- | --------------------- | ----------- | --------------------- |
96
+ | `match_type` | `dedicated`/`p2p` | `dedicated` or `none` | `dedicated` | `dedicated`/`p2p` |
97
+ | `auto_start_host` | `true` | `true` | `true` | **`false`** |
98
+ | `min_players` | the match size | `1` | `1` | `1` |
99
+ | `allow_join_in_progress` | for backfill | **`true`** | **`true`** | `false` |
100
+ | `show_in_browser` | `false` | `true` | `false` | when public |
101
+ | `transports` | the ladder | the ladder | the ladder | the ladder |
102
+ | How a player gets in | `quickJoin` | `quickJoin` | `quickJoin` | `create`/`joinByCode` |
103
+ | What starts the match | `min_players` | `min_players` | first join | the owner |
104
+
105
+ Templates live in your game config under `game_room_templates`, and are uploaded and promoted per
106
+ channel like the rest of it. `transports` is the same on every shape because it is orthogonal to
107
+ shape: the list of wires a match may be played over, most preferred first — `webtransport`,
108
+ `webrtc`, `websocket` — which the SDK dials from and the host offers from, so no game names a
109
+ transport in code. Leave it out for the platform ladder (all three, in that order); a `p2p`
110
+ template may name only `webrtc`. `dedicated-server-flow.md` has the whole story. One template field is deliberately absent from the table:
111
+ `auto_close_when_empty` is carried and returned, and read by nothing — an empty room is deleted
112
+ whichever way it is set ([platform-rules.md](platform-rules.md#2-room-lifecycle-when-one-goes-away-and-what-a-leftover-does-to-you)).
113
+
114
+ Two entries in that table do most of the work. **`auto_start_host: false` is what makes a custom
115
+ room** — with it true, the room starts a match the moment membership reaches `min_players` and the
116
+ owner never gets to choose anything. **`allow_join_in_progress` is what makes a hub** — without
117
+ it, a player who quick-joins while a match runs starts a _new_ room, and your shared world
118
+ quietly becomes one world per player.
119
+
120
+ ## What the platform decides for you
121
+
122
+ Four limits bind every shape, and three of the four shapes exist partly to work around one of
123
+ them.
124
+
125
+ **Seats belong to the template, not to the room.** A create request carrying its own
126
+ `max_players` is refused rather than having the field ignored. The template's count is not
127
+ confined to a lobby-sized number. What _is_ bounded 2–64 is resizing a room that already exists,
128
+ and that resize is refused below the room's current member count. Choose a served room's seats
129
+ from what one process can carry.
130
+
131
+ **A match has a maximum duration.** `max_match_duration_seconds` defaults to 21600 — six hours —
132
+ after which the platform ends the match the way any end happens: members get the ordinary
133
+ `match_ended` push and the server is released. **Nothing here runs forever.** A hub or a worker is
134
+ recycled, not permanent, and has to be written that way: durable state belongs in player save
135
+ data or your own store, written as it changes rather than at shutdown, and the client re-enters
136
+ on the room's `matchEnded` event (reason `expired`, `roomReopened: true`) rather than treating it
137
+ as a crash. Since the SDK's 4.0.0 release (2026-09-10) re-entering is a start, not a join: the
138
+ room is open again with the same members, `auto_start_host` fires only when a room first reaches
139
+ `min_players` and does not fire again for a reopened one, so the member `gem.rooms.isHost` names
140
+ calls `gem.rooms.startMatch()` and every member then receives `matchAllocationStarted` and
141
+ connects as before. Leaving and quick-joining again is not the re-entry — the player is still a
142
+ member of the reopened room. [match-session.md](match-session.md) §5 is the sequence.
143
+
144
+ **Concurrent matches are capped per player.** Starting one allocates real infrastructure. The cap
145
+ counts only that player's own matches — nothing another player does can refuse you — and it clears
146
+ when one of yours ends. A shape that gives every player a private room spends this budget and one
147
+ server per player.
148
+
149
+ **Starting a match is idempotent.** Repeated starts on the same room return the same match,
150
+ behind a shared fence, so an `auto_start_host` template and a client that also calls start cannot
151
+ race into two matches.
152
+
153
+ ## What a worker owes you, and what it does not
154
+
155
+ The worker shape is the one most easily got wrong, in both directions.
156
+
157
+ **It is not the answer to economy arithmetic.** Spending a currency and granting an item is a
158
+ **recipe**, and a recipe is already evaluated on the platform's side where a modified client
159
+ cannot change the arithmetic — no server of yours, no allocation, no seat. See
160
+ [inventory-and-catalogs.md](inventory-and-catalogs.md). A worker room is for validating game state
161
+ the platform knows nothing about. A worker that grants _after_ validating is doing both: decide
162
+ here, then let the grant itself be a recipe or an order.
163
+
164
+ **Its server must check its own roster.** A server bundle's credential is scoped to your game and
165
+ channel, not to its match, and the room roster is not a boundary the platform enforces on it. A
166
+ bundle can therefore act on any player of your game, including one who never joined its room.
167
+ That is needed by an ordinary match server writing results — and it means a worker that skips its
168
+ own membership check is an open grant endpoint wearing a room as decoration. Read the roster and
169
+ check the requesting player against it before acting; not against an id the client sent.
170
+
171
+ **A result against a daily's board is checked against the platform's copy, never the client's.**
172
+ When your studio stores each period's content with the challenge, the worker reads it with
173
+ `gem.challenges.list({ playerId })` — for the player who submitted, whose period is their own, for
174
+ the current period and, with `periodsBack: 1`, the one before, so a run finished just before the
175
+ rollover still verifies — and validates against the entry whose `periodLocalDate` (a cron
176
+ challenge's `seed`) and `dataHash` match what the client submitted, never its `periodStartAt`. No
177
+ match is a rejection. [daily-challenges.md](daily-challenges.md#verifying-a-result-against-the-content) has
178
+ the recipe.
179
+
180
+ **It should hold nothing between joins.** Each join is an unrelated session. A returning player
181
+ may be in a completely different state than when they left, and the process may have served
182
+ hundreds of others in between.
183
+
184
+ ## Where to go next
185
+
186
+ - [rooms-and-matches.md](rooms-and-matches.md) — the browser half in full: the room state
187
+ machine, the event stream, room args, reconnection, and what closing a tab does to a membership.
188
+ - [match-session.md](match-session.md) — the session after the start: its phases, why a match
189
+ ends, the reconnect policy, and how the next round starts in the same room.
190
+ - [dedicated-server-flow.md](dedicated-server-flow.md) — how a match gets a server, and what the
191
+ waits mean.
192
+ - [server-bundles.md](server-bundles.md) — writing and publishing the server half.
193
+ - [p2p-and-parties.md](p2p-and-parties.md) — the shapes that run without a server of ours.
194
+ - [inventory-and-catalogs.md](inventory-and-catalogs.md) — what a server calls once it has
195
+ authority.