@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,1035 @@
1
+ # Inventory and catalogs
2
+
3
+ Games do not have to have an economy. A game with no currency, no items and nothing to buy is a
4
+ complete game. Read this if yours has any of them — a coin count, a bag of items, a shop, a loot
5
+ box, a battle pass.
6
+
7
+ If it does, the platform holds it, the same way it holds a save. Balances and items live on the
8
+ server and are reachable with the token your frame already holds, so they survive a device change
9
+ and a browser wipe, and a modified client cannot invent them — with one large exception this
10
+ document is careful to state, in [§14](#14-there-is-no-client-side-integrity-boundary).
11
+
12
+ ## 1. Two halves, and they happen at different times
13
+
14
+ This is the thing to get straight first, because — as with save slots — only one half is code:
15
+
16
+ | | Who | When |
17
+ | ----------------------------------------------------------------------------------- | ------------------------ | ------------------------------ |
18
+ | **Authoring definitions** — the currencies, items, shops and loot tables that exist | you, the developer | once, in your game config |
19
+ | **Reading and changing what a player holds** | your game's running code | every session, once per player |
20
+
21
+ A currency, an item type, a shop, a loot table — these are **definitions**. You publish them in
22
+ your game config, the same pipeline that ships save slots and room templates: versioned, no new
23
+ build, rollback-able. What a specific player owns is an **instance** of a definition, and that is
24
+ what your game reads and changes at runtime.
25
+
26
+ Game code cannot create a definition, and this is the wall almost everyone hits first: a read or
27
+ a grant that names an id you never published resolves to nothing, at the first call and every
28
+ retry. So this document starts with the config, not the API.
29
+
30
+ ```bash
31
+ gemctl config schema # the authoritative field list
32
+ gemctl config upload --wait # registers a version; validation is ASYNC
33
+ gemctl config promote <version> --channel <channel> # this is what makes it live
34
+ ```
35
+
36
+ **Uploading is not shipping.** Until `gemctl config promote` puts the version on the channel your
37
+ game runs, the definitions do not exist for that channel — see
38
+ [`saving-player-progress.md`](saving-player-progress.md#1-declare-the-slot-in-your-game-config),
39
+ which is the same rule with a save slot in place of a currency.
40
+
41
+ ## 2. Definition versus instance
42
+
43
+ Hold the distinction, because the whole surface is built on it.
44
+
45
+ - A **currency definition** is "coins exist, minimum 0, maximum a million." A player's **balance**
46
+ is an instance: "this player has 250 coins."
47
+ - An **item definition** is "a healing potion, stackable to 99." An **item instance** is "this
48
+ player holds 3 of them," or, for a non-stacking item, "this specific sword, rolled with these
49
+ properties."
50
+ - A **catalog** is a shop: a set of purchasable **recipes**. A player buying from it produces an
51
+ **order** — a record that the change happened.
52
+ - A **loot table** is a weighted pool. A recipe that references it produces one **roll** per draw.
53
+
54
+ You read definitions from the published config. You read instances from the player. They are
55
+ different calls returning different shapes, and conflating them is how a bag renders raw ids
56
+ because it never loaded the definitions that name them.
57
+
58
+ ## 3. The player, and their characters
59
+
60
+ Everything a player owns is reached through `gem.me`, the signed-in player:
61
+
62
+ ```js
63
+ await gem.me.currencies.get('coins');
64
+ await gem.me.inventory.snapshot();
65
+ ```
66
+
67
+ If your game gives a player several characters, each character carries the **same accessors**,
68
+ scoped to that character:
69
+
70
+ ```js
71
+ const hero = await gem.characters.get(characterId);
72
+ await hero.currencies.get('coins'); // the hero's coins, not the account's
73
+ ```
74
+
75
+ Code written against `gem.me` works unchanged against a character, because it is one interface on
76
+ both. Characters are declared and managed exactly as
77
+ [`saving-player-progress.md`](saving-player-progress.md) describes for saves; there is nothing
78
+ economy-specific about them, and this section is all you need here.
79
+
80
+ ## 4. Reading the config, and the sync/async cliff
81
+
82
+ Load the published config once, then read from it synchronously. The load is a round trip; the
83
+ lookups are memory:
84
+
85
+ ```js
86
+ const config = await gem.gameConfig.load(); // a round trip; do it once
87
+ const potion = config.item('healing-potion'); // synchronous, from memory, by id or slug
88
+ const coin = config.currency('coins'); // synchronous
89
+ ```
90
+
91
+ Two things that will bite:
92
+
93
+ - **`load()` resolving `null` is a real state, not an error** — twice over. A game with no config
94
+ at all resolves `null`, and so does a game whose config version has not finished building. A
95
+ game with no economy should not have to catch anything, so `null` is a value you handle, not an
96
+ exception you wrap.
97
+ - **Do not remember the field names.** Read them from `gemctl config schema` and from what
98
+ `load()` returns, never from a list you typed once. A curated field list drifts silently: a
99
+ real one once read a `fungible` flag under an invented `is_fungible`, a `stack_size` under an
100
+ invented `max_stack_size`, and rendered an `icon_url` on the game's own config currencies that
101
+ has never existed on the wire (the PLATFORM currency catalogue does carry one — see
102
+ [brand-art.md](brand-art.md)). None of those raises. All of them just make a game quietly wrong. The schema is the
103
+ authority precisely because it cannot go stale against itself.
104
+
105
+ ## 5. Reading what a player holds
106
+
107
+ One call returns everything the player owns — balances, items and progressions — in a single
108
+ snapshot:
109
+
110
+ ```js
111
+ const snap = await gem.me.inventory.snapshot();
112
+ snap.currencies.data; // balances
113
+ snap.items.data; // item instances
114
+ snap.progressions.data; // progression state
115
+ ```
116
+
117
+ The snapshot is **cached**. Repeat calls, and reads derived from it — `gem.me.currencies`,
118
+ `gem.me.progressions`, item-instance lookups — serve from the one cached read rather than issuing
119
+ a request each, so a HUD polling a balance does not re-download the whole bag. Any change the SDK
120
+ makes drops the cache; [§6](#6-when-to-re-read) is about the changes it cannot see.
121
+
122
+ Each of the three sections carries its **own cursor**, because any one of them can be large
123
+ independently, and the snapshot carries an **`etag`** — the inventory's version — which
124
+ [§11](#11-conditional-writes-and-why-most-writes-do-not-want-them) uses to make a write
125
+ conditional.
126
+
127
+ ## 6. When to re-read
128
+
129
+ **There is no push into a sandboxed frame.** A gift arriving from another player, a grant from
130
+ your dedicated server, a change made on the player's other device — none of these produces an
131
+ event in your game, because the frame has no socket to hear about them. A change your own game
132
+ made through the SDK drops the cache; nothing else does.
133
+
134
+ So the SDK's change event fires for your own writes and no others. Build on that and a port from
135
+ a native engine — which _did_ push external changes — is wrong in exactly the places the engine
136
+ was right.
137
+
138
+ The remedy is not a poll. **Do not poll**: a fixed-period re-read is a per-session load on the
139
+ backend that scales with your player count and buys staleness you could have designed around.
140
+ Instead:
141
+
142
+ - Re-read on screen-open. When the tab becomes visible, drop the cache and read again:
143
+
144
+ ```js
145
+ gem.me.inventory.refresh(); // drop what is cached
146
+ await gem.me.inventory.snapshot(); // re-fetch
147
+ ```
148
+
149
+ Add a little randomness to when you fire it so a whole cohort resuming at once does not hit the
150
+ backend in a spike.
151
+
152
+ - Re-read after any action that could involve another player — a trade, a match result.
153
+ - Treat the server's answer at the moment you _change_ something as the authority. A shop that
154
+ re-checks affordability when the player clicks Buy, and lets the purchase itself be the final
155
+ word, never needs the balance to be live between clicks.
156
+
157
+ Design the UI so staleness is harmless, and you will not want a poll.
158
+
159
+ ## 7. Currencies
160
+
161
+ A balance read is a lookup, not a list to iterate, because **a currency the player holds none of
162
+ is absent, not present as zero**:
163
+
164
+ ```js
165
+ const coins = await gem.me.currencies.get('coins');
166
+ coins.total; // a number, or null when the player has no record of this currency
167
+ ```
168
+
169
+ **`null` is not `0`.** Reach for `total ?? 0` and you have decided that "has never held any" and
170
+ "holds none" are the same thing — and one of them may mean your game does not define that currency
171
+ at all. If you want to show a missing balance as zero, do that where it renders, so the decision
172
+ is visible.
173
+
174
+ **Buckets** split a balance by how it was acquired — a daily allowance, a play-earned pool, a
175
+ real-money pool. They are off by default and asked for per read:
176
+
177
+ ```js
178
+ const c = await gem.me.currencies.get('coins', { buckets: true });
179
+ c.buckets; // [{ bucket: 'earned', balance: 200 }, { bucket: 'purchased', balance: 50 }], or absent
180
+ ```
181
+
182
+ They are off by default because they are a **disclosure**, not a cost: a bucket names whether
183
+ value came from a real-money purchase versus play, so ask for them only on the screen that shows
184
+ the split. An empty array means the currency is bucketed and this player has none; an absent field
185
+ means you did not ask.
186
+
187
+ ## 8. Items
188
+
189
+ An item definition is either **fungible** or not, and it changes what an instance is:
190
+
191
+ - A **fungible** item stacks. A player's 3 potions are one instance with a quantity of 3.
192
+ - A **non-fungible** item does not. Each is a distinct instance with its own id, and each may
193
+ carry its own rolled **properties** — the affixes a generated weapon draws from a pool.
194
+
195
+ Read every instance of a definition without walking the whole bag:
196
+
197
+ ```js
198
+ const swords = await gem.me.inventory.instances('iron-sword');
199
+ ```
200
+
201
+ ⚠️ **An item's `properties` and `customData` are untrusted, and rendering them wrong is a
202
+ cross-player attack.** They are typed `unknown` **because the platform does not validate their
203
+ contents** — every read of them is a parse, not a trusted object. And a transfer moves an instance
204
+ between players, so one player's item text reaches another player's screen. Any string you pull
205
+ from them is untrusted input: render it through `textContent` or your framework's escaping, and
206
+ **never** through `innerHTML`, and **never** as an `img.src` or `a.href` — either of those is a
207
+ `javascript:` / `data:` sink. The same caution applies to strings read from the config, which
208
+ arrives over a signed URL with no integrity check on its body.
209
+
210
+ ### Reading a generated item's affixes and rarity
211
+
212
+ When a recipe grants a **generated** item, the affixes the roll produced land in that instance's
213
+ `properties`, keyed by the names your item definition's property schema declares. The SDK types
214
+ `properties` as `unknown` on purpose — your config, not the SDK, decides those keys — so read a
215
+ value out defensively rather than assuming a shape:
216
+
217
+ ```js
218
+ const [blade] = await gem.me.inventory.instances('legendary-blade');
219
+ const props = blade?.properties ?? {};
220
+ const damage = typeof props.damage === 'number' ? props.damage : 0;
221
+ ```
222
+
223
+ **Rarity is reported once, when the roll happens — it is not a field on the instance.** A loot
224
+ open's result carries it (`result.lootResults[i].rarityTier`, from the same `executeRecipe` call
225
+ that opened the box — see "Loot tables and odds" below), and the item instance itself has no
226
+ rarity of its own. If you need the tier to persist on the item, have your config's generation
227
+ write it as a property; it then reads back like any other affix above. Rendering any of these
228
+ values still follows the untrusted-`properties` rule above.
229
+
230
+ ## 9. Catalogs and recipes
231
+
232
+ A **catalog** is a shop; a **recipe** is one purchasable thing in it. A recipe declares its costs
233
+ (what it consumes), its grants (what it gives), an optional cooldown, a use cap, availability
234
+ windows, and optionally a loot table to roll. Its `recipe_type` is one of `purchase`, `exchange`,
235
+ `claim`, `craft` or `loot_box`, and defaults to `purchase`; any other value — `standard`, for one —
236
+ is refused at upload, naming the five. `gemctl config schema` is the authority if that list grows.
237
+ There is no `consume` kind: a consume — spending an item the player holds, such as a revive token —
238
+ is an `exchange` whose `costs` remove the item.
239
+
240
+ Running a recipe is the one call that matters here:
241
+
242
+ ```js
243
+ await gem.me.mutations.executeRecipe(catalogId, recipeId);
244
+ ```
245
+
246
+ **A recipe is the only server-authoritative path in the economy.** When you execute one, the
247
+ server charges its costs, checks its prerequisites, enforces its cooldown and rolls any loot
248
+ table — none of which your client can forge. Everything else a shop shows — the price, whether
249
+ the player can afford it, the cooldown, uses remaining, the base odds — is derivable on the client
250
+ from the config and the snapshot you already loaded, with no per-item server call. [The
251
+ tutorial](game-economy-tutorial.md) builds exactly that shop.
252
+
253
+ Before you execute, you can ask whether a recipe can run, and that answer carries the cooldown so
254
+ you do not need a second read for it:
255
+
256
+ ```js
257
+ const av = await gem.me.mutations.recipeAvailability(catalogId, recipeId);
258
+ av.canExecute; // boolean
259
+ av.reasons; // why not, if not — the server's names: 'on_cooldown', 'insufficient_currency', …
260
+ av.cooldownExpiresAt; // when the cooldown lifts, if it is on one
261
+ ```
262
+
263
+ To read cooldowns for a whole shop at once, rather than one recipe at a time, there is a bulk
264
+ usage read — one request covering every recipe the player has used:
265
+
266
+ ```js
267
+ const usage = await gem.me.mutations.recipeUsageAll();
268
+ usage.usages; // one entry per recipe, with isOnCooldown and cooldownExpiresAt
269
+ usage.truncated; // true if the server had more than it returned — then a missing recipe is UNKNOWN, not idle
270
+ ```
271
+
272
+ Prefer `recipeUsageAll` to render a shop's cooldowns without a call per row, and
273
+ `recipeAvailability` to make the final check on the one recipe a player chose.
274
+
275
+ ### A locked recipe, and why it is locked
276
+
277
+ A recipe's `prerequisites` — owning an item, completing a progression, reaching a progression value
278
+ or a level — must all hold before it runs. A recipe whose prerequisites do not hold is **locked**,
279
+ and unlike a cooldown or a price, neither waiting nor paying lifts it. `recipeAvailability` says
280
+ whether it is locked and why, with the numbers to show:
281
+
282
+ ```js
283
+ const av = await gem.me.mutations.recipeAvailability(catalogId, recipeId);
284
+ if (av.locked) {
285
+ for (const lock of av.lockReasons) {
286
+ // { prerequisiteType: 'progression_level', referenceId, required: 10, current: 7 }
287
+ if (lock.prerequisiteType === 'progression_level') {
288
+ showLock(`Reach level ${lock.required} to unlock`); // you are level lock.current
289
+ }
290
+ }
291
+ }
292
+ ```
293
+
294
+ `required` and `current` are in the prerequisite's own unit: a quantity for `owns_item`, a value
295
+ for `min_progression_value`, a level for `progression_level`, and `1`/`0` for
296
+ `progression_complete`. `referenceId` is the item or progression, so look it up in your loaded
297
+ config for its name. The prerequisites themselves are on each recipe in the config document, so a
298
+ shop can show "Requires level 10" on every row without a call; `recipeAvailability` is what says
299
+ whether _this_ player meets them.
300
+
301
+ Render a locked row as locked — with what the player must do — rather than as a buy button that
302
+ fails. The platform checks prerequisites **before it charges anything**: a recipe sold for a platform
303
+ currency (§16) that the player is locked out of is refused with the code `recipe_locked`, and no
304
+ currency moves.
305
+
306
+ ## 10. Orders — the record that something happened
307
+
308
+ Every change to a player's holdings produces an **order**. Order history is read-only and answers
309
+ "what happened," including the one question that matters after a write — "did it land":
310
+
311
+ ```js
312
+ const result = await gem.me.mutations.executeRecipe(catalogId, recipeId);
313
+ const order = await gem.me.orders.get(result.orderId); // did it land?
314
+ await gem.me.orders.list(); // a page of recent orders
315
+ ```
316
+
317
+ Two things about the history:
318
+
319
+ - **The `source` of an order is not always your game.** The history holds every order for the
320
+ player in your game, including ones your game did not create — a reward, a system grant — so do
321
+ not assume every row is a purchase you made.
322
+ - **It is not an accounting ledger.** It is a record of orders, not a running balance, and summing
323
+ it will not reconcile against a current total. Read a balance from the snapshot; read history to
324
+ see events.
325
+
326
+ Creating an order directly — as opposed to through a recipe — is possible and is covered where it
327
+ belongs, in the reference, because it is **not** how you build a shop. See
328
+ [§14](#14-there-is-no-client-side-integrity-boundary) for why.
329
+
330
+ When you do create one, `gem.me.mutations.create(operations)` records `source: 'play'` by default:
331
+ that is the generic provenance for a game-authored mutation. Pass
332
+ `{ source: 'game_consume' }` when the order specifically consumes game inventory. Both sources are
333
+ accepted only in your token's game and channel, the currency, item or progression definition must
334
+ still opt into browser writes with `client_writable`, and neither can reach platform inventory.
335
+ They are not interchangeable, though: the platform also uses the source when deciding whether a
336
+ character may be targeted and which configured inventory bucket receives a grant.
337
+
338
+ ## 11. Conditional writes, and why most writes do not want them
339
+
340
+ You can make a write conditional on the inventory not having changed since you read it, by sending
341
+ the snapshot's `etag`:
342
+
343
+ ```js
344
+ const snap = await gem.me.inventory.snapshot();
345
+ await gem.me.mutations.create(ops, { ifMatch: snap.etag });
346
+ ```
347
+
348
+ A `precondition_failed` (HTTP 412) then means someone else wrote in between. The rule for a 412 is
349
+ **re-read and re-decide — never resend the same body.**
350
+
351
+ But **most game writes should not send `ifMatch`.** The common operations are deltas — spend this,
352
+ grant that — and the server already bounds them, so they are safe under concurrency without a
353
+ condition. Adding `ifMatch` converts harmless concurrency into 412s and forces a full re-read each
354
+ time. It is genuinely wanted only for a value your client computed as an absolute that the server
355
+ cannot re-derive, or a write that must not double-apply across two devices. It is a
356
+ contention-dependent choice, not a default.
357
+
358
+ **A 412 and a timeout are opposite cases, and a game that treats both as "retry" corrupts data.**
359
+ A 412 proves the write did _not_ happen — re-decide freely. A timeout proves _nothing_: the write
360
+ may have applied. Do not resend it; read the order back by id to find out what happened.
361
+
362
+ ## 12. Loot tables and odds
363
+
364
+ A loot table is rolled **server-side only**. You never roll one on the client; you execute a
365
+ recipe that references it, and the server draws from it with weights your client cannot influence.
366
+
367
+ You can read a table's weights, to show a player their chances:
368
+
369
+ ```js
370
+ const rates = await gem.gameConfig.dropRates('common-chest');
371
+ rates.entries; // each with a weight and a server-computed drop rate
372
+ ```
373
+
374
+ ⚠️ **These are the table's odds, not this player's.** Three things move the real chance and none of
375
+ them is in this response: a table may hold per-player pity state that no route exposes; entries can
376
+ be gated on what the player already owns or has progressed past, so a table that cannot drop what
377
+ you hold has different effective odds for everyone; and a mid-session config change is never seen,
378
+ because the config does not re-poll — `gem.gameConfig.refresh()` is the only thing that re-fetches
379
+ it. Show this number as a "base chance," or not as a number at
380
+ all. The only authority on what a player actually receives is the result of executing the recipe.
381
+
382
+ ## 13. Progressions
383
+
384
+ A **progression** is a track a player advances along — a battle pass, a level, a mastery. You read
385
+ its state from the snapshot, and you advance and claim it through mutations:
386
+
387
+ ```js
388
+ const levels = await gem.me.progressions.list();
389
+ const one = await gem.me.progressions.get('season-1'); // null if the player has no record
390
+ ```
391
+
392
+ A progression that grants rewards on a milestone is claimed like any other write, and the claim
393
+ produces an order you can read back. As with currencies, `get` returns `null` for a player with no
394
+ record of a progression, which is not the same as one they have started and sit at zero on.
395
+
396
+ **A progression that repeats on a known period is read exactly like this one**, and `get` returns
397
+ this period's value — it resets with the period, without your game doing anything. Adding
398
+ `platform_display: challenge` on top is a feature of its own: the arcade displays it, counts a
399
+ streak, and marks your game's card. It grants rewards and platform currency like any other
400
+ progression. [daily-challenges.md](daily-challenges.md) is the whole of it. Read that before
401
+ giving any progression a `daily`, `weekly` or `monthly` reset.
402
+
403
+ There are four writes, and the choice between the first two is the one that matters:
404
+
405
+ ```js
406
+ await gem.me.mutations.addProgression('season-xp', 250); // a counter: every match adds
407
+ await gem.me.mutations.maxProgression('level-3-stars', 2); // a record: only a better run moves it
408
+ await gem.me.mutations.claimProgression('season-xp'); // collect what milestones owe
409
+ await gem.me.mutations.resetProgression('season-xp'); // back to the start; claimed rewards stay
410
+ ```
411
+
412
+ `addProgression` is for things that accumulate — experience, kills, coins collected across a
413
+ season. `maxProgression` is for things that are a _best_ — stars on a level, a high score, a
414
+ furthest checkpoint. It raises the value if the new one is higher and does nothing otherwise, so
415
+ replaying a level and doing worse leaves the record alone, and replaying it and doing the same
416
+ records no change at all. Thresholds and rewards fire identically for both. Neither can lower a
417
+ value; lowering is a server-only operation (`progression_set` on the generic order route), which is
418
+ what keeps a milestone from being crossed twice by going back and forth over it.
419
+
420
+ Whether a browser client may write a progression at all is the definition's `client_writable`
421
+ flag in the game config, which defaults to off. A progression that feeds anything platform-wide —
422
+ a `platform_grants` mapping onto a platform currency — must be written by your game's server or
423
+ your serverless function, never by the browser; see §14.
424
+
425
+ ### Platform grants: awarding a platform-wide currency
426
+
427
+ The arcade keeps currencies that span every game rather than belonging to yours. A game does not
428
+ write them. A progression in the game's config can declare a _mapping_ — `platform_grants` in the
429
+ gemctl `game-config` skill — and the platform makes the grant itself when that progression moves,
430
+ bounded by the platform's ceilings: what one firing may pay, what one game may pay a player per
431
+ window, and what all games together may pay a player per day.
432
+
433
+ A platform currency is earned in one game and spent in another, including on items other games
434
+ sell. What each one is for is [arcade-economy.md](arcade-economy.md); which ones your game may pay
435
+ is `gemctl platform config`'s answer.
436
+
437
+ **Which targets your config may name is the platform's decision and changes as the arcade grows**,
438
+ so this page does not list them. Name one that is not permitted and the config upload is refused
439
+ with an error that enumerates the ones that are.
440
+
441
+ Gems can be your game's soft currency, awarded through a mapping. A game currency of your own suits
442
+ what lives inside one play loop — coins picked up and spent within a run, energy, crafting
443
+ materials: you control it completely, no other game can spend it, and none of the constraints below
444
+ apply to it.
445
+
446
+ What that means for game code:
447
+
448
+ - The source progression is written by your **server** — through its game-server credential
449
+ against the progression `add` / `max` routes, never through `gem.me` in the browser — or by
450
+ your **serverless function**, through `call-api` against the same routes. A mapped progression
451
+ cannot be `client_writable`, cannot be the target of any operation in your config (a recipe, a
452
+ loot-table entry, another progression's reward), and a browser write to it is refused.
453
+ - **A function pays only from a progression that opts in** with `function_may_grant: true`
454
+ (otherwise `suppressed`, `functions_not_opted_in`), and **a function a player called pays that
455
+ player only**: it may change only that player's own state (their inventory, progressions and
456
+ own characters), and any write to another player is refused with
457
+ `403 function_foreign_entity_write`. A function never pays a guest
458
+ (`function_invoker_is_guest`). One call pays
459
+ once: a second paying order in the same call is `suppressed` with
460
+ `function_invocation_already_granted`. Every receipt a function's order produces carries
461
+ `principal: "function"`.
462
+ [serverless-functions.md](serverless-functions.md#paying-the-player-who-called) is the whole of
463
+ it, including what the function must check before it pays.
464
+ - A `progressionSet` never fires a mapping — it is a correction. Neither does a reward that
465
+ happens to add to the source.
466
+ - **The channel does not change where a grant lands.** A mapping fires on `dev`, `test` and
467
+ `release` alike, into the player's real platform balance, against the real per-game cap — so
468
+ Gems paid while you test are real, and the allowance they spend is not given back.
469
+ - The order your server gets back carries one `platform_grant` entry in `changes` per mapping:
470
+ `fired`, `clamped` (and which ceiling bit — the per-firing ceiling, the platform's per-game
471
+ ceiling or its daily maximum across all games, or the player's holdings cap), or `suppressed`
472
+ (and why). That entry is the receipt; the player's platform-scope order history does not list
473
+ these grants.
474
+ - **A ceiling trims; it does not refuse**, for every platform currency. A firing that would cross
475
+ one is granted what is left of it, and 0 once it is spent — a player 50 short of the daily
476
+ maximum is paid 50 of a 200 firing. The receipt's `granted` is what landed, and a trim by the
477
+ per-game ceiling or the daily maximum names `platform_max_per_window`. The one exception is the
478
+ **holdings cap**, the most a player may hold: a grant that would cross it pays nothing, with
479
+ `clamped_by: holdings_cap`, so the receipt never reveals how much the player holds.
480
+ - To **show** how much more a player can earn before a grant is trimmed, read it rather than
481
+ discovering it from a `clamped` receipt: see
482
+ [Showing how much more a player can earn](#showing-how-much-more-a-player-can-earn).
483
+ - **The platform can switch your grants off**, globally, for one game, for one game and one
484
+ currency, or only for serverless functions (every game's, or one game's) — and when it has, the
485
+ receipt says so as a `suppressed` reason. If your mappings stop
486
+ firing, read the receipt before suspecting your config: that is the difference between "the
487
+ platform switched this off" and "your mapping is wrong", and it is the only place the two are
488
+ distinguishable. Existing balances are unaffected — a disable stops new grants and is never a
489
+ clawback.
490
+ - **Granting a target is never permission to take it back.** No consume or set on a platform
491
+ currency reaches it from a game. A platform currency is spent by the _player_, through the
492
+ platform, and never by your game against their balance.
493
+ - The count a player sees as "284 / 300" is what your game has granted them so far in the current
494
+ window, over the platform's per-game cap. **It counts what you awarded, not what they still
495
+ hold** — within a window it only rises, resetting or lowering the source progression never takes
496
+ it back, and a player spending a granted currency does not refill your game's allowance.
497
+
498
+ ### Achievements: the other kind of platform grant
499
+
500
+ A `platform_grants` entry can also be an **achievement** — a platform-wide recognition with points,
501
+ on the progression's completion or on one level of a track, that unlocks once per player when your
502
+ server, or a serverless function that player called, moves the progression past it. Everything
503
+ above about server-only writes applies — an achievement that awards points or rewards may not sit
504
+ on a progression any operation in your config can raise, so a recipe priced in Gems can never sell
505
+ one — and more: an achievement unlocks only on `release`, it never re-fires, and what it credits is
506
+ fixed once released. Its points are never boosted. [achievements.md](achievements.md) is the whole of it.
507
+
508
+ ## 14. There is no client-side integrity boundary
509
+
510
+ This is the exception the top of the document promised, and it is the most important paragraph
511
+ here.
512
+
513
+ > Within your own game's scope there is **no client-side integrity boundary**. Any balance, item
514
+ > or progression a browser client can author, a modified browser client can author differently.
515
+ > Recipes move the arithmetic to the server and are the only thing that does. Do not attach
516
+ > anything of value — a leaderboard, a tournament, a payout, a cross-player market — to a number a
517
+ > client authored.
518
+ >
519
+ > The platform holds itself to the same rule. Nothing a browser authors ever reaches platform
520
+ > inventory: a `platform_grants` mapping fires only for a write your game's _server_ or your
521
+ > _serverless function_ made, on a progression no client can reach, and the platform applies it
522
+ > under its own authority. A game with neither a server nor a serverless function cannot award a
523
+ > platform currency — that is the rule read strictly, not a gap. A function a player calls pays
524
+ > only that player, and only from a progression that opts in
525
+ > ([serverless-functions.md](serverless-functions.md#paying-the-player-who-called)).
526
+
527
+ Your game can grant itself currency and items within its own scope, and there are legitimate uses
528
+ for that — a single-player game rewarding a level, a prototype. But a grant your client authored is
529
+ a grant a modified client can author differently, so it is never a basis for competition. When the
530
+ value matters, run a **recipe**: the server does the arithmetic, and that is the only thing a
531
+ modified client cannot rewrite. The SDK steering you toward recipes is ergonomics, not a fence —
532
+ the game shares a page with the SDK and can reach any route directly — so the discipline has to be
533
+ yours.
534
+
535
+ ## 15. Batch orders
536
+
537
+ You can commit several orders together. One caution, because it is a data-loss trap:
538
+
539
+ ⚠️ **A batch is not safe to retry.** The idempotency guard the SDK applies per operation does not
540
+ protect a whole batch the same way, so a batch that times out must not be resent — a resend can
541
+ double-apply the entire batch. As with any write, a timeout is not a failure you retry; read back
542
+ what happened. If a set of changes can be one order with more operations rather than several
543
+ orders in a batch, prefer that.
544
+
545
+ ## 16. Selling for a platform currency
546
+
547
+ Everything above is about currencies **your game defines** — you mint them, you price in them, and
548
+ nobody else can touch them. The arcade also has currencies that span every game, and a game can both
549
+ **award** one and **be paid in** one.
550
+
551
+ That changes what a shop can be. A player earns in someone else's game and spends in yours.
552
+
553
+ There are two such currencies, and they are not interchangeable: **Gems** (`GEMS`) buy progress and
554
+ **Tickets** (`ARCADE_TICKETS`) buy cosmetics only. [arcade-economy.md](arcade-economy.md) says which
555
+ to use for what.
556
+
557
+ ### Awarding one
558
+
559
+ You do not write platform inventory, and you cannot. Instead a progression in your game config
560
+ declares a _mapping_: when this progression moves, the platform grants the currency itself, under its
561
+ own authority. That is config, not code — see the `platform_grants` section of the gemctl
562
+ `game-config` skill, and note the two rules that catch people:
563
+
564
+ - the source progression must be written by your **server** or your **serverless function**, never
565
+ the browser, and
566
+ - it must not be the target of anything else in your config (a recipe, a loot-table entry, another
567
+ progression's reward).
568
+
569
+ **Which currencies you may target, and which you may price in, are two different sets, and both are
570
+ the platform's to change.** Ask rather than memorising either:
571
+
572
+ ```bash
573
+ gemctl platform config
574
+ ```
575
+
576
+ It answers both sites at once — everything under `platform_grants` may be a mapping's target,
577
+ everything under `platform_prices` may carry a price, and a currency under neither cannot be used at
578
+ all. The grant rows also carry the platform's ceilings, and a game does not declare its own; a
579
+ `max_per_window` on a mapping is ignored, with a warning.
580
+
581
+
582
+ A browser-only game cannot award a platform currency without a server **or a serverless function**.
583
+ That is the price of the platform never trusting a client. A game with no dedicated server writes
584
+ the source progression from a function the player calls, on a progression that opts in with
585
+ `function_may_grant`, and the platform then pays that player only, in any listed platform currency
586
+ — Gems (`GEMS`) and Tickets (`ARCADE_TICKETS`) alike
587
+ ([serverless-functions.md](serverless-functions.md#paying-the-player-who-called)).
588
+
589
+ ### Showing the reward land in the arcade's wallet
590
+
591
+ **Platform-currency display belongs to the arcade** (the creator's ruling, 2026-09-24) — Gems and
592
+ Tickets alike. The arcade draws the player's platform currencies in its own chrome while your game
593
+ runs, and moves a total when the platform confirms a grant. Your game keeps no counter or balance
594
+ of a platform currency of its own, draws no flight or celebration of its own, and shows no
595
+ platform-currency amount at all when it is not connected to the arcade.
596
+
597
+ What your game does is ask for the payout to start on **your** screen, from the currency's icon (or
598
+ hero) beside the reward, so the currency flies from there into the wallet and the total bounces up
599
+ as it lands. A Gems payout is cued from the gem:
600
+
601
+ ```js
602
+ const { x, y, width, height } = rewardGem.getBoundingClientRect(); // the gem element's own box
603
+ gem.cueReward({
604
+ currencySlug: 'GEMS',
605
+ expected: 15, // if you know it; drawn as provisional until the platform confirms
606
+ origin: { x: x + width / 2, y: y + height / 2 }, // CSS px of your own viewport
607
+ });
608
+ ```
609
+
610
+ `origin` is the **centre of the gem on screen** — measure the gem element itself, not the row or
611
+ panel around it. A Tickets payout is the same call with `currencySlug: 'ARCADE_TICKETS'`, from the
612
+ ticket: the slug decides which total the payout flies into. **If `cueReward` returns `false`, do
613
+ nothing**: there is no fallback celebration.
614
+ `false` means the arcade will not show it here — the bridge is not up, or the embedder has no wallet
615
+ — and your game does not draw one of its own in its place.
616
+
617
+ Call it when your server moves the mapped progression — just before or just after the write, it does
618
+ not matter; the arcade reconciles. Three things to know:
619
+
620
+ - **The arcade never takes your word for the amount.** What lands, and the player's new total, come
621
+ from the platform's own event, sent only after the grant committed. `expected` is drawn as
622
+ provisional; a cue the platform never confirms fades on its own.
623
+ - **What lands can be less than you cued, and the arcade says why.** A ceiling trims a grant to what
624
+ is left of it, so at the platform's daily maximum the wallet moves by the part that fit, or not at
625
+ all once the player's day is spent. When the daily maximum is reached — by a trim, an exact fill
626
+ or a firing that paid nothing — the arcade shows the player "daily max" and when more of that
627
+ currency arrives, in its own chrome. That notice is the player's and never reaches your game, so
628
+ cue every payout the same way: do not predict the outcome of a grant. To show how much more your
629
+ game can still pay, read [the earn allowance](#showing-how-much-more-a-player-can-earn) — never to
630
+ decide whether to grant. A mapping the receipt reports `suppressed` produces no event, so its cue
631
+ fades and the wallet does not move. Do not read the wallet as confirmation of anything: your
632
+ server's order response is the receipt.
633
+ - **This is presentation, not permission.** It grants nothing, spends nothing and reveals nothing,
634
+ and a reduced-motion player sees the total change without the flight.
635
+
636
+ To draw the Gem on your own screen, use the platform's art, which ships in the package — the Gems
637
+ **icon** beside an amount or price, the **hero** only on a reward presentation where the gem is the
638
+ subject. [brand-art.md](brand-art.md) has both, how to draw them, and the rest of the rules.
639
+
640
+ ### Being paid in one
641
+
642
+ Every game can choose between two purchase owners for the same published platform price:
643
+
644
+ | Call | Who owns confirmation | Transport | Result |
645
+ | --- | --- | --- | --- |
646
+ | `gem.purchases.request(...)` | the platform | postMessage bridge; platform sheet always opens | `ok`, `declined`, or `unavailable` |
647
+ | `gem.me.mutations.purchase(...)` | your game | direct authenticated SDK request; no bridge or platform sheet | an order-style result or a `GemApiError` |
648
+
649
+ There is no approval step. Both paths are available to every game.
650
+
651
+ #### Platform-owned purchase sheet
652
+
653
+ Use the bridge path when you want the arcade to confirm and process the purchase:
654
+
655
+ ```js
656
+ const config = await gem.gameConfig.load();
657
+ const item = config.item('extra-life');
658
+
659
+ const res = await gem.purchases.request({
660
+ kind: 'item', // or 'recipe'
661
+ targetId: item.id, // UUID, never the slug
662
+ currencySlug: 'GEMS',
663
+ quantity: 1,
664
+ });
665
+
666
+ if (res.outcome === 'ok' || res.outcome === 'unavailable') {
667
+ gem.me.inventory.refresh();
668
+ await gem.me.inventory.snapshot();
669
+ }
670
+ ```
671
+
672
+ The platform always opens its purchase sheet for this call. It re-fetches the target and price,
673
+ submits the order with the player's account session, and verifies an `ok` before returning it to the
674
+ game.
675
+
676
+ - `declined` means the player cancelled or the platform refused, before anything was charged.
677
+ Return to the shop silently.
678
+ - `unavailable` means no reliable answer arrived. Never retry automatically; re-read inventory
679
+ because the order may have completed.
680
+ - `ok` means the grant was confirmed. Re-read inventory rather than granting anything locally.
681
+
682
+ `declined` and `unavailable` also carry `res.reason`, which says why — `cancelled`,
683
+ `insufficient_funds`, `recipe_locked`, `unknown_currency` and the rest of the list in
684
+ [Why a purchase was refused](#why-a-purchase-was-refused). Branch on `outcome`; read `reason` to
685
+ put the right state on the row or to find your own mistake. A reason your SDK version does not
686
+ know arrives as `error`.
687
+
688
+ The sheet opens only in answer to a player's click or key press, so call `request` from your Buy
689
+ button's handler, never from a timer or on load: without one the answer is `declined` with reason
690
+ `busy`, and nothing is shown. After a player cancels, the arcade answers further asks from your
691
+ game `busy` for a short, growing interval — do not re-ask on `cancelled`.
692
+
693
+ Hide this specific affordance when `gem.frame.parentSupports('purchases')` is false. That check does
694
+ not apply to the direct SDK path.
695
+
696
+ #### Game-owned direct purchase
697
+
698
+ Use the direct path when your game provides its own purchase UI and does not want the platform sheet:
699
+
700
+ ```js
701
+ const result = await gem.me.mutations.purchase({
702
+ kind: 'item', // or 'recipe'
703
+ targetId: item.id,
704
+ quantity: 1,
705
+ payment: {
706
+ kind: 'platform_currency',
707
+ currencySlug: 'GEMS',
708
+ },
709
+ });
710
+
711
+ console.log(result.orderId, result.purchaseId, result.status);
712
+ ```
713
+
714
+ The game supplies the target and currency, never an amount. The SDK reads the current purchase
715
+ catalog and active platform currencies, resolves the published price and config version, mints the
716
+ idempotency nonce, and submits the order with the player's game token. gem-api checks the same
717
+ published data again before debiting. A stale price is refused rather than charged at either value.
718
+
719
+ The same method also replaces `executeRecipe()` for purchases paid by configured game-currency or
720
+ item costs:
721
+
722
+ ```js
723
+ const result = await gem.me.mutations.purchase({
724
+ kind: 'recipe',
725
+ catalogId,
726
+ targetId: recipeId,
727
+ payment: { kind: 'configured' },
728
+ });
729
+ ```
730
+
731
+ `executeRecipe(catalogId, recipeId)` remains as a compatibility wrapper around this configured
732
+ payment form. Both forms are server-authoritative: a configured payment executes the recipe's costs
733
+ and outputs; a platform-currency payment atomically debits the platform wallet and fulfills the item
734
+ or recipe, reversing the debit if fulfillment fails.
735
+
736
+ The direct path returns a discriminated result:
737
+
738
+ - `payment: 'configured'` carries the recipe's `granted`, `consumed`, auto-claimed rewards and
739
+ loot rolls.
740
+ - `payment: 'platform_currency'` carries `orderId`, `purchaseId`, `sourceOrderId`, `status`
741
+ and the total `price`.
742
+
743
+ A refusal rejects, and **`err.code` says why** — in the same vocabulary as the sheet's `reason`:
744
+
745
+ - Before sending anything, the SDK checks the target and currency against the catalog and the
746
+ player's platform currencies it just read, and throws a `PurchaseRefusal` (a `RangeError`) with
747
+ `code` `unknown_currency`, `not_purchasable`, `no_price_in_currency` or `quantity_invalid`. Its
748
+ message says what to fix: the currencies that are active, the config version it looked in, or
749
+ what the target _is_ priced in.
750
+ - The platform's own refusals reject as `GemApiError`, whose `code` is the platform's —
751
+ `insufficient_funds` (with `reason: 'insufficient_funds'`), `price_mismatch`,
752
+ `config_version_stale`, `recipe_locked`, `recipe_on_cooldown`, `recipe_costs_unmet`, and so on.
753
+
754
+ ```js
755
+ try {
756
+ await gem.me.mutations.purchase(request);
757
+ } catch (err) {
758
+ switch (err.code) {
759
+ case 'insufficient_funds':
760
+ return showNotEnough(); // your shop's own words, never a top-up screen
761
+ case 'recipe_locked':
762
+ return showLock(); // read the lock from recipeAvailability()
763
+ case 'price_mismatch':
764
+ case 'config_version_stale':
765
+ return reloadShop(); // the catalog moved; nothing was charged
766
+ default:
767
+ console.error(err); // unknown_currency, no_price_in_currency, … are yours to fix
768
+ return showStoreUnavailable();
769
+ }
770
+ }
771
+ ```
772
+
773
+ Do not retry an unsafe write after a timeout; re-read inventory or use the returned order/purchase
774
+ ids for support.
775
+
776
+ **`targetId` is the UUID, never the slug.** `config.item(...)` accepts either because it is your
777
+ local lookup. The purchase catalog is keyed by ids. Recipes also require `catalogId`.
778
+
779
+ #### Why a purchase was refused
780
+
781
+ One vocabulary covers both paths: the sheet's `res.reason` and the direct path's `err.code` are
782
+ the same strings.
783
+
784
+ | Code | What happened | What to do |
785
+ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
786
+ | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
787
+ | `currency_not_in_game_payable` | the currency exists but cannot pay for game items | price in a currency `gemctl platform config` lists under `platform_prices` |
788
+ | `not_purchasable` | the target is not in the purchase catalog on this channel, or cannot be bought | send the id (not the slug); promote the config version that prices it |
789
+ | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
790
+ | `busy` | a purchase is already in flight, a sheet is open, the arcade is backing off after a cancel, or no click | ask again only from a fresh player action |
791
+ | `cancelled` | the player dismissed the sheet | nothing — return to the shop |
792
+ | `insufficient_funds` | the player cannot afford it | say so in your shop's words; never draw a top-up screen |
793
+ | `price_mismatch`, `config_version_stale` | the price or config changed since your catalog read; nothing was charged | re-read the catalog and re-render |
794
+ | `platform_currency_unavailable` | the currency is not available to this purchase right now | show the store as unavailable |
795
+ | `not_available` | the recipe is outside its availability window, or inactive; nothing was charged | hide or disable the row until its window opens |
796
+ | `recipe_locked` | the recipe's prerequisites are not met; nothing was charged | show the lock from `recipeAvailability().lockReasons` |
797
+ | `recipe_on_cooldown`, `recipe_max_uses_exceeded`, `item_max_stack_exceeded`, `recipe_costs_unmet`, `quantity_invalid` | the recipe or item refused this purchase before any charge | reflect it on the row; `recipeAvailability` has the detail |
798
+ | `error` | anything else, or no reliable answer | one neutral "store unavailable"; after `unavailable`, re-read inventory |
799
+
800
+ Reasons are for your shop's state and your logs. Branch money on `outcome` and on a re-read of
801
+ inventory, never on a reason, and do not retry because of one.
802
+
803
+ ⚠️ **Never draw a top-up or payment screen of your own** — no "get more Gems", no checkout, nothing
804
+ that looks like the arcade's purchase sheet or wallet. A game screen shaped like platform payment
805
+ chrome is an imitation of it. Your Buy button is yours; asking a player to add currency or pay is
806
+ the arcade's alone.
807
+
808
+ ### Tickets buy cosmetics; Gems buy anything
809
+
810
+ The arcade has two currencies a player spends: **Gems**, for progress, and **Tickets**
811
+ (`ARCADE_TICKETS`), for cosmetics. The platform holds that line with two settings: each platform
812
+ currency's **spend scope**, which the platform sets, and each of your items' **category**, which you
813
+ set.
814
+
815
+ - A currency whose spend scope is **`cosmetic_only`** may price cosmetics only. Tickets' is.
816
+ - A currency whose spend scope is **`any`** may price anything. Gems' is.
817
+
818
+ The platform owns each currency's scope and can change it, so do not hard-code which is which: a
819
+ refusal always names the scope that applied.
820
+
821
+ ```yaml
822
+ items:
823
+ - id: '…'
824
+ slug: dragon-skin
825
+ category: cosmetic # changes only how the player looks
826
+ platform_prices:
827
+ - currency: ARCADE_TICKETS
828
+ price: 120
829
+ - id: '…'
830
+ slug: extra-life
831
+ # category omitted: `progression`, the default
832
+ platform_prices:
833
+ - currency: GEMS
834
+ price: 25
835
+ ```
836
+
837
+ - **`category`** is `cosmetic` — skins, outfits, visual effects, decorations, emotes, profile
838
+ cosmetics: anything that changes how the player looks and never what they can do — or
839
+ `progression`, anything that affects play. It defaults to `progression`, so an item is only ever a
840
+ cosmetic because you said so.
841
+ - **A price in a `cosmetic_only` currency, such as Tickets, is accepted only on a cosmetic.** On an
842
+ item, that item must be `category: cosmetic` and a plain `item_type: item` — a `bundle` or
843
+ `loot_box` opens into things its own row does not describe, so it cannot be sold for Tickets.
844
+ - **A recipe priced in a `cosmetic_only` currency must grant at least one cosmetic item and nothing
845
+ else.** It is refused if it grants any currency, moves any progression, grants a non-cosmetic item,
846
+ or rolls a loot table — a loot table is refused for now even when every entry is cosmetic. Taking
847
+ from the player as part of the recipe (`currency_consume`, `item_remove`) is fine.
848
+ - **A currency whose scope is `any`, such as Gems, may price anything**, cosmetic or not.
849
+
850
+ The upload refuses a config that breaks the rule. The refusal names the currency and its scope, and
851
+ the offending item or operation, so you know what to change:
852
+
853
+ ```text
854
+ catalogs[0].recipes[2] (skin-pack): priced in ARCADE_TICKETS, whose spend scope is `cosmetic_only`,
855
+ but grants[1] (item_grant of 'xp-boost') grants an item whose category is `progression`. …
856
+ ```
857
+
858
+ The same rule holds after you publish. If a currency's scope becomes `cosmetic_only` later, the
859
+ purchase catalog stops listing that currency's price on anything the scope does not admit, and a
860
+ purchase naming such a price is refused `not_purchasable` before anything is charged.
861
+
862
+ The category is a declaration the platform takes at your word, and the release review checks it.
863
+ Labelling something that affects play `cosmetic` so it can be sold for Tickets breaks platform policy.
864
+
865
+ **To sell a cosmetic that needs game progress first, sell it through a recipe.** An item's own
866
+ `platform_prices` cannot carry prerequisites; a recipe's can. Grant the cosmetic from a recipe that
867
+ declares the prerequisite and carries the Tickets price:
868
+
869
+ ```yaml
870
+ catalogs:
871
+ - id: '…'
872
+ slug: cosmetics
873
+ recipes:
874
+ - id: '…'
875
+ slug: buy-dragon-skin
876
+ prerequisites:
877
+ - prerequisite_type: progression_level
878
+ reference_id: '…' # your `mastery` progression
879
+ reference_value: 10
880
+ grants:
881
+ - type: item_grant
882
+ item_definition_slug: dragon-skin
883
+ platform_prices:
884
+ - currency: ARCADE_TICKETS
885
+ price: 120
886
+ ```
887
+
888
+ A player below level 10 is refused `recipe_locked` before any Tickets are taken, and
889
+ `recipeAvailability` tells your shop the recipe is locked and what it needs (§9) — so show the lock
890
+ ("Reach level 10 to unlock") instead of offering the purchase.
891
+
892
+ ### Reading the player's balance
893
+
894
+ ```js
895
+ const currencies = await gem.platform.currencies.get();
896
+ const gems = currencies.find((currency) => currency.slug === 'GEMS');
897
+ buyButton.disabled = !gems || gems.balance < price; // can the player afford it?
898
+ ```
899
+
900
+ **Read it to decide, never to display.** Platform-currency display belongs to the arcade: its
901
+ wallet shows the player's Gems and Tickets totals while your game runs, and your game keeps no
902
+ counter, balance or wallet total of either of its own ([brand-art.md](brand-art.md) §5). Use the
903
+ balance to enable a purchase or to say "not enough Gems" — not to draw a number.
904
+
905
+ Every game receives every active platform currency. Each row has `access: 'spend'`; the field stays
906
+ in the response shape for existing consumers.
907
+
908
+ - `currencyId` joins a balance to `platform_prices[].platform_currency_id`.
909
+ - The balance is a snapshot. Re-read when the shop opens and after a purchase; do not poll.
910
+ - This is a player-only read. A dedicated server token is refused.
911
+ - The read itself never spends, grants or sets a balance.
912
+
913
+ ### Showing how much more a player can earn
914
+
915
+ ```js
916
+ const allowances = await gem.platform.earnAllowance.get();
917
+ const gems = allowances.find((a) => a.currencySlug === 'GEMS');
918
+ // 0 or less once `resetsAt` has passed: the read is stale, so say nothing about when.
919
+ const hours = gems?.resetsAt ? Math.ceil((gems.resetsAt - Date.now()) / 3_600_000) : 0;
920
+ if (gems && gems.remaining > 0) {
921
+ hud.setText(`You can still earn ${gems.remaining} Gems` + (hours > 0 ? `, more in ${hours}h` : ''));
922
+ } else if (gems?.resetsAt) {
923
+ // Spent for now: say when more arrives, never "0 left".
924
+ hud.setText(hours > 0 ? `More Gems in ${hours}h` : 'More Gems soon');
925
+ }
926
+ ```
927
+
928
+ One entry per earned platform currency (Gems and Tickets today), for **this game and this player**:
929
+ how much more your game can still grant before the platform's ceilings trim the grant. It is the
930
+ lower of what is left of your game's own cap and what is left of the player's daily maximum across
931
+ all games, with the player's Boost applied — one figure, never a breakdown.
932
+
933
+ | Field | What it is |
934
+ | -------------- | ------------------------------------------------------------------------------------------------- |
935
+ | `currencySlug` | `GEMS`, `ARCADE_TICKETS`, … (`''` if the platform sent none) |
936
+ | `currencyId` | The permanent id; joins to `gem.platform.currencies` and `platform_prices[].platform_currency_id` |
937
+ | `limit` | The binding ceiling: your game's own allowance, or the daily maximum across every game |
938
+ | `earned` | How much of that ceiling has been paid, which may include other games' payouts |
939
+ | `remaining` | How much more can be paid now — the number to show |
940
+ | `period` | `'day'` or `'window'` (a longer window) |
941
+ | `periodDays` | The window's length in days — a label; use `resetsAt` for _when_ |
942
+ | `resetsAt` | When more arrives, in epoch milliseconds — **absent** (never `0`) when no refill is scheduled |
943
+
944
+ **Only `remaining` and `resetsAt` are stable to display.** Do not build an `earned / limit` progress
945
+ bar: the bar switches referent when the binding ceiling changes.
946
+
947
+ A player whose Tickets are spent for today reads `remaining: 0` with a `resetsAt`: say when more
948
+ arrives (from `resetsAt`), not "no Tickets". When the platform has paused a currency's grants for
949
+ your game the entry reads `limit: 0, remaining: 0` with **no** `resetsAt`.
950
+
951
+ **For a one-day allowance, `resetsAt` is the player's own next midnight — do not assume 00:00 UTC.**
952
+ A longer window is a block of the player's own dates and refills at their local midnight too. Either
953
+ way, render the `resetsAt` you are given rather than computing one. `resetsAt` can be later than the
954
+ end of the reported period when both of the platform's ceilings are spent.
955
+
956
+ **Display only. Never gate gameplay or a grant on it.**
957
+
958
+ - ✅ `` hud.setText(`${gems.remaining} Gems left to earn today`) ``
959
+ - ❌ `if (gems.remaining > 0) awardDailyBonus()`
960
+
961
+ Grant anyway: the platform trims every grant to what is left, and the receipt is the authority on
962
+ what landed. The figure can be stale the moment after you read it — another game may pay the player
963
+ in between — and it is not a reservation. A player at their holdings cap can read a positive
964
+ `remaining` while a grant pays 0.
965
+
966
+ It is **not a counter** either: it states what your game can still pay, not what the player holds,
967
+ so it never stands in for the arcade's wallet. Like every platform-currency amount, hide it when you
968
+ are not connected to the arcade.
969
+
970
+ **Staying current.** Read on your own schedule — on load, on a results screen, after your own grant.
971
+ Some arcades also tell your game when the allowance may have changed — after your own grant, or a
972
+ platform-wide change that moved your figure:
973
+
974
+ ```js
975
+ let pending = false;
976
+ const off = gem.platform.earnAllowance.on('changed', async () => {
977
+ if (pending) return;
978
+ pending = true;
979
+ try {
980
+ await refreshAllowance();
981
+ } finally {
982
+ pending = false;
983
+ }
984
+ });
985
+ ```
986
+
987
+ The listener receives no figures; call `get()` again. A burst of hints should produce one read. It
988
+ is an upgrade, never a replacement for reading: an arcade may never send it, and nothing is fetched
989
+ for you when it fires. Do not poll.
990
+
991
+ **Errors.** `get()` rejects only when the platform could not be asked or refused the caller:
992
+ `forbidden` (a context with no player, such as a game server), `not_found`, the retryable
993
+ `unavailable` or `rate_limited`, or an `AuthError` when the play token cannot be renewed. **An outage
994
+ rejects; it is never a zero** — a spent-looking allowance would be a wrong answer, so show nothing
995
+ rather than "0 left".
996
+
997
+ ## What you cannot do here
998
+
999
+ **You cannot submit an arbitrary platform debit, grant or amount.** A direct purchase names a
1000
+ published target and currency; the SDK and API resolve and verify the price. General platform-scope
1001
+ inventory writes remain unavailable. Your own currencies remain entirely yours.
1002
+
1003
+ **You cannot read general platform inventory.** `gem.platform.currencies.get()` exposes active
1004
+ currency balances only, and `gem.platform.earnAllowance.get()` one consolidated figure per currency
1005
+ for this game. History, items, orders outside this game, other platform holdings, and a per-game
1006
+ breakdown of the player's earnings stay out of reach.
1007
+
1008
+ **You cannot take back what you awarded.** Granting a platform currency is never permission to
1009
+ remove it. A player spends it through the platform, by choosing to.
1010
+
1011
+ **You cannot sell for real money here.** A platform currency a player _earns_ is what this surface
1012
+ reaches. Charging actual money is a separate platform capability and is not something a game config
1013
+ grants itself.
1014
+
1015
+ ## Errors
1016
+
1017
+ | What you get | What happened | What to do |
1018
+ | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1019
+ | A read or grant resolves to nothing for an id | The definition is not on this channel — uploaded but not promoted, or a channel mismatch between the running game and the promoted config | Promote the config version to the channel your game runs; see [`saving-player-progress.md`](saving-player-progress.md#1-declare-the-slot-in-your-game-config) |
1020
+ | `null` from `load()` | This game has no config on this channel, or its version has not built yet | A real state; render the no-economy path rather than catching an error |
1021
+ | `insufficient_funds` (HTTP 402) on a consume | The player cannot afford it | Re-read the balance and re-render; do not retry the same write |
1022
+ | `precondition_failed` (HTTP 412) on a conditional write | Someone else wrote since your read | Re-read and re-decide — never resend the same body |
1023
+ | A recipe reports it cannot run | A cooldown, a use cap, an unmet prerequisite, or an availability window | Show the reason from `recipeAvailability`; the cooldown's end is on that same response |
1024
+ | `recipe_locked` (HTTP 422) on a purchase | The player does not meet the recipe's prerequisites; nothing was charged | Show the lock from `recipeAvailability().lockReasons`; do not retry until it lifts |
1025
+ | Any other purchase refusal, as `reason` or `err.code` | The purchase was refused, and the code says why | See [Why a purchase was refused](#why-a-purchase-was-refused) |
1026
+ | A write times out | Unknown — it may have applied | Do not resend; read the order back by id |
1027
+
1028
+ ## What to read next
1029
+
1030
+ - [`arcade-economy.md`](arcade-economy.md) — Gems, Tickets and achievement points: which to use for
1031
+ what, and the rules each carries.
1032
+ - [`achievements.md`](achievements.md) — platform achievements on your progressions' thresholds.
1033
+ - [`game-economy-tutorial.md`](game-economy-tutorial.md) — build a shop, step by step, the safe way.
1034
+ - [`trading-and-gifting.md`](trading-and-gifting.md) — move items and currency between players.
1035
+ - [`saving-player-progress.md`](saving-player-progress.md) — a save often holds an inventory; the two are neighbours.