@indiegems/gem-web-sdk 23.3.0 → 24.0.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.
@@ -141,7 +141,7 @@ gem.me.inventory.onChanged(async () => {
141
141
 
142
142
  It is a cue to re-read, not a receipt, and it arrives a moment after the change commits. It is
143
143
  only ever about **this** game, on the channel you are playing: the Arcade never relays another
144
- game's changes or the platform wallet's (Gems and Coins have their own hint,
144
+ game's changes or the platform wallet's (Coins have their own hint,
145
145
  `gem.platform.earnAllowance.on('changed')`). A burst — a function granting in a loop — reaches
146
146
  you as one call now and at most one more every two seconds, and the hint itself makes no request.
147
147
 
@@ -334,6 +334,77 @@ fails. The platform checks prerequisites **before it charges anything**: a recip
334
334
  currency (§16) that the player is locked out of is refused with the code `recipe_locked`, and no
335
335
  currency moves.
336
336
 
337
+ ### Requirements: `requires`
338
+
339
+ New config states what a player must hold or have done with one vocabulary, `requires`: a list
340
+ of requirements that must **all** hold. Items, recipes and progression tracks may carry one, and so
341
+ may each bonus (below).
342
+
343
+ | `kind` | Holds when the player… |
344
+ | ---------------------- | ------------------------------------------------------------------------------------ |
345
+ | `holds_pass` | holds the pass `pass` now, e.g. `{ kind: 'holds_pass', pass: 'vip' }` |
346
+ | `owns_platform_item` | owns the platform item `item_definition_id`; only one this game grants or sells |
347
+ | `owns_item` | holds at least `min` of the game item `item_definition_id` |
348
+ | `has_currency` | holds at least `min` of the **game** currency `currency_id`; a platform currency is refused |
349
+ | `progression_complete` | has completed the progression `progression_definition_id` |
350
+ | `progression_level` | is at or above `min` on the progression `progression_definition_id`: its track level, or its value when it has no tracks |
351
+
352
+ - **The platform judges it, when the recipe runs.** Not the browser: a requirement is the one place
353
+ a gate on a pass is enforced.
354
+ - **A refusal or a receipt names an unmet requirement by its index and kind**, never by the pass or
355
+ item it names.
356
+ - **The older shapes still work.** A recipe's `prerequisites`, a loot entry's exclusion conditions
357
+ and a track's `requires_item_id` are read as they always were; `gemctl` warns on `prerequisites`
358
+ in a new upload. Write `requires` in new config.
359
+
360
+ ### Bonuses: more for a player who meets a requirement
361
+
362
+ A recipe or a loot table may carry `bonuses`: extra grants for a player who meets each bonus's own
363
+ `requires`. VIP is just `holds_pass: vip`; owning a charm, or reaching a level, works the same way.
364
+
365
+ ```jsonc
366
+ // a recipe
367
+ {
368
+ "id": "open_chest",
369
+ "costs": [{ "type": "item_remove", "item_definition_id": "<chest_key>", "quantity": 1 }],
370
+ "loot_table_id": "<chest_common>",
371
+ "bonuses": [
372
+ {
373
+ "id": "vip_roll",
374
+ "requires": [{ "kind": "holds_pass", "pass": "vip" }],
375
+ "grants": [{ "type": "roll", "loot_table_id": "<chest_common>", "count": 1 }]
376
+ },
377
+ {
378
+ "id": "lucky_charm",
379
+ "requires": [{ "kind": "owns_item", "item_definition_id": "<lucky_charm>", "min": 1 }],
380
+ "grants": [{ "type": "item_grant", "item_definition_id": "<rare_shard>", "quantity": 1, "account_bound": true }]
381
+ }
382
+ ]
383
+ }
384
+ ```
385
+
386
+ - **A bonus never fails the base.** The recipe runs exactly as it would without bonuses; each bonus
387
+ is then judged on the player's state from before the recipe ran, applied or skipped on its own,
388
+ and a skipped bonus changes nothing else. The order's `changes` carry one `bonus` entry per
389
+ bonus: `{ bonus_id, outcome: 'applied' | 'skipped', reason?, unmet: [{ index, kind }] }`, so the
390
+ game can say "VIP bonus: +1 roll".
391
+ - **What a bonus may grant:** game items, game currencies, and `roll`, which rolls a loot table
392
+ `count` times. Never a platform currency, and never a write to a progression.
393
+ - **A loot table's bonuses grant extra rolls only**, never extra entries, so the base roll's odds
394
+ never move. A `roll`'s table may not carry bonuses of its own: one level deep.
395
+ - **Limits:** at most 8 bonuses on a recipe or a table, and 10 rolls per bonus. A bonus that grants
396
+ what a bonus requires is refused, and a VIP bonus that grants a transferable item is warned about:
397
+ mark it `account_bound`.
398
+ - **Rolls are their own.** Each bonus rolls from its own random stream, after every base draw, and
399
+ never reads or moves pity, so adding, removing or skipping a bonus never changes the base's
400
+ outcome or another bonus's.
401
+ - **The platform holds nothing.** A bonus is judged when the recipe runs and never later: there is
402
+ no pending or missed bonus and no voucher. To sell VIP at that moment, offer it **before** the
403
+ recipe runs ([the upsell](#offering-vip-before-a-reward-the-upsell)).
404
+ - **A paid roll is gated.** On a recipe priced in Coins, a bonus that rolls is skipped with
405
+ `paid_chance_restricted` for a known minor and in restricted regions; the base of a free recipe is
406
+ never affected.
407
+
337
408
  ## 10. Orders — the record that something happened
338
409
 
339
410
  Every change to a player's holdings produces an **order**. Order history is read-only and answers
@@ -410,6 +481,12 @@ because the config does not re-poll — `gem.gameConfig.refresh()` is the only t
410
481
  it. Show this number as a "base chance," or not as a number at
411
482
  all. The only authority on what a player actually receives is the result of executing the recipe.
412
483
 
484
+ **Show the odds before a player pays for a roll.** A recipe priced in Coins that rolls a table, or
485
+ carries a bonus that rolls one, is a paid random item: the arcade's purchase sheet shows the odds
486
+ from the published weights before the player pays, and your shop and any upsell show them too. The
487
+ platform refuses a paid roll with `paid_chance_restricted` for a known minor and in restricted
488
+ regions (a bonus roll is skipped instead), so hide such an offer rather than drawing a dead button.
489
+
413
490
  ## 13. Progressions
414
491
 
415
492
  A **progression** is a track a player advances along — a battle pass, a level, a mastery. You read
@@ -469,7 +546,7 @@ is `gemctl platform config`'s answer.
469
546
  so this page does not list them. Name one that is not permitted and the config upload is refused
470
547
  with an error that enumerates the ones that are.
471
548
 
472
- Gems can be your game's soft currency, awarded through a mapping. A game currency of your own suits
549
+ Coins can be your game's soft currency, awarded through a mapping. A game currency of your own suits
473
550
  what lives inside one play loop — gold picked up and spent within a run, energy, crafting
474
551
  materials: you control it completely, no other game can spend it, and none of the constraints below
475
552
  apply to it.
@@ -496,7 +573,7 @@ What that means for game code:
496
573
  happens to add to the source.
497
574
  - **The channel does not change where a grant lands.** A mapping fires on `dev`, `test` and
498
575
  `release` alike, into the player's real platform balance, against the real per-game cap — so
499
- Gems paid while you test are real, and the allowance they spend is not given back.
576
+ Coins paid while you test are real, and the allowance they spend is not given back.
500
577
  - The order your server gets back carries one `platform_grant` entry in `changes` per mapping:
501
578
  `fired`, `clamped` (and which ceiling bit — the per-firing ceiling, the platform's per-game
502
579
  ceiling or its daily maximum across all games, or the player's holdings cap), or `suppressed`
@@ -526,13 +603,66 @@ What that means for game code:
526
603
  hold** — within a window it only rises, resetting or lowering the source progression never takes
527
604
  it back, and a player spending a granted currency does not refill your game's allowance.
528
605
 
606
+ ### Showing a payout before and after: `preview` and `fromReceipt`
607
+
608
+ **These two are the only way to show a platform payout.** Never multiply by `gem.player.boost`, and
609
+ never copy a config amount into your game's code: the platform retunes amounts and multipliers
610
+ without a release of your game, and both go stale the moment it does. The release review flags
611
+ hand-written multiplier code.
612
+
613
+ Before the claim, give the preview a **config id** — never an amount, never a pass:
614
+
615
+ ```js
616
+ const preview = await gem.gameConfig.preview({ recipe: 'open_chest' });
617
+ // or { progression: 'run_complete' } — an `on_complete` mapping
618
+ // or { progression: 'puzzles_cleared', add: 3 } — an `on_add` mapping, with the delta you will apply
619
+
620
+ for (const p of preview.platformGrants) {
621
+ // { currencySlug: 'ARCADE_TICKETS', base: 30, total: 45,
622
+ // breakdown: [{ source: 'pass', pass: 'vip', amount: 15 }], ifComplete?: true }
623
+ rewardRow.show(p.total, p.breakdown.map((b) => (b.source === 'pass' ? 'VIP' : 'Boost')));
624
+ }
625
+ for (const b of preview.bonuses) {
626
+ // { id, grants, eligible: true | false | 'unknown', unmet: [{ index, kind }] }
627
+ }
628
+ ```
629
+
630
+ - **`grants`**: the items and game currencies the recipe gives, and `{ kind: 'roll', lootTableId,
631
+ count }` for each roll — "1 roll from Common Chest", never what it will land on.
632
+ - **`platformGrants`**: each `platform_grants` mapping that fires, with `base` (no pass, no Boost),
633
+ `total` (this player) and the `breakdown` of the difference. `ifComplete: true` marks an
634
+ `on_complete` payout: it pays only if this claim completes the progression.
635
+ - **`bonuses`**: each bonus with `eligible` `true`, `false` or `'unknown'` — unknown when the SDK
636
+ cannot see what the requirement names. Draw unknown as neither won nor lost.
637
+ - **`upsells`**: what each pass on sale here would add. Empty means show no upsell
638
+ ([below](#offering-vip-before-a-reward-the-upsell)).
639
+ - **It is advisory.** The run decides: state can change between the preview and the claim, and the
640
+ platform's daily limits are not in it, because they are private.
641
+ - **Browser only.** It rejects a `PreviewError` with `not_found` for an id the config does not have,
642
+ `invalid_argument` for a target that is not one of the three shapes, and `unsupported` outside a
643
+ browser game.
644
+
645
+ After the claim, the order's `platform_grant` change is the receipt. `gem.earnings.fromReceipt`
646
+ turns it into the same shape, so one component draws "you will earn" and "you earned":
647
+
648
+ ```js
649
+ for (const change of order.changes) {
650
+ const paid = await gem.earnings.fromReceipt(change); // null for any other kind of change
651
+ if (paid) rewardRow.show(paid.total, paid.breakdown);
652
+ }
653
+ ```
654
+
655
+ `base` there is what a player with no pass and no Boost would have got under the same limits, so
656
+ the breakdown is exact even when a limit trimmed the payout. A function's `gem.earnings` and a
657
+ dedicated server's `gem.earnings` read receipts the same way.
658
+
529
659
  ### Achievements: the other kind of platform grant
530
660
 
531
661
  A `platform_grants` entry can also be an **achievement** — a platform-wide recognition with points,
532
662
  on the progression's completion or on one level of a track, that unlocks once per player when your
533
663
  server, or a serverless function that player called, moves the progression past it. Everything
534
664
  above about server-only writes applies — an achievement that awards points or rewards may not sit
535
- on a progression any operation in your config can raise, so a recipe priced in Gems can never sell
665
+ on a progression any operation in your config can raise, so a recipe priced in Coins can never sell
536
666
  one — and more: an achievement unlocks only on `release`, it never re-fires, and what it credits is
537
667
  fixed once released. Its points are never boosted. [achievements.md](achievements.md) is the whole of it.
538
668
 
@@ -581,9 +711,10 @@ nobody else can touch them. The arcade also has currencies that span every game,
581
711
 
582
712
  That changes what a shop can be. A player earns in someone else's game and spends in yours.
583
713
 
584
- There are two such currencies, and they are not interchangeable: **Gems** (`GEMS`) buy progress and
585
- **Coins** (`ARCADE_TICKETS`) buy cosmetics, and play items too where Coins' spend scope allows.
586
- [arcade-economy.md](arcade-economy.md) says which to use for what.
714
+ That currency is **Coins** (`ARCADE_TICKETS`): play earns them, the Arcade sells them, and games
715
+ price in them. **Gems are retiring** — reprice any `GEMS` price in Coins at 5 Coins per Gem and move
716
+ any Gem grant to Coins; [arcade-economy.md](arcade-economy.md) has the date's consequences and the
717
+ rest of the rules.
587
718
 
588
719
  ### Awarding one
589
720
 
@@ -613,13 +744,12 @@ A browser-only game cannot award a platform currency without a server **or a ser
613
744
  That is the price of the platform never trusting a client. A game with no dedicated server writes
614
745
  the source progression from a function the player calls, on a progression that opts in with
615
746
  `function_may_grant`, and the platform then pays that player only, in any listed platform currency
616
- — Gems (`GEMS`) and Coins (`ARCADE_TICKETS`) alike
747
+ — Coins (`ARCADE_TICKETS`)
617
748
  ([serverless-functions.md](serverless-functions.md#paying-the-player-who-called)).
618
749
 
619
750
  ### Showing the reward land in the arcade's wallet
620
751
 
621
- **Platform-currency display belongs to the arcade** (the creator's ruling, 2026-09-24) — Gems and
622
- Coins alike. The arcade shows the player's platform currencies in its own chrome — in the Gem
752
+ **Platform-currency display belongs to the arcade** (the creator's ruling, 2026-09-24). The arcade shows the player's platform currencies in its own chrome — in the Gem
623
753
  Arcade, the Gem menu the player opens from the Gem button over your frame; no wallet
624
754
  is drawn over your game — and moves a total when the platform confirms a grant. Your game keeps no counter or balance
625
755
  of a platform currency of its own, draws no flight or celebration of its own, and shows no
@@ -627,20 +757,19 @@ platform-currency amount at all when it is not connected to the arcade.
627
757
 
628
758
  What your game does is ask for the payout to start on **your** screen, from the currency's icon (or
629
759
  hero) beside the reward, so the currency flies from there into the arcade — in the Gem Arcade,
630
- into the Gem button, which pops as each mark lands. A Gems payout is cued from the gem:
760
+ into the Gem button, which pops as each mark lands. A Coins payout is cued from the coin:
631
761
 
632
762
  ```js
633
- const { x, y, width, height } = rewardGem.getBoundingClientRect(); // the gem element's own box
763
+ const { x, y, width, height } = rewardCoin.getBoundingClientRect(); // the coin element's own box
634
764
  gem.cueReward({
635
- currencySlug: 'GEMS',
765
+ currencySlug: 'ARCADE_TICKETS',
636
766
  expected: 15, // if you know it; drawn as provisional until the platform confirms
637
767
  origin: { x: x + width / 2, y: y + height / 2 }, // CSS px of your own viewport
638
768
  });
639
769
  ```
640
770
 
641
- `origin` is the **centre of the gem on screen** — measure the gem element itself, not the row or
642
- panel around it. A Coins payout is the same call with `currencySlug: 'ARCADE_TICKETS'`, from the
643
- coin: the slug decides what flies and which total moves. **A payout you do not cue still flies**
771
+ `origin` is the **centre of the coin on screen** — measure the coin element itself, not the row
772
+ or panel around it. The slug decides what flies and which total moves. **A payout you do not cue still flies**
644
773
  in the Gem Arcade, from the middle of your frame, once the platform confirms it — cue it so the
645
774
  flight starts where the player is looking. **If `cueReward` returns `false`, do nothing**: there is
646
775
  no fallback celebration.
@@ -658,10 +787,10 @@ reward once, whichever reaches it first, so you may cue before the call or after
658
787
  ```js
659
788
  const cueId = crypto.randomUUID(); // one per award, never reused
660
789
  const result = await gem.functions.invoke({ claim: 'daily' }, { cueId });
661
- gem.cueReward({ currencySlug: 'GEMS', expected: 15, origin, cueId });
790
+ gem.cueReward({ currencySlug: 'ARCADE_TICKETS', expected: 45, origin, cueId });
662
791
  ```
663
792
 
664
- A payout that pays Gems and Coins in one call cues each currency with the same `cueId`. The id is
793
+ A payout that pays more than one platform currency in one call cues each with the same `cueId`. The id is
665
794
  yours and opaque: 1 to 64 letters, digits, `_` or `-`, and the SDK leaves out anything else with a
666
795
  console warning rather than fail the cue or the call. It is for the animation only. It moves no
667
796
  balance, your function never sees it, and no receipt or order carries it.
@@ -721,14 +850,14 @@ gem.cueReward({ currencySlug: 'ARCADE_TICKETS', expected: 20, origin: claimCoinC
721
850
  the bridge is up (held and sent on connect) and under an embedder that presents every payout its
722
851
  own way; there, keep cueing as usual and nothing else changes.
723
852
 
724
- To draw the Gem on your own screen, use the platform's art, which ships in the package — the Gems
725
- **icon** beside an amount or price, the **hero** only on a reward presentation where the gem is the
726
- subject. [brand-art.md](brand-art.md) has both, how to draw them, and the rest of the rules.
853
+ To draw a Coin on your own screen, use the platform's art, which ships in the package — the Coins
854
+ **icon** beside an amount or price, the **hero** only on a reward presentation where the coin is
855
+ the subject. [brand-art.md](brand-art.md) has both, how to draw them, and the rest of the rules.
727
856
 
728
857
  ### Being paid in one
729
858
 
730
- **Spend platform currency with `gem.purchases.request`.** Every game sells for Gems, Coins or any
731
- other platform currency through the arcade's buy sheet: the platform confirms the purchase with the
859
+ **Spend platform currency with `gem.purchases.request`.** Every game sells for Coins, or any
860
+ other platform currency, through the arcade's buy sheet: the platform confirms the purchase with the
732
861
  player, offers a guest an account, and processes the order. Build your shop's Buy button on it.
733
862
 
734
863
  | Call | Who owns confirmation | Transport | Use it for |
@@ -754,7 +883,7 @@ const item = config.item('extra-life');
754
883
  const res = await gem.purchases.request({
755
884
  kind: 'item', // or 'recipe'
756
885
  targetId: item.id, // UUID, never the slug
757
- currencySlug: 'GEMS',
886
+ currencySlug: 'ARCADE_TICKETS',
758
887
  quantity: 1,
759
888
  });
760
889
 
@@ -768,11 +897,12 @@ The platform re-fetches the target and price, submits the order with the player'
768
897
  and verifies an `ok` before returning it to the game. Whether the player sees a sheet first depends
769
898
  on the currency:
770
899
 
771
- - **Gems and other platform currencies:** the arcade opens its purchase sheet, and nothing is spent
772
- until the player presses Buy.
900
+ - **Any other platform currency:** the arcade opens its purchase sheet, and nothing is spent until
901
+ the player presses Buy.
773
902
  - **Coins (`ARCADE_TICKETS`) the player can afford:** no sheet. The arcade spends them at once, does
774
903
  not pause your game, and says so over it ("Lantern purchased · 180 Coins left"). Coins are earned
775
- by playing, never bought, so the arcade does not ask.
904
+ by playing as well as bought, and a game's Coin spend needs no confirmation: the platform's
905
+ decision, unchanged since Coins went on sale.
776
906
  - **Coins the player cannot afford:** the arcade opens its sheet to say so, and answers `declined`
777
907
  with reason `insufficient_funds`.
778
908
 
@@ -824,7 +954,7 @@ backend it answers `undefined` by design: there, your placeholder is what you se
824
954
  The direct path submits an order from your game with no platform sheet. Its job is recipes paid in
825
955
  your own game currency or items (`payment: { kind: 'configured' }`, below). It also accepts a
826
956
  `platform_currency` payment, but **do not spend platform currency this way on gemarcade.com**: a
827
- web guest is refused (below), and every game spends Gems and Coins through
957
+ web guest is refused (below), and every game spends Coins through
828
958
  [the sheet](#platform-owned-purchase-sheet). The form is documented for games that have not moved
829
959
  yet:
830
960
 
@@ -835,7 +965,7 @@ const result = await gem.me.mutations.purchase({
835
965
  quantity: 1,
836
966
  payment: {
837
967
  kind: 'platform_currency',
838
- currencySlug: 'GEMS',
968
+ currencySlug: 'ARCADE_TICKETS',
839
969
  },
840
970
  });
841
971
 
@@ -905,7 +1035,7 @@ Do not retry an unsafe write after a timeout; re-read inventory or use the retur
905
1035
  ids for support.
906
1036
 
907
1037
  **A guest is refused `guest_upgrade_required`, and you draw nothing.** The platform lets only a
908
- player with an account spend Gems or Coins — which is why platform currency is spent through
1038
+ player with an account spend Coins — which is why platform currency is spent through
909
1039
  [the sheet](#platform-owned-purchase-sheet), where the arcade handles a guest itself. This is the
910
1040
  safety net for a game that still spends directly. When a guest's direct purchase is refused, the SDK
911
1041
  hands the attempt to the arcade, which offers the player an account over your game, and your call
@@ -943,7 +1073,7 @@ the same strings.
943
1073
 
944
1074
  | Code | What happened | What to do |
945
1075
  | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
946
- | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS` or `GEMS`; the direct path's message lists what is active |
1076
+ | `unknown_currency` | the slug is not an active platform currency | write `ARCADE_TICKETS`; the direct path's message lists what is active |
947
1077
  | `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` |
948
1078
  | `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 |
949
1079
  | `no_price_in_currency` | the target has no price in that currency | ask in the currency it is priced in, or add the price |
@@ -964,24 +1094,21 @@ inventory, never on a reason, and do not retry because of one.
964
1094
  ⚠️ **Never draw a top-up or payment screen of your own** — no checkout, no wallet top-up, nothing
965
1095
  that looks like the arcade's purchase sheet or wallet. A game screen shaped like platform payment
966
1096
  chrome is an imitation of it. Your Buy button is yours, and taking a player's money is the
967
- arcade's alone: a "Get Gems" button of yours calls `gem.store.purchase` from the player's press, a
968
- "Get VIP" button calls `gem.store.showOffer`, and the arcade store's own checkout does the rest
1097
+ arcade's alone: a "Get Coins" button of yours calls `gem.store.purchase` with a Coin pack from the
1098
+ player's press, a "Get VIP" button calls `gem.store.showPass('vip')`, and the arcade store's own checkout does the rest
969
1099
  ([arcade-and-store.md](arcade-and-store.md)). An
970
1100
  `insufficient_funds` refusal never opens it by itself.
971
1101
 
972
- ### What Coins and Gems may buy: the spend scope
1102
+ ### What Coins may buy: the spend scope
973
1103
 
974
- The arcade has two currencies a player spends: **Gems**, for progress, and **Coins**
975
- (`ARCADE_TICKETS`), earned by play, for cosmetics and, since 25 Oct 2026, play items too. What
976
- each may price is decided by two settings: each platform currency's **spend scope**, which the
977
- platform sets, and each of your items' **category**, which you set.
1104
+ What a platform currency may price is decided by two settings: the currency's **spend scope**, which
1105
+ the platform sets, and each of your items' **category**, which you set.
978
1106
 
979
- - A currency whose spend scope is **`cosmetic_only`** may price cosmetics only. Coins' is, until
980
- the platform moves it to `any` to let Coins price play items.
981
- - A currency whose spend scope is **`any`** may price anything. Gems' is.
1107
+ - A currency whose spend scope is **`any`** may price anything. Coins' is.
1108
+ - A currency whose spend scope is **`cosmetic_only`** may price cosmetics only.
982
1109
 
983
- The platform owns each currency's scope and can change it, so do not hard-code which is which: a
984
- refusal always names the scope that applied.
1110
+ The platform owns each currency's scope and can change it, so do not hard-code it: a refusal always
1111
+ names the scope that applied.
985
1112
 
986
1113
  ```yaml
987
1114
  items:
@@ -990,27 +1117,28 @@ items:
990
1117
  category: cosmetic # changes only how the player looks
991
1118
  platform_prices:
992
1119
  - currency: ARCADE_TICKETS
993
- price: 120
1120
+ price: 1500
994
1121
  - id: '…'
995
1122
  slug: extra-life
996
1123
  # category omitted: `progression`, the default
997
1124
  platform_prices:
998
- - currency: GEMS
999
- price: 25
1125
+ - currency: ARCADE_TICKETS
1126
+ price: 125
1000
1127
  ```
1001
1128
 
1002
1129
  - **`category`** is `cosmetic` — skins, outfits, visual effects, decorations, emotes, profile
1003
1130
  cosmetics: anything that changes how the player looks and never what they can do — or
1004
1131
  `progression`, anything that affects play. It defaults to `progression`, so an item is only ever a
1005
1132
  cosmetic because you said so.
1006
- - **A price in a `cosmetic_only` currency, such as Coins, is accepted only on a cosmetic.** On an
1007
- item, that item must be `category: cosmetic` and a plain `item_type: item` — a `bundle` or
1008
- `loot_box` opens into things its own row does not describe, so it cannot be sold for Coins.
1009
- - **A recipe priced in a `cosmetic_only` currency must grant at least one cosmetic item and nothing
1010
- else.** It is refused if it grants any currency, moves any progression, grants a non-cosmetic item,
1011
- or rolls a loot table — a loot table is refused for now even when every entry is cosmetic. Taking
1012
- from the player as part of the recipe (`currency_consume`, `item_remove`) is fine.
1013
- - **A currency whose scope is `any`, such as Gems, may price anything**, cosmetic or not.
1133
+ - **Under a `cosmetic_only` scope, a price is accepted only on a cosmetic.** On an item, that item
1134
+ must be `category: cosmetic` and a plain `item_type: item`; a recipe must grant at least one
1135
+ cosmetic item and nothing else — no currency, no progression, no non-cosmetic item, no loot table.
1136
+ Taking from the player as part of the recipe (`currency_consume`, `item_remove`) is fine.
1137
+ - **Under `any`, a price may go on anything**, cosmetic or not.
1138
+ - **A paid roll is gated.** A recipe priced in Coins that rolls a loot table, or has a bonus that
1139
+ rolls one, is a paid random item: the arcade's sheet shows the odds before the player pays, and
1140
+ the platform refuses it with `paid_chance_restricted` for a known minor and in restricted regions
1141
+ (§12).
1014
1142
 
1015
1143
  The upload refuses a config that breaks the rule. The refusal names the currency and its scope, and
1016
1144
  the offending item or operation, so you know what to change:
@@ -1025,8 +1153,8 @@ purchase catalog stops listing that currency's price on anything the scope does
1025
1153
  purchase naming such a price is refused `not_purchasable` before anything is charged.
1026
1154
 
1027
1155
  The category is a declaration the platform takes at your word, and the release review checks it.
1028
- Labelling something that affects play `cosmetic` so it can be sold for Coins breaks platform policy,
1029
- whatever the scope: a play item sold for Coins keeps `category: progression`.
1156
+ Labelling something that affects play `cosmetic` breaks platform policy, whatever the scope: a play
1157
+ item sold for Coins keeps `category: progression`.
1030
1158
 
1031
1159
  **To sell a cosmetic that needs game progress first, sell it through a recipe.** An item's own
1032
1160
  `platform_prices` cannot carry prerequisites; a recipe's can. Grant the cosmetic from a recipe that
@@ -1048,27 +1176,70 @@ catalogs:
1048
1176
  item_definition_slug: dragon-skin
1049
1177
  platform_prices:
1050
1178
  - currency: ARCADE_TICKETS
1051
- price: 120
1179
+ price: 1500
1052
1180
  ```
1053
1181
 
1054
1182
  A player below level 10 is refused `recipe_locked` before any Coins are taken, and
1055
1183
  `recipeAvailability` tells your shop the recipe is locked and what it needs (§9) — so show the lock
1056
1184
  ("Reach level 10 to unlock") instead of offering the purchase.
1057
1185
 
1186
+ ### Offering VIP before a reward: the upsell
1187
+
1188
+ The platform holds nothing back for later, so a game that wants to sell VIP does it **before** it
1189
+ grants: it holds the reward, shows what VIP would add, offers it, then runs the recipe.
1190
+
1191
+ ```js
1192
+ async function openChest() {
1193
+ const preview = await gem.gameConfig.preview({ recipe: 'open_chest' });
1194
+ const upsell = preview.upsells[0]; // empty: no pass on sale here would add anything
1195
+ if (upsell && (await vipCard.ask(upsell)) === 'get') {
1196
+ // The card shows upsell.name and its locked extras: "+1 roll · 45 Coins".
1197
+ const { outcome, reason } = await gem.store.showPass(upsell.pass);
1198
+ if (outcome === 'ok' && reason === 'pending') note('VIP will apply once it is confirmed');
1199
+ }
1200
+ vipCard.focusReturnsTo(openButton);
1201
+ await runRecipe(); // the bonus applies if VIP is now held, and the base runs either way
1202
+ }
1203
+ ```
1204
+
1205
+ `showPass` answers `ok` only once a re-read shows the pass held, so a recipe run after it gets the
1206
+ bonus the player just paid for. `pending` (Ask to Buy, or a payment not yet settled) means run it
1207
+ now without the bonus. `declined` or `unavailable` means run it as it is — and `unavailable` or
1208
+ `error` may still mean the player paid, so read `gem.me.passes` again rather than telling them
1209
+ they did not.
1210
+
1211
+ `upsells` is empty for a minor, a store that is off, a pass the player declined twice today or
1212
+ already holds, and anywhere the store sells no pass: draw nothing then. A guest **does** get
1213
+ upsells, and buying takes them through sign-up first.
1214
+
1215
+ **The rules, every one a must:**
1216
+
1217
+ - **Upsell only before granting**, and at most once per reward moment. Never after.
1218
+ - **Give Get VIP and Continue equal weight.** Neither is the default, the larger or the brighter.
1219
+ - **Never show the outcome of a locked roll.** Show "+1 roll", never what it would land on.
1220
+ - **Disclose the odds** for a bonus roll, as for any roll ([§12](#12-loot-tables-and-odds)).
1221
+ - **No countdowns, no "expires" pressure, and no price** in your game. The Arcade's sheet shows the
1222
+ price.
1223
+ - **Show a lock as an icon plus text**, never colour alone.
1224
+ - **Return focus to the button** that opened the sheet when it closes.
1225
+ - **Announce an applied bonus in a polite live region** (`aria-live="polite"`).
1226
+ - **Use the preview's numbers and `upsell.name`**, never your own copy of them, and never a
1227
+ literal `'vip'`: pass `upsell.pass` straight to `showPass`.
1228
+
1058
1229
  ### Reading the player's balance
1059
1230
 
1060
1231
  ```js
1061
1232
  const currencies = await gem.platform.currencies.get();
1062
- const gems = currencies.find((currency) => currency.slug === 'GEMS');
1063
- buyButton.disabled = !gems || gems.balance < price; // can the player afford it?
1233
+ const coins = currencies.find((currency) => currency.slug === 'ARCADE_TICKETS');
1234
+ buyButton.disabled = !coins || coins.balance < price; // can the player afford it?
1064
1235
  ```
1065
1236
 
1066
1237
  **Read it to decide, never to display.** Platform-currency display belongs to the arcade: its
1067
1238
  wallet — the Gem menu, in the Gem Arcade, and beside the Gem for a game that asks with
1068
1239
  `gem.setWalletVisible(true)` ([the Arcade's wallet](what-a-game-must-do.md#the-arcades-wallet-in-the-gem-menu-and-beside-the-gem-when-you-ask))
1069
- — shows the player's Gems and Coins totals while your game runs, and your game keeps no
1070
- counter, balance or wallet total of either of its own ([brand-art.md](brand-art.md) §5). Use the
1071
- balance to enable a purchase or to say "not enough Gems" — not to draw a number.
1240
+ — shows the player's Coins total while your game runs, and your game keeps no counter, balance or
1241
+ wallet total of its own ([brand-art.md](brand-art.md) §5). Use the balance to enable a purchase or
1242
+ to say "not enough Coins" — not to draw a number.
1072
1243
 
1073
1244
  Every game receives every active platform currency. Each row has `access: 'spend'`; the field stays
1074
1245
  in the response shape for existing consumers.
@@ -1084,27 +1255,27 @@ in the response shape for existing consumers.
1084
1255
 
1085
1256
  ```js
1086
1257
  const allowances = await gem.platform.earnAllowance.get();
1087
- const gems = allowances.find((a) => a.currencySlug === 'GEMS');
1258
+ const coins = allowances.find((a) => a.currencySlug === 'ARCADE_TICKETS');
1088
1259
  // 0 or less once `resetsAt` has passed: the read is stale, so say nothing about when.
1089
- const hours = gems?.resetsAt ? Math.ceil((gems.resetsAt - Date.now()) / 3_600_000) : 0;
1090
- if (gems && gems.remaining > 0) {
1260
+ const hours = coins?.resetsAt ? Math.ceil((coins.resetsAt - Date.now()) / 3_600_000) : 0;
1261
+ if (coins && coins.remaining > 0) {
1091
1262
  hud.setText(
1092
- `You can still earn ${gems.remaining} Gems` + (hours > 0 ? `, more in ${hours}h` : ''),
1263
+ `You can still earn ${coins.remaining} Coins` + (hours > 0 ? `, more in ${hours}h` : ''),
1093
1264
  );
1094
- } else if (gems?.resetsAt) {
1265
+ } else if (coins?.resetsAt) {
1095
1266
  // Spent for now: say when more arrives, never "0 left".
1096
- hud.setText(hours > 0 ? `More Gems in ${hours}h` : 'More Gems soon');
1267
+ hud.setText(hours > 0 ? `More Coins in ${hours}h` : 'More Coins soon');
1097
1268
  }
1098
1269
  ```
1099
1270
 
1100
- One entry per earned platform currency (Gems and Coins today), for **this game and this player**:
1271
+ One entry per earned platform currency (Coins, today), for **this game and this player**:
1101
1272
  how much more your game can still grant before the platform's ceilings trim the grant. It is the
1102
1273
  lower of what is left of your game's own cap and what is left of the player's daily maximum across
1103
1274
  all games, with the player's Boost applied — one figure, never a breakdown.
1104
1275
 
1105
1276
  | Field | What it is |
1106
1277
  | -------------- | ------------------------------------------------------------------------------------------------- |
1107
- | `currencySlug` | `GEMS`, `ARCADE_TICKETS`, … (`''` if the platform sent none) |
1278
+ | `currencySlug` | `ARCADE_TICKETS`, … (`''` if the platform sent none) |
1108
1279
  | `currencyId` | The permanent id; joins to `gem.platform.currencies` and `platform_prices[].platform_currency_id` |
1109
1280
  | `limit` | The binding ceiling: your game's own allowance, or the daily maximum across every game |
1110
1281
  | `earned` | How much of that ceiling has been paid, which may include other games' payouts |
@@ -1127,8 +1298,8 @@ end of the reported period when both of the platform's ceilings are spent.
1127
1298
 
1128
1299
  **Display only. Never gate gameplay or a grant on it.**
1129
1300
 
1130
- - ✅ ``hud.setText(`${gems.remaining} Gems left to earn today`)``
1131
- - ❌ `if (gems.remaining > 0) awardDailyBonus()`
1301
+ - ✅ ``hud.setText(`${coins.remaining} Coins left to earn today`)``
1302
+ - ❌ `if (coins.remaining > 0) awardDailyBonus()`
1132
1303
 
1133
1304
  Grant anyway: the platform trims every grant to what is left, and the receipt is the authority on
1134
1305
  what landed. The figure can be stale the moment after you read it — another game may pay the player
@@ -1182,8 +1353,8 @@ remove it. A player spends it through the platform, by choosing to.
1182
1353
 
1183
1354
  **You cannot sell for real money here.** A platform currency a player _earns_ is what this surface
1184
1355
  reaches. Charging actual money is a separate platform capability and is not something a game config
1185
- grants itself. What a game _can_ do is offer the arcade store's own Gem packs, Boosts, passes and
1186
- bundles from a button of its own, through `gem.store`
1356
+ grants itself. What a game _can_ do is offer the arcade store's own Coin packs, passes such as VIP
1357
+ and bundles from a button of its own, through `gem.store`
1187
1358
  ([arcade-and-store.md](arcade-and-store.md)): the arcade prices them and takes the money.
1188
1359
 
1189
1360
  ## Errors
@@ -1201,8 +1372,8 @@ bundles from a button of its own, through `gem.store`
1201
1372
 
1202
1373
  ## What to read next
1203
1374
 
1204
- - [`arcade-economy.md`](arcade-economy.md) — Gems, Coins and achievement points: which to use for
1205
- what, and the rules each carries.
1375
+ - [`arcade-economy.md`](arcade-economy.md) — Coins and achievement points: which to use for what,
1376
+ the rules each carries, and Gems' retirement.
1206
1377
  - [`achievements.md`](achievements.md) — platform achievements on your progressions' thresholds.
1207
1378
  - [`game-economy-tutorial.md`](game-economy-tutorial.md) — build a shop, step by step, the safe way.
1208
1379
  - [`trading-and-gifting.md`](trading-and-gifting.md) — move items and currency between players.