@indiegems/gem-web-sdk 11.1.2 → 13.0.0

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.
@@ -0,0 +1,303 @@
1
+ # Arcade avatars: draw the 3D character a player wears, and sell your own
2
+
3
+ Read this if your game draws the player as a character and you want that character to be the one
4
+ they wear across the Arcade — or you want to sell characters of your own. A game that draws no
5
+ player character needs none of it.
6
+
7
+ **This page is about 3D avatars only.** The Arcade also has 2D portraits — the picture beside a
8
+ player's name — and they are a separate system: wearing a 3D avatar never changes the portrait,
9
+ and nothing here reads or changes one. Portraits are in
10
+ [what-a-game-must-do.md](what-a-game-must-do.md#showing-a-player-one-identity-on-every-surface).
11
+
12
+ ## 1. What a 3D avatar is
13
+
14
+ A 3D avatar is an Arcade item: a binary glTF model (`.glb`) built on a **rig family**, plus a
15
+ rendered portrait of it. A player owns as many as they like and **wears one per family**. What
16
+ they wear follows them into every game that accepts that family, and changes when they pick
17
+ another in the Arcade's avatar picker or buy one.
18
+
19
+ **The platform's item is the only copy that counts.** Your game reads the worn avatar's model URL
20
+ at runtime and loads it — there is no shared avatar library to install and no copy to bundle. A
21
+ model in your build is a model that drifts from what the player owns, and an avatar another game
22
+ sells could never be in your build at all. Ship your own **default look** for a player who wears
23
+ nothing, and nothing else.
24
+
25
+ Everything your game calls is on [`gem.avatars3d`](api/api-reference-game.md#gem-avatars3d).
26
+
27
+ ## 2. Rig families, and the `small_human` rig
28
+
29
+ A family is a skeleton contract: the node names and parent-child shape every model in it has, so a
30
+ game that animates the family animates every avatar in it. The first family is `small_human`:
31
+
32
+ ```
33
+ root
34
+ └── body
35
+ ├── head
36
+ ├── arm_l
37
+ ├── arm_r
38
+ │ └── hand_socket
39
+ ├── leg_l
40
+ └── leg_r
41
+ ```
42
+
43
+ The platform checks every `small_human` model when it is uploaded and refuses one that breaks any
44
+ of these:
45
+
46
+ - **The names are exact and case-sensitive**, and each of the eight appears **exactly once**.
47
+ `root` is a root node of the scene, `body` is its child, `head`, `arm_l`, `arm_r`, `leg_l` and
48
+ `leg_r` are children of `body`, and `hand_socket` is a child of `arm_r`. A held item goes on
49
+ `hand_socket`.
50
+ - **Anything else is allowed**: extra nodes anywhere, meshes, materials, skins and animation
51
+ clips. A rig is a floor, not a ceiling.
52
+ - **Nothing outside the file.** Buffers are embedded, and an image is embedded or a `data:` URI.
53
+ An external URI is refused.
54
+ - **No compression extension.** Draco and meshopt are refused. The extensions a model may
55
+ _require_ are `KHR_materials_unlit`, `KHR_texture_transform` and `KHR_mesh_quantization`.
56
+ - **At most 2 MiB** for the `.glb`.
57
+ - **A portrait**: a still, square PNG, 256 to 1024 pixels, at most 2 MiB — what shop tiles and
58
+ rosters show where loading the model is too much.
59
+
60
+ | What you get | What happened | What to do |
61
+ | ----------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
62
+ | `rig_mismatch` | a node is missing, under the wrong parent, or named twice | read `details.missing`, `misplaced` and `duplicated`, rename in your modelling tool, upload again |
63
+ | `model_external_reference` | the file points at a buffer or image outside it | export as a single binary `.glb` with textures embedded |
64
+ | `model_extension_unsupported` | the file requires Draco, meshopt or another extension | export without compression |
65
+ | `unknown_avatar_family` | the family name is not one the platform knows | use `small_human` |
66
+
67
+ ## 3. Declare the families your game accepts
68
+
69
+ A game accepts no family until its config says so. Add the section to
70
+ `gem-backend-game-config.yaml`:
71
+
72
+ ```yaml
73
+ avatars_3d:
74
+ families: [small_human]
75
+ ```
76
+
77
+ Then upload the config and point each channel at it:
78
+
79
+ ```sh
80
+ gemctl config upload --wait
81
+ gemctl config promote <version> --channel dev
82
+ gemctl config promote <version> --channel test
83
+ gemctl config promote <version> --channel release --yes
84
+ ```
85
+
86
+ - **The order is meaningful.** The first family is your default: it is the one the flat
87
+ `gem.player.avatar3dId` fields describe. Up to eight families, no repeats.
88
+ - **Each channel reads its own config.** A channel whose config declares no families accepts
89
+ none: the Arcade shows no 3D picker in your game there, and nothing is for sale.
90
+ - **Read it back** with `gem.avatars3d.families()`, which resolves the declared list from the
91
+ config the channel serves — `[]` when there is none.
92
+
93
+ ## 4. Upload an avatar
94
+
95
+ ```sh
96
+ gemctl item upload red-knight.glb --family small_human --portrait red-knight.png \
97
+ --slug red-knight --name "Red Knight" --description "Sworn to the hearth."
98
+ ```
99
+
100
+ A `.glb` file makes an avatar. gemctl checks the rig, the size and the external references on
101
+ your machine before it uploads, so a broken model costs no upload; the platform checks again and
102
+ renders the item, and the command waits until it is `ready`.
103
+
104
+ - **An avatar cannot be changed after upload.** A fixed model is a new item with a new slug — the
105
+ old one stays owned by everyone who has it.
106
+ - **It counts toward your game's items**, which are capped at 200 per game.
107
+
108
+ ## 5. Sell it
109
+
110
+ An avatar is for sale only when you put it on sale, priced in Tickets, Gems or both:
111
+
112
+ ```sh
113
+ gemctl item sell red-knight --tickets 50 --gems 300
114
+ gemctl item retire red-knight
115
+ ```
116
+
117
+ - **Prices are whole numbers from 1 to 1,000,000**, one or two of them. A player picks which
118
+ currency to pay in when you list both.
119
+ - **At most 30 avatars are on sale at once.** Selling a 31st is refused with
120
+ `avatar_3d_sale_limit_reached`. Retiring one frees its slot immediately.
121
+ - **Retiring never takes an avatar away.** A player who bought it keeps it, keeps wearing it, and
122
+ it keeps loading in your game. Retired means only that nobody new can buy it.
123
+ - **On sale means on sale on every channel** where your game declares the avatar's family.
124
+
125
+ ### Award it as a prize
126
+
127
+ An avatar can also be **won** — an event prize, a first-run gift, the end of a journey. A won avatar
128
+ is a real Arcade avatar: the player owns it and can wear it in every game that accepts its family.
129
+ First mark it awardable, then have your server award it:
130
+
131
+ ```sh
132
+ gemctl item awardable red-knight # may be awarded
133
+ gemctl item awardable red-knight --off # stop awarding; winners keep it
134
+ ```
135
+
136
+ ```http
137
+ POST /v1/games/{game_id}/avatars-3d/{item_id}/award
138
+ { "player_id": "<uuid>", "wear": false }
139
+ → { "item_id", "player_id", "family", "newly_awarded", "worn" }
140
+ ```
141
+
142
+ - **Only your server awards.** The call takes your game's server credential, or a serverless
143
+ function's for the player who called it. A browser never awards, and a server you started with a
144
+ developer credential is refused on `release` (it works on `dev` and `test`, so you can test).
145
+ - **It works on every channel** and is written once per player and avatar: awarding it again answers
146
+ `newly_awarded: false` and changes nothing, so retrying is safe.
147
+ - **At most 30 avatars are awardable at once**, counted apart from the 30 on sale; the 31st is
148
+ refused with `avatar_3d_award_limit_reached`. An avatar may be both on sale and awardable.
149
+ - **`wear: true` puts it on** for the player, as buying does.
150
+ - Don't award an avatar through an achievement reward: achievements unlock only on `release` and
151
+ share the 2D icon budget. Unlock the achievement as usual, and award the avatar from the same
152
+ server code.
153
+
154
+ ## 6. The purchase flow
155
+
156
+ Your game draws the shop; the Arcade takes the money. Read the offers from the platform, show
157
+ them, and call `purchase` from the player's own click:
158
+
159
+ ```js
160
+ const offers = await gem.avatars3d.offers(); // what is on sale, priced by the platform
161
+
162
+ for (const offer of offers) {
163
+ const tile = shop.addTile(offer.displayName, platformIconUrl(offer.iconUrl));
164
+ for (const { currencySlug, price } of offer.prices) {
165
+ tile.addButton(`${price} ${currencySlug === 'GEMS' ? 'Gems' : 'Tickets'}`, async () => {
166
+ const result = await gem.avatars3d.purchase({ itemId: offer.itemId, currencySlug });
167
+ if (result.outcome === 'declined' && result.reason === 'already_owned') {
168
+ gem.openAvatarPicker({ tab: '3d' }); // they have it: let them put it on
169
+ } else if (result.outcome === 'unavailable') {
170
+ shop.showMessage('The store is unavailable right now.');
171
+ }
172
+ // `ok`: nothing to do here — onChange already brought the new look.
173
+ });
174
+ }
175
+ }
176
+ ```
177
+
178
+ - **Render prices from `offers()`, never from numbers of your own.** The Arcade prices the sheet
179
+ again from the platform's record, so a price you typed is a price that disagrees with it.
180
+ - **Call `purchase` from a click or key press.** The Arcade shows its confirmation sheet only on
181
+ one; anything else is answered `declined` with reason `busy`.
182
+ - **A bought avatar is worn at once.** The Arcade wears it in its family and sends the player
183
+ again **before** it answers, so `onChange` runs and `worn()` already shows it when `purchase`
184
+ resolves `ok`.
185
+ - **`ok` is checked.** The SDK asks the platform whether the player now owns the avatar before it
186
+ tells you `ok`; an answer it cannot confirm becomes `unavailable`.
187
+
188
+ Every outcome resolves; nothing rejects.
189
+
190
+ | What you get | What happened | What to do |
191
+ | --------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------- |
192
+ | `ok` | bought, worn, and confirmed with the platform | nothing — `onChange` has redrawn |
193
+ | `declined` / `already_owned` | the player owns it already | offer `gem.openAvatarPicker({ tab: '3d' })` |
194
+ | `declined` / `cancelled` | the player closed the sheet | return to your shop silently |
195
+ | `declined` / `insufficient_funds` | the player cannot afford it | return silently; never draw a top-up |
196
+ | `declined` / `not_purchasable` | not on sale, retired, or not an id from `offers()` | read `offers()` again |
197
+ | `declined` / `busy` | no click, another sheet open, or a purchase in flight | let the player press again |
198
+ | `unavailable` | no reliable answer | one neutral message; read `worn()` again; never retry on your own |
199
+
200
+ **Outside the Arcade, `purchase` asks nobody.** Opened outside any Arcade, before the bridge is
201
+ up, or under an Arcade that does not sell 3D avatars, it resolves `unavailable` with reason
202
+ `error` at once — the same as `gem.purchases.request` does there. `offers()` still reads the
203
+ platform with your game's token, so a shop can show what is on sale.
204
+
205
+ **Purchases are real on every channel.** On `dev` and `test` a purchase spends the player's real
206
+ Gems or Tickets and grants a real avatar they keep, exactly as on `release`. Test with your
207
+ organization's test accounts.
208
+
209
+ ## 7. Read the worn avatar, and load it at runtime
210
+
211
+ The SDK is the package [getting-started.md](getting-started.md#3-add-the-sdk) installs; three.js
212
+ is your own dependency.
213
+
214
+ ```js
215
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
216
+ import { platformIconUrl } from '@indiegems/gem-web-sdk/sdk';
217
+
218
+ const loader = new GLTFLoader();
219
+ let shown = null; // the model URL on screen now
220
+
221
+ async function wear() {
222
+ const worn = gem.avatars3d.worn('small_human'); // or worn(): your first declared family
223
+ const url = worn && platformIconUrl(worn.modelUrl);
224
+ if (!url) return hero.useDefaultLook(); // nothing worn, or a host the platform does not own
225
+ if (url === shown) return;
226
+ shown = url;
227
+ try {
228
+ const gltf = await loader.loadAsync(url);
229
+ if (shown === url) hero.setCharacter(gltf.scene, gltf.animations);
230
+ } catch {
231
+ if (shown === url) hero.useDefaultLook(); // a model that fails is not a reason to show nothing
232
+ }
233
+ }
234
+
235
+ hero.useDefaultLook(); // never hold the boot for a model
236
+ wear();
237
+ gem.avatars3d.onChange(wear);
238
+ ```
239
+
240
+ - **`worn()` is read live** from the player the Arcade pushed. It is `undefined` before the player
241
+ arrives, outside the Arcade, and when nothing is worn in that family — draw your default look.
242
+ - **`onChange` runs whenever what is worn changes**: the player picked another avatar in the
243
+ Arcade's picker — from your own "Change avatar" button (`gem.openAvatarPicker({ tab: '3d' })`)
244
+ or the Arcade's menu — bought one, or their avatars arrived for the first time. It does not run
245
+ for the avatar already worn when you subscribe; read `worn()` for that.
246
+ - **Pass every URL through `platformIconUrl`** before you load it. Loading a model is a request
247
+ that carries your player's `Referer`, so only the platform's own item host is allowed; anything
248
+ else reads as `undefined`. Against a development backend that includes its own item host, so
249
+ there you see your default look.
250
+ - **A model URL never changes what it serves**, so cache by URL. When one fails to load, show your
251
+ default look and do not retry in a loop; the next `onChange` is the next chance.
252
+ - **Attach and animate by the rig's names.** Look nodes up by name, never by index:
253
+
254
+ ```js
255
+ function setCharacter(scene, clips) {
256
+ const node = (name) => scene.getObjectByName(name);
257
+ node('hand_socket').add(sword); // a held item goes on the socket
258
+ if (clips.length > 0) {
259
+ mixer = new THREE.AnimationMixer(scene); // the model's own clips, when it has them
260
+ mixer.clipAction(clips[0]).play();
261
+ } else {
262
+ walk = (t) => {
263
+ // otherwise, swing the limbs yourself
264
+ node('arm_l').rotation.x = Math.sin(t * 6) * 0.6;
265
+ node('arm_r').rotation.x = -Math.sin(t * 6) * 0.6;
266
+ node('leg_l').rotation.x = -Math.sin(t * 6) * 0.5;
267
+ node('leg_r').rotation.x = Math.sin(t * 6) * 0.5;
268
+ };
269
+ }
270
+ }
271
+ ```
272
+
273
+ Clip names are the model maker's, not a contract — play one only by a name you checked for, or
274
+ animate the named nodes, which every model in the family has.
275
+
276
+ **Other players' avatars** come the same way on every surface that shows a player: a room member,
277
+ a leaderboard row, a friend. Each carries `avatars3d` — one per family — and the flat
278
+ `avatar3dId`, `avatar3dModelUrl` and `avatar3dIconUrl` of your first family. A server reads
279
+ `avatars_3d` and `avatar_3d_id` from the room read; nothing tells a server a player changed, so
280
+ when `onChange` fires, have your client tell your server and have the server **re-read the room**
281
+ — never trust an id the client sends.
282
+
283
+ ## 8. Moving from 2D items, achievements and the `arcade-avatars` tag
284
+
285
+ Games that drew characters before 3D avatars existed did it with 2D portrait items granted by
286
+ achievements, a bundled copy of each character's model keyed on `avatarIconId`, and the store tag
287
+ `arcade-avatars`. Move to 3D avatars like this:
288
+
289
+ 1. **Upload one `small_human` avatar per old character** with `gemctl item upload`, as in
290
+ section 4.
291
+ 2. **Ask the platform team to map each old 2D item to its new avatar.** The platform then grants
292
+ every player who owns the 2D item the matching 3D avatar, and wears it for a player whose 3D
293
+ slot is empty and who wears that portrait. Players keep their 2D portraits.
294
+ 3. **Declare `avatars_3d: { families: [small_human] }` on every channel** (section 3), and **remove
295
+ the achievements and progressions that granted the 2D items**.
296
+ 4. **Put the avatars on sale** with `gemctl item sell`, and **make the ones players win
297
+ awardable** and award them from your server (section 5).
298
+ 5. **Ship a build that reads `gem.avatars3d.worn()` and loads the model at runtime** (section 7),
299
+ and delete the bundled character models and the `avatarIconId` mapping.
300
+ 6. **Remove the `arcade-avatars` tag from your store page.** It is deprecated. For a game whose
301
+ config declares no families the Arcade still reads the tag as accepting `small_human`, which
302
+ is what keeps an unmoved game working while you switch; do not rely on it once you have — a
303
+ declared family is what the Arcade reads.
package/docs/changelog.md CHANGED
@@ -16,6 +16,43 @@ Its entries are grouped under up to five headings:
16
16
  - **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
17
17
  - **New capability**: something a game can now do. Nothing changes unless you use it.
18
18
 
19
+ ## 13.0.0
20
+
21
+ Upgrading from 12.0.0. Impact: required.
22
+
23
+ ### Must change
24
+
25
+ - The controls declaration is gone. Remove every call that declares controls, along with any code that builds a controls declaration or a control choice, because these no longer exist and the game will not compile against them. Follow the updated settings guidance for how the game now exposes settings and controls.
26
+ - If you embed the game in your own host, remove the controls-declare callback from the host options and callbacks, because the host no longer receives that message and the property is no longer accepted.
27
+ - setMenu no longer takes a settings flag as its first argument. Pass only the optional list of menu items. Also remove any onSettings handler from the game menu, since that handler is gone.
28
+
29
+ ### Rebuild
30
+
31
+ - The purchase refusal code gains a new value, already_owned, for purchases of something the player already has. Recompile; if the game switches over refusal codes exhaustively, add a branch for it.
32
+ - The list of messages a game frame sends to its parent, the frame message type, and the capability names each gain an entry for 3D avatar purchases, including the new avatar3dPurchases capability. Recompiling is enough unless the game or host switches over these values exhaustively.
33
+
34
+ ### New capability
35
+
36
+ - A game can now work with 3D avatars. The new avatars3d API lists the avatar rig families the game supports, reports the avatar a player wears per family, lists avatar offers and their prices, starts a purchase, and notifies the game when the worn avatar changes. Purchases are also available through the frame, gated by the avatar3dPurchases capability, and are answered like other purchases. Outside the Arcade, avatar purchases are unavailable immediately.
37
+ - The worn 3D avatar, one per rig family, is now available on the current player and on other players' data, including friends, leaderboard entries, profiles, showcase friends, player identities and room members. Use it to render other players with the avatar they wear.
38
+ - The game configuration can now declare the 3D avatar families the game supports through avatar_3d_families.
39
+ - If you embed games in your own host, you can now supply worn 3D avatars when sending player information, handle 3D avatar purchase requests with the new request callback, and validate those requests with the new validator and message type.
40
+ - Server-side code can now read the worn 3D avatars in avatars_3d on player and subject identity data, using the new worn avatar type.
41
+
42
+ ## 12.0.0
43
+
44
+ Upgrading from 11.1.2. Impact: required.
45
+
46
+ ### Rebuild
47
+
48
+ - The list of capability names gained ticketEvents. Recompile your game. If your code switches exhaustively over capability names, add a case for the new one.
49
+ - The list of messages the parent sends to the frame, and the union of parent message types, each gained a ticket event message. Recompile your game. If your code switches exhaustively over these message names or types, handle or ignore the new one.
50
+
51
+ ### New capability
52
+
53
+ - A party member's game can now show the matchmaking ticket its party is queued on. Read the ticket through the rooms manager, or listen for the new partyQueued room event. The view is read-only: it has a status, a party size, two instants and an outcome, and it cannot be cancelled. A matched ticket does not carry the room. The room is reported separately once the SDK has confirmed the player is seated in it. The view ends on its own as lost once the ticket's lifetime has passed with no ending heard. For the player who queued, the ticket still comes from the platform's own answer.
54
+ - Games that build their own frame can now pass an onTicketEvent callback to receive ticket state changes pushed by the parent. It is delivered only when the parent relays ticket events and advertises the new ticketEvents capability. Under a parent that does not, nothing changes and the callback is never called. Ticket events name no player and do not carry the templates a ticket named.
55
+
19
56
  ## 11.1.2
20
57
 
21
58
  Upgrading from 11.1.1. Impact: none.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "11.1.2"
53
+ "@indiegems/gem-web-sdk": "13.0.0"
54
54
  }
55
55
  ```
56
56
 
@@ -66,7 +66,10 @@ is involved, and nothing is built when you install.
66
66
  **`/sdk` is the import, from every seat.** It carries the whole surface, so client code, a
67
67
  dedicated server bundle and a listen host all import the same path and the same names.
68
68
  `.`, `/game` and `/server` stay published as narrow doors over the same modules — a game
69
- wants none of them unless it knows why.
69
+ wants none of them unless it knows why. `/function` is the fourth door, for code that runs as
70
+ a serverless function ([serverless-functions-js.md](serverless-functions-js.md)). The function
71
+ starter's build resolves `/sdk` and `/game` to it, so verification code shared with your game
72
+ keeps its import.
70
73
 
71
74
  **`dependencies`, not `devDependencies`.** The SDK is part of what your game ships — your
72
75
  bundler compiles it into the browser build and into the server bundle — so a `--production`
@@ -16,6 +16,11 @@
16
16
  "sha256": "57fb07c3d649c2758a35660707d31b05164b363e9a425156978aed394d086339",
17
17
  "updated": "2026-10-02T14:04:37Z"
18
18
  },
19
+ {
20
+ "file": "arcade-avatars.md",
21
+ "sha256": "355bba7936ec7f0a65d52ec65a6add323c75a992d931de9cd23319ed9d8aeb52",
22
+ "updated": "2026-10-14T06:27:58Z"
23
+ },
19
24
  {
20
25
  "file": "arcade-economy.md",
21
26
  "sha256": "9058c26bc11266f235dfa3c72c80a3fc962aa9f204ef491006e6d34545f5eeeb",
@@ -33,8 +38,8 @@
33
38
  },
34
39
  {
35
40
  "file": "changelog.md",
36
- "sha256": "f88799ff04404b0ad76c5774375a66772c9ed7a64b59ca566f473f1302c9b2d7",
37
- "updated": "2026-10-12T17:30:48Z"
41
+ "sha256": "914fb511d6f15af9ccf0d71e2309dcaf35369d4e4272233927ab7e48b47921a3",
42
+ "updated": "2026-10-14T06:33:54Z"
38
43
  },
39
44
  {
40
45
  "file": "cross-promotion.md",
@@ -68,8 +73,8 @@
68
73
  },
69
74
  {
70
75
  "file": "getting-started.md",
71
- "sha256": "4a824bccf87d75c2884a7159b44c768b39096f815b80fa7b5018da2862add89e",
72
- "updated": "2026-10-12T17:30:48Z"
76
+ "sha256": "6aef9e454064492b2164417e1b31050e0f487c48edab07d5b58b5df9bb089bff",
77
+ "updated": "2026-10-14T06:33:54Z"
73
78
  },
74
79
  {
75
80
  "file": "inventory-and-catalogs.md",
@@ -113,28 +118,28 @@
113
118
  },
114
119
  {
115
120
  "file": "rooms-and-matches.md",
116
- "sha256": "971e67d9cb74a046cddc0ff76cdea64f1aef3af8e9376944388ca8aa87730729",
117
- "updated": "2026-10-12T02:26:43Z"
121
+ "sha256": "29cf42a4334d3d9702e6a7f6e04c52fb2b414af3340043c2f98b2bfafa7dc64d",
122
+ "updated": "2026-10-12T19:59:08Z"
118
123
  },
119
124
  {
120
125
  "file": "saving-player-progress.md",
121
- "sha256": "08778cccc7d7a9a26a005a070bb2cc98784752ebec9dfce9873ef3d4e674e1e0",
122
- "updated": "2026-10-05T16:53:25Z"
126
+ "sha256": "11ab0684247cfc43d6d8faba6dbb897192354af5f5622b16fd96abcb156ca0cd",
127
+ "updated": "2026-10-12T21:21:05Z"
123
128
  },
124
129
  {
125
130
  "file": "server-bundles.md",
126
- "sha256": "f1ff81f3ee1950dae0d8b0fab66994eeec99d6e6bf1684b121c742f29d63e0b9",
127
- "updated": "2026-10-12T17:30:48Z"
131
+ "sha256": "8b19574001cefea73b1892dd864d360dc0e5dfd252e9d291f7ccb08a170b48d9",
132
+ "updated": "2026-10-14T06:33:54Z"
128
133
  },
129
134
  {
130
135
  "file": "serverless-functions-js.md",
131
- "sha256": "56588d50d5a59f066164565470663f8f9da03fe85d98a96d3754e6ec14eb9fac",
132
- "updated": "2026-10-10T00:12:33Z"
136
+ "sha256": "3904704418f9c6c246c8e1fa83f96901f6e493a3f4499b16fd6845620515e57d",
137
+ "updated": "2026-10-12T17:39:05Z"
133
138
  },
134
139
  {
135
140
  "file": "serverless-functions.md",
136
- "sha256": "b1562b4442b69bb6895198def7b00ccd7a8c56133862a939c2cce9b6e9d30290",
137
- "updated": "2026-10-07T19:27:16Z"
141
+ "sha256": "84881bcc40c6203c731ebf9092be575158d0ad3b3a65120321b695ae6558f63f",
142
+ "updated": "2026-10-12T17:39:05Z"
138
143
  },
139
144
  {
140
145
  "file": "trading-and-gifting.md",
@@ -148,8 +153,8 @@
148
153
  },
149
154
  {
150
155
  "file": "what-a-game-must-do.md",
151
- "sha256": "275ca0984cdeb9d52221df0700516f85d3b24d41457ee09e0e1326f6ed3c8112",
152
- "updated": "2026-10-12T16:40:39Z"
156
+ "sha256": "ca29417cbe0c337efe44c3d6f44f943ff27425fbb11e208faff1207d2b6f912c",
157
+ "updated": "2026-10-13T14:23:52Z"
153
158
  }
154
159
  ]
155
160
  }
@@ -140,6 +140,13 @@ frame that advertises `autoStartFailure`. It is not a sixth `gem.roomEvent` beca
140
140
  refuses an enum value it does not know, and reports that to the game as a protocol error; an unknown
141
141
  message type it ignores. The game sees it as `matchAutoStartFailed` (§4.4).
142
142
 
143
+ **The matchmaking-ticket pushes cross the same way**, as `gem.ticketEvent`, to a frame that
144
+ advertises `ticketEvents`. The platform tells every player a ticket seats when it is created,
145
+ matched, or ended without a match, and the parent relays each one for the frame's own game: the
146
+ ticket's id and new status, plus the party size and the two instants on creation, the reason on an
147
+ ending without a match, and the room to look in on a match. The templates the ticket named do not
148
+ cross, and no player is named on either side of the bridge. What a game does with it is §5c.
149
+
143
150
  There is exactly one filtering point and this is it. The engine keeps a full match event in native
144
151
  code and hands its scripting layer an allowlisted subset, because native code can hold a secret
145
152
  from a sandboxed script. **The frame-side SDK cannot** — it shares a JavaScript realm with the game,
@@ -794,11 +801,15 @@ every game.
794
801
  platform sends the push a moment before it marks the ticket. So on a page that relays, a match is
795
802
  noticed in about the time the push takes, not at the next heartbeat. A tab returning to the
796
803
  foreground is the same cue. Like every relay it is a hint: nothing is entered on its strength.
804
+ **A relayed ticket push is the same cue**, so an ending without a match is noticed as quickly as a
805
+ match is. The ticket's outcome still comes from the platform's answer, never from the push.
797
806
  - **It refuses a second thing in flight.** While a ticket waits, `create`, `join`, `joinByCode`,
798
807
  `quickJoin` and another `queueForMatch` are refused locally.
799
808
  - **It cancels the ticket when the tab closes.** See below.
800
809
  - **It raises no `seated` for the ticket's own room.** The owner is told by the outcome. Party
801
- members, who hold no ticket, are told by `seated` as they are for any room their leader enters.
810
+ members, who hold no ticket, are told by `seated` as they are for any room their leader enters —
811
+ and, on a page that relays ticket events, by `partyQueued` while the party waits. See
812
+ [A party member's view](#a-party-members-view-of-the-ticket).
802
813
 
803
814
  ### Queueing from inside a room
804
815
 
@@ -862,6 +873,58 @@ seat stands, so `myRooms()` shows the room and `join(id)` enters it.
862
873
  `cancel()` resolves the ticket's real outcome, and that may be `matched`. Backing out from there is
863
874
  `gem.rooms.leave()`.
864
875
 
876
+ ### A party member's view of the ticket
877
+
878
+ A party queues as **one ticket, and it is the leader's**. Only the leader calls `queueForMatch()`,
879
+ and only a ticket's owner can read it, keep it alive or cancel it. So a member's game holds no
880
+ ticket — and without help it knows nothing about the queue its player is standing in until the match
881
+ pulls them in.
882
+
883
+ On an arcade page that relays ticket events, the member's game is told. When the leader queues,
884
+ `gem.rooms.partyTicket` is set and `partyQueued` is raised with it:
885
+
886
+ ```js
887
+ gem.rooms.on('partyQueued', async (ticket) => {
888
+ showSearching({ expiresAt: ticket.expiresAt, canCancel: false });
889
+ const outcome = await ticket.outcome;
890
+ if (outcome.status !== 'matched') backToMenu(outcome.status);
891
+ // `matched`: the room arrives through your `seated` handler.
892
+ });
893
+ ```
894
+
895
+ - **It is a view. There is no `cancel()`.** The ticket is the leader's to cancel. A member who wants
896
+ out leaves the party, which ends the ticket for everyone with reason `party_changed`. Draw the
897
+ searching screen without a leave-queue button.
898
+ - **It costs no request.** Nothing is heartbeated and nothing is polled for it; everything the view
899
+ says came from a relayed push.
900
+ - **`matched` carries no room.** The room is reported the way a party's seat always is, the `seated`
901
+ event, and that handler is where a game joins it. The SDK looks for the seat as soon as it hears
902
+ `matched`, and reports it only after reading the room over the game's own token and finding this
903
+ player in it.
904
+ - **It ends by itself.** The pushes are sent once. A member's game that missed the ending would
905
+ otherwise show a searching screen for ever, so the view ends as `lost` a few seconds after the
906
+ ticket's lifetime has run out with nothing more heard. A seat found by the SDK's own seat check
907
+ ends it as `matched`, whether or not that push arrived.
908
+ - **Never set beside `currentTicket`.** The player who queued holds the ticket itself and gets no
909
+ `partyQueued`.
910
+
911
+ | `status` | `reason` | What happened |
912
+ | --- | --- | --- |
913
+ | `matched` | — | The party is seated and the match is starting. `seated` has the room. |
914
+ | `expired` | `timed_out` | Nobody to play with before the ticket's expiry. |
915
+ | `expired` | `heartbeat_lapsed` | The leader's game stopped keeping the ticket alive: a closed, throttled or offline tab. |
916
+ | `cancelled` | `requested` | The leader cancelled. |
917
+ | `cancelled` | `party_changed` | The party gained or lost a member while it waited. |
918
+ | `failed` | `handoff_failed` | A match formed and could not be started, several times over. |
919
+ | `lost` | — | The SDK's own conclusion: the ticket's lifetime ran out and no ending was heard. |
920
+
921
+ ⚠️ **Build it as an extra, never as a step.** The view exists only where the page relays ticket
922
+ events, and only for a game that was already running when the leader queued: a push sent before the
923
+ game was listening is not sent again. In every other case `partyTicket` stays `null`, no
924
+ `partyQueued` is raised, and the member is carried into the match by `seated` with no lead-up. That
925
+ path has to keep working, so the `seated` handler is what enters the match and the searching screen
926
+ is only something shown on the way.
927
+
865
928
  ### The refusals when queueing
866
929
 
867
930
  `queueForMatch()` itself rejects with `GemApiError` when no ticket was made. Branch on `code`:
@@ -363,13 +363,18 @@ often rather than to retry harder.
363
363
  ## For something small, reach for settings instead
364
364
 
365
365
  `gem.me.settings` needs no declaration and is one call: `put(key, value)` with a **string**
366
- value, up to **8 KiB** each and **32** keys per player per game. It is the right home for a
367
- chosen difficulty, a control scheme, a level counter.
366
+ value, up to **8 KiB** each and **32** keys per player per game. It is the right home for a small
367
+ value that follows the player, such as a level counter, and for the player's settings that do too.
368
+
369
+ **Which store a setting goes in** ([game settings](what-a-game-must-do.md#game-settings) has the
370
+ whole model): Device configuration — volume, graphics quality, look sensitivity, a touch-stick
371
+ style — goes in `gem.deviceSettings`; a choice that must follow the player across devices —
372
+ difficulty, key bindings, accessibility toggles the game owns — goes in `gem.me.settings`. Either
373
+ way, save only what the player chose, never a value your game started from `gem.prefs.recommended`.
368
374
 
369
375
  Reach for a save when the payload is a document rather than a value — a world, an inventory, a
370
- replay. Something that belongs to **this device** rather than to the player — a volume, a graphics
371
- quality — belongs in neither; that is the next section. Progress a player would be upset to lose
372
- belongs in a save, and nowhere else.
376
+ replay. Device configuration belongs in neither; that is the next section. Progress a player would
377
+ be upset to lose belongs in a save, and nowhere else.
373
378
 
374
379
  ### Device settings: local to this device, and only that
375
380
 
@@ -380,9 +385,10 @@ browser storage cannot do this: every build of a web game runs on its own origin
380
385
  game kept in `localStorage` is gone the moment you publish again. The Arcade's own storage outlives
381
386
  your builds; that is the whole mechanism.
382
387
 
383
- **What goes here:** a master volume and the music/SFX mix, a graphics quality or resolution scale,
384
- a chosen input layout, a tip the player dismissed, a "skip intro" toggle. Configuration that is
385
- about **this screen and these speakers**.
388
+ **What goes here:** device configuration — a master volume and the music/SFX mix, a graphics
389
+ quality or resolution scale, a look sensitivity, a touch-stick style — and a tip the player
390
+ dismissed or a "skip intro" toggle. Configuration that is about **this screen, these speakers and
391
+ this input**. Key bindings and a difficulty follow the player, so they go in `gem.me.settings`.
386
392
 
387
393
  ⚠️ **What never goes here: progression, unlocks, currency, anything a player would mind losing,
388
394
  and anything that must follow them to another device.** This is not a save and it is not a
@@ -433,8 +439,8 @@ game, and a key you experiment with on `dev` cannot break a shipped build.
433
439
  | You want | Where it goes | Follows the player? |
434
440
  | -------- | ------------- | ------------------- |
435
441
  | a world, an inventory, a replay | `gem.me.saves` — a declared slot | yes |
436
- | a difficulty, a control scheme, a level counter | `gem.me.settings` — a string per key | yes |
437
- | a volume, a quality preset, a dismissed tip | `gem.deviceSettings` — one object per device | **no** |
442
+ | a difficulty, key bindings, an accessibility toggle your game owns, a level counter | `gem.me.settings` — a string per key | yes |
443
+ | a volume, a graphics quality, a look sensitivity, a touch-stick style, a dismissed tip | `gem.deviceSettings` — one object per device | **no** |
438
444
 
439
445
  ## Starting over as a new player while you test
440
446
 
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v11.1.2/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v11.1.2/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v13.0.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v13.0.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87