@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.
- 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 +583 -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,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.
|