@indiegems/gem-web-sdk 7.3.1 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +54 -0
  2. package/dist/game.d.ts +156 -22
  3. package/dist/gem-web-sdk.bridge.js +1 -1
  4. package/dist/gem-web-sdk.esm.js +2 -2
  5. package/dist/gem-web-sdk.game.js +2 -2
  6. package/dist/gem-web-sdk.min.js +2 -2
  7. package/dist/gem-web-sdk.sdk.js +2 -2
  8. package/dist/gem-web-sdk.server.js +2 -2
  9. package/dist/index.d.ts +89 -6
  10. package/dist/sdk.d.ts +156 -22
  11. package/docs/achievements.md +232 -0
  12. package/docs/analytics.md +271 -0
  13. package/docs/api/api-reference-game.md +15308 -0
  14. package/docs/api/api-reference-server.md +4397 -0
  15. package/docs/api/reference.json +24130 -0
  16. package/docs/arcade-economy.md +263 -0
  17. package/docs/async-opponents.md +396 -0
  18. package/docs/brand-art.md +334 -0
  19. package/docs/cross-promotion.md +137 -0
  20. package/docs/daily-challenges.md +530 -0
  21. package/docs/dedicated-server-flow.md +879 -0
  22. package/docs/deprecation-policy.md +124 -0
  23. package/docs/friends.md +233 -0
  24. package/docs/game-economy-tutorial.md +326 -0
  25. package/docs/getting-started.md +552 -0
  26. package/docs/inventory-and-catalogs.md +1035 -0
  27. package/docs/leaderboards.md +277 -0
  28. package/docs/manifest.json +135 -0
  29. package/docs/match-session.md +288 -0
  30. package/docs/p2p-and-parties.md +783 -0
  31. package/docs/platform-rules.md +372 -0
  32. package/docs/player-sign-in.md +89 -0
  33. package/docs/room-shapes.md +195 -0
  34. package/docs/rooms-and-matches.md +779 -0
  35. package/docs/saving-player-progress.md +457 -0
  36. package/docs/server-bundles.md +1041 -0
  37. package/docs/serverless-functions.md +748 -0
  38. package/docs/trading-and-gifting.md +98 -0
  39. package/docs/what-a-game-must-do.md +1231 -0
  40. package/package.json +7 -2
  41. package/schema/bridge.v1.json +440 -7
  42. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,288 @@
1
+ # The match session — from the start to the next round
2
+
3
+ What your game sees between "the host pressed start" and "everyone is back in the lobby", and what
4
+ to show the player at each point. If your game calls `gem.match.connect()` at all, this is the
5
+ contract that call lives inside. A game with no matches — no `gem.rooms`, no server — has nothing
6
+ to do here.
7
+
8
+ Which SHAPE of room you want is decided on [room-shapes.md](room-shapes.md); how a room becomes a
9
+ match is [rooms-and-matches.md](rooms-and-matches.md). This page is the part after that: the
10
+ session's phases, the events that move it, why a match ends, how a dropped link comes back, and how
11
+ the next round starts.
12
+
13
+ ## 1. One session per player, derived from the platform
14
+
15
+ `gem.rooms.matchSession` is this player's view of the room's match: which match, what phase, and —
16
+ once it is over — why. Read it whenever you render. It is kept current from the room poll, the
17
+ platform's pushes and the transport, so it is right whether or not any push reached the tab.
18
+
19
+ ```
20
+ none ──startMatch──▶ starting ──server up──▶ ready ──link up──▶ connected
21
+ │
22
+ link dropped
23
+ ▼
24
+ ended ◀────── the platform ended the match ────────── suspended
25
+ │ │
26
+ └── roomReopened: the host starts again ──▶ starting └── link back ──▶ connected
27
+ ```
28
+
29
+ | `phase` | What it means | What to show |
30
+ | ----------- | ------------------------------------------------------------- | ------------------------------------------------------------ |
31
+ | `none` | the room has no match, and none has ended since you joined | the lobby |
32
+ | `starting` | a match exists and its server is being brought up | "starting" — render `matchStartWaiting` if it fires |
33
+ | `ready` | the match is up and this player holds a credential for it | a brief transition; `connect()` is about to resolve |
34
+ | `connected` | this player's link to the match is up | the game |
35
+ | `suspended` | the link dropped; the platform has not said the match is over | "reconnecting", with a way out that leaves the room |
36
+ | `ended` | the match ran and is over; `reason` says why | match over, or the lobby again when `roomReopened` is `true` |
37
+
38
+ **The union is open.** A later release may add a phase, so `switch` on the values you handle and
39
+ treat any phase you do not recognise as "a match is held" — never as `none`. The test to write is
40
+ `phase !== 'none' && phase !== 'ended'`; the one that goes wrong is an allowlist of the phases you
41
+ know, which reads a new phase as "no match" and lets the host allocate a second one over it.
42
+ `gem.rooms.matchConnectionState` is the coarse view (`disconnected`, `connecting`, `connected`,
43
+ `suspended`) and is fine for a spinner; branch on `matchSession.phase` when the distinction between
44
+ `ready` and `connected`, or between `none` and `ended`, matters.
45
+
46
+ After a match ends the session keeps naming the match that ended and why, until the next one
47
+ starts. A lobby that renders "last match: expired" from it is reading the right field.
48
+
49
+ ## 2. The events, and the one thing never to key on
50
+
51
+ Every transition below arrives on `gem.rooms.on(...)`. The allocation events are the ones a game
52
+ already handles; the session events are what this page adds.
53
+
54
+ | Event | Fires when | What to do |
55
+ | ------------------------ | -------------------------------------------------------------- | ----------------------------------------------------- |
56
+ | `matchAllocationStarted` | a server is being brought up — every member, not just the host | show "starting", then `serve()` and `connect()` |
57
+ | `matchStartWaiting` | the platform has no server free yet, inside the start budget | render the wait |
58
+ | `matchReady` | the match is up (retained: a late listener still gets it) | nothing if you use `connect()`; it resolves from this |
59
+ | `matchFailed` | the match never came up | offer another attempt — the room survives |
60
+ | `matchEnded` | the match ran and is over | match-over screen, or the lobby when `roomReopened` |
61
+ | `matchSuspended` | this player's link to a running match dropped | "reconnecting" — the SDK is already on it |
62
+ | `matchReconnecting` | an attempt is about to be made — which of how many | update the spinner |
63
+ | `matchReconnected` | the link is back | resync from the authority |
64
+ | `hostChanged` | the room has a new host | move the start button to whoever it names |
65
+
66
+ **`matchEnded` fires once per match, whether or not the platform's push arrived.** The SDK derives
67
+ it from the room and the match status it reads itself, so a phone that was locked when the match
68
+ ended still gets it on the next read. After it, `currentMatch` is `null` and `matchSession.phase`
69
+ is `ended`.
70
+
71
+ **Never decide "the match is over" from the connection's `onClose`.** It fires once, when the
72
+ connection is over, and its reason string cannot tell the platform ending the match from the link
73
+ dropping and the reconnect policy giving up. A game that tears down its match screen on `onClose`
74
+ tears it down on a drop the SDK was about to recover from; a game that reconnects on `onClose`
75
+ reconnects to a match that has ended. `matchEnded` is "over"; `matchSuspended` is "dropped". Wire
76
+ those two, and treat `onClose` as cleanup.
77
+
78
+ `connect({ onMatchEnded })` is the same fact as the `matchEnded` event, delivered on the connection
79
+ instead of the room — called once, with the platform's reason, immediately before the connection
80
+ closes, on every seat. Either is fine; the room event is the one that also reaches a game that has
81
+ not connected yet.
82
+
83
+ ## 3. Why a match ended
84
+
85
+ `matchEnded.reason` and `matchSession.reason` carry the platform's word for it. Match the ones you
86
+ handle and treat anything else as "the match is over":
87
+
88
+ | `reason` | What happened | What the player should see |
89
+ | ------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------- |
90
+ | `explicit` | the server, or the listen host, ended it — the ordinary end | your results screen |
91
+ | `expired` | the match ran for the template's whole maximum duration | the results you have; nothing is wrong |
92
+ | `stale_cleanup` | the server stopped reporting in and the platform reaped the match | "the match was lost"; offer another |
93
+ | `host_left` | the hosting player left a peer-to-peer match and nobody could take over | "the host left"; back to the lobby |
94
+ | `all_left` | everyone left | the lobby |
95
+ | `insufficient_players` | too few players remained for the match to continue | "not enough players"; the lobby |
96
+ | `game_room_closed` | the host closed the room | leave the room screen; `closed` follows |
97
+ | `game_room_gone` | the room was deleted from under the match | the same |
98
+ | `replaced` | the room started a new match while this one was held | nothing — the new match's events follow at once |
99
+ | `migration_failed` | a peer-to-peer host migration did not complete | "the match was lost"; offer another |
100
+ | `migration_rate_exceeded` | hosts changed too often for the platform to keep following | the same |
101
+ | `failed` | the server reported that it failed after the match had started | "the match was lost"; offer another |
102
+ | `unknown` | the platform no longer had the answer | the neutral "match over" |
103
+
104
+ The vocabulary is open: a later platform may add a word, and your `default` branch is what
105
+ handles it.
106
+
107
+ **Render your own copy, keyed by the reason. Never put a platform message into the page.** The
108
+ reason above is an enum you switch on. Other strings arrive beside it — `matchFailed`'s reason,
109
+ `matchSuspended.cause`, the message on an error — and those are diagnostics for your console and
110
+ your bug-report context, not text to hand to `innerHTML` or even `textContent` as if it were yours.
111
+ A player reads "the match was lost", not the server's own words for why.
112
+
113
+ ## 4. A dropped link comes back on its own
114
+
115
+ **Auto-reconnect is on by default** for every connection made through `gem.match.connect()`. When
116
+ the link drops and the platform has not said the match is over, the SDK raises `matchSuspended`
117
+ and then tries to get the link back itself **for ninety seconds**, the wait between attempts
118
+ doubling from half a second to a ceiling of eight, each attempt one status read and — if the match
119
+ is still up — one re-dial. Before the first attempt it reads the room and the match once, so a
120
+ link that dropped _because_ the match ended produces `matchEnded` and no attempt at all.
121
+
122
+ The bound is time, not a count. The policy used to be six attempts — about twenty-four seconds of
123
+ waiting, shorter than a real mobile outage — and that only went unnoticed while drops were noticed
124
+ late. A wait the server asks for (a rate-limited join) spends from the same ninety seconds; a
125
+ `{ attempts }` cap can be set on top, and `{ budgetMs }` changes the budget.
126
+
127
+ While the connection is `suspended`, `send` drops what you give it and `onMessage` stays attached.
128
+ `matchReconnecting` fires before each attempt with `attempt`, `attempts` (`Infinity` unless you
129
+ capped it — show the attempt or the time left, not "of N") and `remainingMs`, what is left of the
130
+ budget; `matchReconnected` fires when the link is back. **Resync from the authority on
131
+ `matchReconnected`.** Nothing sent while the link was down was delivered or replayed, and a
132
+ dedicated server sees the rejoin as a new `join`.
133
+
134
+ **Send from that handler.** The connection is back to `connected` before `matchReconnected` fires,
135
+ so the first message the new session needs — a hello your server rejects the client for missing, a
136
+ request for the current state — goes out from the handler itself. There is nothing to wait for and
137
+ no `state` to poll.
138
+
139
+ **How a drop is noticed** is the same on every transport. Each one has its own idea — QUIC reports
140
+ a lost connection at once, a WebSocket that lost its radio reads open until TCP gives up minutes
141
+ later, and a WebRTC connection on a phone was measured reporting nothing until connectivity came
142
+ _back_ — so the same outage used to show a player a reconnect notice at once, after twenty
143
+ seconds, or never, depending on which transport had won. Now the server writes a heartbeat every
144
+ ten seconds on whichever transport carries the link, and the SDK treats two intervals with nothing
145
+ delivered as a drop, closing the link itself and running the policy from there. You see nothing of
146
+ the heartbeats; they never reach `onMessage`. A server runtime from before this sends them on the
147
+ WebSocket rung only, and the other two keep their transport's own detection there.
148
+
149
+ **On a peer-to-peer match the re-dial is a second session at the same host**, not a wait for the
150
+ old one to heal. The stale signaling session is closed — the platform never sees this peer leave
151
+ the match, so the seat is still theirs — and a fresh offer goes out on the same DTLS certificate.
152
+ That is what brings back a drop which changed the player's network path, an airplane-mode gap or a
153
+ handover from Wi-Fi to cellular, where the WebRTC connection underneath can never recover on its
154
+ own. When a host migration is already in flight the attempt waits it out instead: the migration is
155
+ the way back, and it re-attaches this connection without a dial (§5.1 of
156
+ [p2p-and-parties.md](p2p-and-parties.md)).
157
+
158
+ The loop never runs while the page is hidden. It parks, and the next visible edge — the phone
159
+ unlocked, the tab foregrounded — is itself a read, so a match that ended while the player was away
160
+ resolves to `matchEnded` and one that is still running resumes reconnecting. Nothing in your game
161
+ has to notice that the page was hidden.
162
+
163
+ It stops on `matchEnded`, on your own `close()`, on `gem.rooms.reconnectToMatch()` (you took
164
+ over), and when the budget (or an attempt cap you set) runs out — the session then stays
165
+ `suspended` and the connection's `onClose` fires once, so a manual `connect()` remains possible. A
166
+ `matchSuspended` whose `cause` is `kicked` is not retried at all: the server closed the link on
167
+ purpose and said so.
168
+
169
+ **Turning it off.** `createGem({ autoReconnect: false })` — or `{ budgetMs: 0 }`, or
170
+ `{ attempts: 0 }` — and a connection then closes on a drop, `onClose` fires on every drop, and the
171
+ session still moves to `suspended`. The decision is then yours: `gem.match.connect()` again rejoins
172
+ the held match, and `gem.rooms.reconnectToMatch()` does the same at the room level.
173
+ `{ budgetMs, attempts, maxDelayMs }` tunes the policy without removing it.
174
+
175
+ **What the policy does not cover.** A game that dials its own transport from `matchReady`, or uses
176
+ `gem.dedicatedServer.connect()` directly, reconnects on its own. It tells the session where it is
177
+ with three calls: `gem.rooms.notifyMatchConnected(matchId)` when its link is up,
178
+ `gem.rooms.notifyMatchDisconnected(cause)` when it drops — which raises `matchSuspended` and lets
179
+ the rest of the room see this player as disconnected — and `gem.rooms.notifyLeavingMatch()` when
180
+ the player leaves on purpose. Reconnecting is then `gem.rooms.reconnectToMatch()`.
181
+
182
+ **The cancel button.** A "reconnecting" screen needs a way out, and the way out is leaving the
183
+ room — `gem.rooms.leave()` — not closing the connection, which leaves the player in a room whose
184
+ match they have abandoned.
185
+
186
+ 🔴 **Await it, and do not send the player anywhere until it resolves.** This button is pressed at
187
+ the one moment the network is known to be unwell, so the leave is the call most likely to fail on
188
+ the whole surface. A game that fires it and immediately navigates, reloads or returns to its menu
189
+ leaves the membership standing, and the next launch drops the player back into the match they just
190
+ walked out of — which is how this was found, on a released build. `leave()` rejects when it fails;
191
+ show the failure and keep the player on the screen. `leaveAll()` rejects too, including when it
192
+ cannot list the player's rooms at all, which is the shape offline takes.
193
+
194
+ ## 5. Play again
195
+
196
+ When `matchEnded.roomReopened` is `true` the room is open again and holds the same members. The
197
+ next round is one call:
198
+
199
+ - **The host calls `gem.rooms.startMatch()` again.** `gem.match.serve()` followed by
200
+ `gem.match.connect()` does the same thing on the host's seat — with the session `ended`,
201
+ `connect()` allocates the next match rather than handing back the one that finished.
202
+ - **Members do nothing to start it.** Every member receives `matchAllocationStarted` for the new
203
+ match, and then serves and connects exactly as they did the first time.
204
+
205
+ **A template's `auto_start_host` starts the first match, not the second.** It fires when the room
206
+ reaches `min_players`; a reopened room already has them, and nothing fires again. A shape whose
207
+ first match started itself — a hub, a worker, a matchmade lobby — still needs a client to call
208
+ `startMatch()` for the next one, and that client is whoever `gem.rooms.isHost` says. Watch
209
+ `hostChanged`: if the host closed their tab during the match, the room has a new host, and the
210
+ start belongs to them now.
211
+
212
+ **Room args unlock when the match ends.** They are write-locked for the match; the host changes
213
+ them for the next round with `gem.rooms.update({ roomArgs })` after `matchEnded` and before the
214
+ next `startMatch()`.
215
+
216
+ When `roomReopened` is `false` the room went away with the match — the host closed it, or the
217
+ platform deleted it — and `closed` follows. There is nothing to start again; the lobby you return
218
+ to is a new room.
219
+
220
+ Starting a match is bounded per player: an owner who starts round after round while earlier
221
+ matches are still held open on the platform meets `429 too_many_concurrent_matches`. A match your
222
+ own server ends with `gem.match.end()` is released at once; one nobody ends is released when its
223
+ maximum duration runs out.
224
+
225
+ ## 6. Ending a match, from the server and from the listen host
226
+
227
+ **A dedicated server ends its match with `gem.match.end()`.** The platform reopens the room and
228
+ pushes `matchEnded` to every member with reason `explicit`; then your bundle's `shutdown` runs with
229
+ `info.reason` `match_ended` and `info.endedReason` `explicit`. The platform's own ends — the
230
+ duration limit, everyone leaving, a stale server — arrive at your bundle the same way, with the
231
+ reason they carry on the client. When `info.reason` is `idle_timeout`, nobody was connected for the
232
+ idle window and the platform ends the match itself, so the room reopens without your help. When it
233
+ is `signal`, the platform is reclaiming
234
+ the machine while the match plays out — **do not end the match there**; your players are still in
235
+ it.
236
+
237
+ ```js
238
+ async shutdown(info) {
239
+ await saveResults();
240
+ if (info?.reason !== 'signal') await gem.match.end();
241
+ }
242
+ ```
243
+
244
+ `info` is absent on older platform images, so default before use. The reasons are an open set:
245
+ one you do not recognise means "the match is over".
246
+
247
+ **A listen host ends its match with the handle `gem.match.serve()` returned.** When `mode` is
248
+ `listen`, `hosting.end()` tells the platform first — which ends the match for everyone, reopens
249
+ the room and pushes `matchEnded` — and then runs the bundle's `shutdown` with `match_ended` /
250
+ `explicit`. `hosting.leave()` leaves without ending: the platform is told so it can seat another
251
+ host, and the bundle's `shutdown` runs with `signal`. The bare `stop()` on that handle is
252
+ deprecated because it tells the platform nothing, so a host that meant to end the match starts a
253
+ migration instead. Both calls are refused on a `dedicated` or `peer` seat — a server the platform
254
+ runs ends its own match, and a peer is not the authority.
255
+
256
+ A listen host's bundle also hears two reasons a dedicated one never does: `host_migrated` (the
257
+ platform moved the match to another host, and this seat no longer speaks for it) and `stopped`
258
+ (the hosting game called the deprecated `stop()`).
259
+
260
+ ## 7. Backgrounding, a locked phone, a closed lid
261
+
262
+ Nothing here needs game code. The room manager listens for the page coming back — a visibility
263
+ change, a focus, a page restored from the back-forward cache, a network that returned — and re-reads
264
+ the room and the match on each. A match that ended while the phone was in a pocket raises
265
+ `matchEnded` on that read; one still running resumes the reconnect loop that parked when the page
266
+ went hidden. `gem.rooms.reconcile()` is the same read on demand, coalesced, for a moment your game
267
+ knows about that the browser did not announce.
268
+
269
+ What the SDK does not do is guess that a hidden tab is a disconnected player: alt-tabbing to read a
270
+ wiki keeps a live match live. Only the transport says a link dropped.
271
+
272
+ The two messages the arcade pushes about the page — `onPause` on both edges, and `onWillTerminate`
273
+ with its grace budget — are the game's to honour and are on
274
+ [what-a-game-must-do.md](what-a-game-must-do.md#what-the-parent-says-about-the-session-prefs-pause-and-terminate).
275
+ Neither ends a match.
276
+
277
+ ## 8. When it goes wrong
278
+
279
+ | What you see | What it is |
280
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
281
+ | Players stay on the game screen after the server ended the match | nothing is listening for `matchEnded` (or `onMatchEnded`); the game is keyed on `onClose`, which fires after teardown |
282
+ | "Reconnecting" never clears | the attempts ran out — `onClose` fired and the session is `suspended`; offer "try again" (`connect()` again) or leave |
283
+ | The second round never starts, though the host pressed start | the button is wired to a member, or to the host the room _had_ — follow `hostChanged`; `auto_start_host` does not re-fire |
284
+ | The host closed their tab and everyone froze | peer-to-peer: a migration is in progress, or `matchEnded` with `host_left` is coming; render `matchSuspended` meanwhile |
285
+ | The game reconnected but the world is wrong | no resync on `matchReconnected`; nothing sent while suspended was delivered |
286
+ | The lobby shows the last match as still running | the lobby renders `currentMatch` from a cache; read `matchSession` at render time |
287
+ | A server pod outlives its match | the bundle never calls `gem.match.end()`; the duration limit ends it later with `expired` |
288
+ | `matchEnded` arrives twice for one room | two matches: the first ended `replaced` when the host started again over a held session |