@indiegems/gem-web-sdk 7.4.0 → 7.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/gem-web-sdk.esm.js +1 -1
- package/dist/gem-web-sdk.game.js +1 -1
- package/dist/gem-web-sdk.min.js +1 -1
- package/dist/gem-web-sdk.sdk.js +1 -1
- package/dist/gem-web-sdk.server.js +1 -1
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +583 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +5 -5
- package/schema/capabilities.public.json +189 -0
|
@@ -0,0 +1,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.
|