@indiegems/gem-web-sdk 23.3.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  # Arcade features and the Arcade Store
2
2
 
3
3
  Your game can reach a few things the Arcade owns: its own app and yours in the app stores, another
4
- game, games like yours, and the **Arcade Store**, which sells Gem packs, Boosts, passes and
5
- bundles for real money.
4
+ game, games like yours, and the **Arcade Store**, which sells Coin packs, passes such as VIP,
5
+ Boosts and bundles for real money. **Only the Arcade sells Coins**: a game never sells currency
6
+ for money.
6
7
 
7
8
  ```js
8
9
  // From the press on your own "More games like this" card.
@@ -12,17 +13,17 @@ async function onCardPressed(card) {
12
13
  if (outcome !== 'ok') showCardAsUnavailable(card);
13
14
  }
14
15
 
15
- // From the press on your own "Get Gems" button.
16
+ // From the press on your own "Get Coins" button.
16
17
  async function onBuyPressed(offer) {
17
18
  const { outcome, reason } = await gem.store.purchase(offer.skuId);
18
19
  // Read through gem.me; nothing here carries an amount. `error` is "outcome unknown" (§9).
19
20
  if (outcome === 'ok' || reason === 'error') await refreshBalances();
20
21
  }
21
22
 
22
- // From the press on your own "Get VIP" button: the Arcade draws the offer.
23
+ // From the press on your own "Get VIP" button: the Arcade draws the pass, found by its slug.
23
24
  async function onVipPressed() {
24
- const { outcome, reason } = await gem.store.showOffer(VIP_SKU);
25
- if (outcome === 'ok' || reason === 'error') await refreshBalances();
25
+ const { held } = await gem.store.showPass('vip');
26
+ if (held) drawVipBadge(); // a fresh read already shows VIP held (§10)
26
27
  }
27
28
  ```
28
29
 
@@ -51,10 +52,10 @@ itself. You get an outcome.
51
52
  | Money | none | **real money**, through the Arcade Store's checkout |
52
53
  | Who draws it | the Arcade (a new tab, a QR code, the next game) | your card or the Arcade's sheet, then the checkout |
53
54
 
54
- `gem.store` is not `gem.purchases`. **`gem.purchases` spends Gems or Coins on your game's own
55
- items** ([`inventory-and-catalogs.md`](inventory-and-catalogs.md)). **`gem.store` sells the Arcade
56
- Store's own SKUs for the player's money.** A game that wants a "Get Gems" button uses `gem.store`;
57
- a game selling a sword for 50 Gems uses `gem.purchases`.
55
+ `gem.store` is not `gem.purchases`. **`gem.purchases` spends Coins on your game's own items**
56
+ ([`inventory-and-catalogs.md`](inventory-and-catalogs.md)). **`gem.store` sells the Arcade Store's
57
+ own SKUs for the player's money.** A game that wants a "Get Coins" button uses `gem.store`; a game
58
+ selling a sword for 250 Coins uses `gem.purchases`.
58
59
 
59
60
  ## 2. Nothing rejects
60
61
 
@@ -78,7 +79,7 @@ An Arcade that does not offer a feature answers every call for it at once with `
78
79
  ```js
79
80
  if (gem.arcade.available.openGame) showMoreGamesSection();
80
81
  if (gem.arcade.available.appLinks) showGetTheAppButton();
81
- if (gem.store.available) showGetGemsButton();
82
+ if (gem.store.available) showGetCoinsButton();
82
83
  if (gem.store.showOfferAvailable) showGetVipButton();
83
84
  ```
84
85
 
@@ -222,16 +223,26 @@ policy refused. Allow that host rather than a wildcard.
222
223
  ## 8. The Arcade Store's offers
223
224
 
224
225
  ```js
225
- const { outcome, reason, offers } = await gem.store.offers();
226
- if (outcome === 'unavailable' && reason === 'sign_in_required' && gem.account.signInAvailable)
227
- showSignInButton(); // gem.account.requestSignIn
228
- for (const offer of offers) drawOffer(offer);
226
+ const { outcome, offers } = await gem.store.offers();
227
+ for (const offer of offers) if (offer.kind === 'coin_pack') drawCoinPack(offer);
229
228
  ```
230
229
 
231
- `offers()` lists what the Arcade Store sells this player, here, right now: Gem packs, Boosts,
232
- passes and bundles. Each offer has a `skuId`, a `kind` (`gem_pack`, `boost`, `pass` or `bundle`),
233
- the store's own `name` and `description`, an optional `imageUrl`, a `priceDisplay`, and, for a
234
- Gem pack, a `grant` naming the currency and amount.
230
+ `offers()` lists what the Arcade Store sells this player, here, right now: Coin packs, passes,
231
+ Boosts and bundles. Each offer has:
232
+
233
+ - a `skuId`, which `purchase` and `showOffer` take;
234
+ - a `kind`: `coin_pack`, `pass`, `boost` or `bundle`. `gem_pack` is deprecated: Gems are being
235
+ retired and a Gem pack is no longer sold. Keep a `default` arm;
236
+ - the store's own `name` and `description`, an optional `imageUrl`, and a `priceDisplay`;
237
+ - for a Coin pack, a `grant` naming the currency (`ARCADE_TICKETS`) and the amount;
238
+ - for a pass, its slug in `pass` (VIP is `vip`), which `showPass` takes;
239
+ - `earnMultipliers`: for an offer that makes the player earn a currency faster while they hold it,
240
+ such as VIP's +50% Coins, one `{ currencySlug, multiplierBp }` per currency. Empty otherwise.
241
+ **Never multiply a reward by it yourself**; the platform applies it when it pays.
242
+
243
+ **A guest is answered too.** Buying takes a guest through sign-up in the Arcade's own screens, then
244
+ back to what they chose, so a guest sees your "Get Coins" and "Get VIP" buttons. An older Arcade
245
+ answers a guest `unavailable` / `sign_in_required`: hide the buttons there.
235
246
 
236
247
  - **Show `priceDisplay` exactly as given.** It is the payment rail's own price in the player's
237
248
  currency, already formatted. Never parse it, compare two of them, or compute a discount from it.
@@ -295,16 +306,17 @@ arrives, so your own celebration is visible.
295
306
 
296
307
  ```js
297
308
  // Decide on the screen you draw the button on.
298
- const { offers } = await gem.store.offers();
299
- const vip = offers.find((offer) => offer.skuId === VIP_SKU);
300
- if (gem.store.showOfferAvailable && vip) showGetVipButton();
309
+ if (gem.store.showOfferAvailable && (await gem.store.passOnSale('vip'))) showGetVipButton();
301
310
  else hideGetVipButton();
302
311
 
303
312
  // From the press on that button.
304
- const { outcome, reason } = await gem.store.showOffer(VIP_SKU);
305
- if (outcome === 'ok' || reason === 'error') await refreshBalances();
313
+ const { outcome, reason, held } = await gem.store.showPass('vip');
306
314
  ```
307
315
 
316
+ For a pass, use **`showPass(slug)`**: it finds the pass's offer by its slug and shows it, so your
317
+ game never names a SKU ([below](#a-pass-by-its-slug-showpass)). `showOffer(skuId)` is the same
318
+ sheet for any other SKU, such as a bundle.
319
+
308
320
  `showOffer(skuId)` asks the Arcade to draw one SKU from `offers()` in its own sheet, over your paused
309
321
  and hidden game. The Arcade draws the art, what the player gets, a sale if one is running, and, for a
310
322
  pass the player already holds, how long it has left. The sheet's Buy opens the same checkout
@@ -312,13 +324,13 @@ pass the player already holds, how long it has left. The sheet's Buy opens the s
312
324
 
313
325
  Pick by who can say what the offer is:
314
326
 
315
- | | **You draw it: `offers()` + `purchase()`** | **The Arcade draws it: `showOffer()`** |
316
- | --------------- | --------------------------------------------------- | --------------------------------------------------------------- |
317
- | Use it for | Gem packs: a name, an amount and a price say it all | VIP and other passes, and bundles |
318
- | Your button | says what it buys and shows `priceDisplay` | says what it opens, such as "Get VIP" |
319
- | The press opens | the checkout, straight away | the Arcade's sheet, then the checkout if the player presses Buy |
320
- | Perks and sales | not available to you | always current, in the player's language |
321
- | A holder sees | the same Buy button | what they already have |
327
+ | | **You draw it: `offers()` + `purchase()`** | **The Arcade draws it: `showPass()` or `showOffer()`** |
328
+ | --------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
329
+ | Use it for | Coin packs: a name, an amount and a price say it all | VIP and other passes (`showPass`), and bundles |
330
+ | Your button | says what it buys and shows `priceDisplay` | says what it opens, such as "Get VIP" |
331
+ | The press opens | the checkout, straight away | the Arcade's sheet, then the checkout if the player presses Buy |
332
+ | Perks and sales | not available to you | always current, in the player's language |
333
+ | A holder sees | the same Buy button | what they already have, and how to add time |
322
334
 
323
335
  - **Never write an offer's perks into your game.** They change without a new build of your game, and
324
336
  yours would go stale. Your button only needs the offer's name.
@@ -328,7 +340,9 @@ Pick by who can say what the offer is:
328
340
  - **The sheet does not tell you what the player holds.** It tells the player, so a button that
329
341
  stays up for a holder is fine: they see what they already have, and choose from there. To change
330
342
  your own screen for a holder, read `gem.me.passes` ([§11](#11-what-the-player-holds-and-a-vip-door)).
331
- - **Hide the button when the SKU is not in `offers()`.** A SKU that is not on sale here answers
343
+ - **Hide the button when the pass is not on sale.** `passOnSale(slug)` says so, and is `false`
344
+ where the store is off, for a player the platform won't sell it to, once the player has declined
345
+ its sheet twice today, and on an older Arcade. A pass that is not on sale answers
332
346
  `unavailable` / `not_found`, and the player would have pressed a button that does nothing.
333
347
  - **Read `showOfferAvailable`, not `available`.** An older Arcade can sell through `purchase` and
334
348
  still not draw this sheet. It answers `unavailable` / `unsupported` and sends nothing. Hide the
@@ -337,16 +351,72 @@ Pick by who can say what the offer is:
337
351
  Every rule of `purchase` holds ([§9](#9-buying-from-the-arcade-store)), and the answers are the
338
352
  same, with one addition. `declined` / `cancelled` also covers the player leaving the sheet without
339
353
  pressing Buy, and you are not told which of the two they closed. Nothing backs off: the player's
340
- next press on either button opens it again.
354
+ next press on either button opens it again. A pass is the exception: after the player declines its
355
+ sheet **twice in a day**, the Arcade stops offering it to them until tomorrow, and `passOnSale`
356
+ answers `false`. Your game cannot reset that count.
357
+
358
+ ### A pass, by its slug: `showPass`
359
+
360
+ `showPass(slug)` is `offers()` plus `showOffer()`, with one promise of its own: **`ok` means a fresh
361
+ read of `gem.me.passes` shows the pass held.** A recipe or claim your game runs next gets the pass's
362
+ bonus, with no reload. That makes it safe to call at the moment the player is about to claim
363
+ something:
364
+
365
+ | Answer | `held` | What happened | Then |
366
+ | ----------------------------- | ------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ |
367
+ | `ok` | `true` | the player bought it, and it is held now | run the recipe or claim: the bonus applies |
368
+ | `ok` / `pending` | `false` | the player paid, and the pass has not arrived yet (Ask to Buy, say) | run it now, without the bonus; nothing is held back for later |
369
+ | `declined` / `cancelled` | `false` | they closed the sheet | run it as it is, and don't ask again at this moment |
370
+ | `unavailable` / `not_found` | `false` | not on sale to this player here, or an older Arcade | run it as it is, and hide the button |
371
+ | `unavailable` / `unsupported` | `false` | this Arcade draws no sheet | run it as it is, and hide the button |
372
+ | `unavailable` / `error` | `false` | **the outcome is unknown, and the player may have paid** | run it; `gem.me.passes.on('changed')` hears the pass if it arrives |
373
+
374
+ - **Pass it the slug the platform gave you,** never a SKU. VIP is `vip`.
375
+ - **A guest** goes through sign-up first, then back to the sheet. A guest who signs up and buys
376
+ answers `ok` like anyone else.
377
+ - **After a paid sheet it waits a little.** It reads `gem.me.passes` for about ten seconds before it
378
+ settles for `pending`, because the payment provider's notice can trail the checkout.
379
+ - **A player who already holds the pass** sees how long they have left and can add time.
380
+
381
+ ### At a reward: the VIP extra roll
382
+
383
+ The moment a pass sells best is the moment it would pay: a chest the player is about to open, where
384
+ VIP adds a roll. **Your game never names the pass and never works out the extra.** It asks
385
+ `gem.gameConfig.preview` what the claim gives, and the answer's `upsells` says what each pass on
386
+ sale here would add:
387
+
388
+ ```js
389
+ // The recipe carries `bonuses: [{ id: 'vip_roll', requires: [{ kind: 'holds_pass', pass: 'vip' }],
390
+ // grants: [{ type: 'roll', loot_table_id: '<chest_common>', count: 1 }] }]` in your game config.
391
+ openButton.onclick = async () => {
392
+ const preview = await gem.gameConfig.preview({ recipe: 'open_chest' });
393
+ const upsell = preview.upsells[0];
394
+ if (upsell) {
395
+ // "VIP: +1 roll, 45 Coins" — upsell.name, and the extras locked, never what a roll would land on.
396
+ const choice = await vipCard.ask(upsell); // Get VIP and Continue, with equal weight
397
+ if (choice === 'get') await gem.store.showPass(upsell.pass);
398
+ openButton.focus(); // focus goes back where it was
399
+ }
400
+ const order = await gem.me.mutations.executeRecipe(CHESTS, 'open_chest');
401
+ // The base always runs; the VIP roll applies if the pass is held now.
402
+ };
403
+ ```
404
+
405
+ `upsells` is already empty wherever no upsell may be shown: for a minor, a store that is off, a pass
406
+ the player declined twice today or already holds, and a claim no pass would change. So an empty
407
+ list means draw no card. **Upsell only before the grant**, once per reward moment: the platform
408
+ holds nothing back, so a pass bought afterwards cannot reach a chest already opened.
409
+ [inventory-and-catalogs.md](inventory-and-catalogs.md#offering-vip-before-a-reward-the-upsell) has
410
+ the rest of the rules, every one a must, and `bonuses` in full.
341
411
 
342
412
  ## 11. What the player holds, and a VIP door
343
413
 
344
414
  ```js
345
415
  const passes = await gem.me.passes.list();
346
- // [{ pass: 'vip', held: true, endsAt: '2026-11-29T12:34:56Z' }]
416
+ // [{ pass: 'vip', held: true, daysLeft: 7, endsAt: '2026-11-29T12:34:56Z' }]
347
417
 
348
418
  const vip = await gem.me.passes.get('vip'); // one pass by its slug
349
- if (vip.held) drawVipBadge();
419
+ if (vip.held) drawVipBadge(vip.daysLeft === 7 ? '7+ days left' : `${vip.daysLeft} days left`);
350
420
  ```
351
421
 
352
422
  `gem.me.passes` says which store passes the player holds. **VIP is the pass `vip`**, its offering
@@ -355,12 +425,15 @@ slug. Key on the slug, which is stable and not localized, and never on a display
355
425
  - **It lists every pass games may see, held or not.** An admin marks a pass as visible to games, and
356
426
  the list has one entry for each, so its shape never says what the player bought. A pass that is not
357
427
  marked is left out, and `get` reads a pass that is not listed as not held.
358
- - **A pass is a slug, whether it is held, and when it ends.** No price, no name, no perks, nothing
359
- about how the player got it, and nothing about a pass that has ended.
360
- - **`endsAt` is the exact end,** grace and any queued period included, and `null` when the pass is not
361
- held or is held with no end, which support can grant. Read `held` first: `null` alone never means
362
- ended.
363
- - **A guest reads as not holding.** Every pass is `held: false`, and that is no error.
428
+ - **A pass is a slug, whether it is held, and how many days it has left.** No price, no name, no
429
+ perks, nothing about how the player got it, and nothing about a pass that has ended.
430
+ - **`daysLeft` is whole days, rounded up and capped at 7**: `7` means "7 or more", and is also what
431
+ a pass held with no end reads (support can grant one). `0` when it is not held. Show `7` as
432
+ "7+ days left", never as a date.
433
+ - **`endsAt` is deprecated.** It is the exact end, to the second, and two games could match that
434
+ value to link one player's accounts, so the platform will stop sending it. Use `daysLeft`.
435
+ - **A guest holds nothing.** When the Arcade says the player is a guest, every pass reads
436
+ `held: false` without a call to the platform, and that is no error.
364
437
  - **A read that fails rejects with a `GemApiError`, as every `gem.me` read does.** That is unknown,
365
438
  not "not held", so keep what you drew. It is the one call on this page that rejects.
366
439
  - **It is a read over your play token,** not an ask of the Arcade. It needs no press, and works
@@ -402,7 +475,7 @@ door.onPress = async () => {
402
475
  };
403
476
 
404
477
  // 3. BUYING. From the press on your own "How to get VIP" button.
405
- howToGetVipButton.onclick = () => gem.store.showOffer(VIP_SKU);
478
+ howToGetVipButton.onclick = () => gem.store.showPass('vip');
406
479
  ```
407
480
 
408
481
  The function, in Rust, because only the platform's answer counts:
@@ -440,26 +513,32 @@ Ok(answer.to_string().into_bytes())
440
513
  "Me" on that route is the player who invoked the function. A function your server invoked has no
441
514
  player, and the route refuses it with `403`. The body is `{ pass, held, ends_at }` for each pass.
442
515
 
516
+ **Gate on the pass, never on the boost.** VIP also shows in `gem.player.boost` as +50% Coins, but a
517
+ boost is display-only and a player can hold one without VIP. What a pass unlocks is decided by
518
+ `held` on this read, or by a `requires: holds_pass` on your config, checked by the platform
519
+ ([platform rules §8](platform-rules.md#8-a-players-boost-is-for-display-never-a-gate)).
520
+
443
521
  - **If the door and the function disagree, the function is right.** The player sees "How to get VIP"
444
522
  or the lounge, and either way nobody got in by editing the page. Redraw the door when the function
445
523
  says no.
446
- - **A guest has no VIP and cannot buy it from here.** Their door is shut, and `offers()` answers
447
- `unavailable` / `sign_in_required`, so offer `gem.account.requestSignIn` instead of the button,
448
- when `gem.account.signInAvailable` says the Arcade can show them a sign-in.
524
+ - **A guest has no VIP, and can buy it from here.** Their door is shut, and `showPass('vip')` takes
525
+ them through sign-up, then back to the sheet. On an older Arcade, which answers a guest
526
+ `unavailable` / `sign_in_required`, offer `gem.account.requestSignIn` instead, when
527
+ `gem.account.signInAvailable` says the Arcade can show them a sign-in.
449
528
 
450
529
  ### When the door moves by itself
451
530
 
452
531
  While something listens to `on('changed')`, the SDK reads again at each of these, and calls you only
453
532
  if the answer differs:
454
533
 
455
- | Moment | Why you cannot rely on something else |
456
- | -------------------------------------------------- | ------------------------------------------------------------------------------ |
457
- | `store.purchase` or `store.showOffer` answers `ok` | `onSettled` does not fire after a plain `ok` |
458
- | Either answers `unavailable` / `error` | The outcome is unknown, and the player may have paid |
459
- | `gem.store.onSettled` fires | `ok` / `pending` still reads as not held |
460
- | Your game resumes | A purchase in the Arcade's own store, from the Gem menu, sends no event at all |
461
- | The player signs in | A guest who signs in may hold VIP |
462
- | A held pass reaches `endsAt` | VIP does not renew, so the door has to lock by itself |
534
+ | Moment | Why you cannot rely on something else |
535
+ | --------------------------------------------------- | ------------------------------------------------------------------------------ |
536
+ | `store.purchase`, `showOffer` or `showPass` is `ok` | `onSettled` does not fire after a plain `ok` |
537
+ | Either answers `unavailable` / `error` | The outcome is unknown, and the player may have paid |
538
+ | `gem.store.onSettled` fires | `ok` / `pending` still reads as not held |
539
+ | Your game resumes | A purchase in the Arcade's own store, from the Gem menu, sends no event at all |
540
+ | The player signs in | A guest who signs in may hold VIP |
541
+ | A held pass reaches `endsAt` | VIP does not renew, so the door has to lock by itself |
463
542
 
464
543
  A read that fails changes nothing, and is tried again later. A read that was already on its way when
465
544
  a purchase ended is never reused, so a stale answer cannot shut the door on the buyer. If you read
@@ -469,14 +548,36 @@ without a listener, call `list()` yourself at those moments; it is always fresh.
469
548
 
470
549
  ### On a dedicated server
471
550
 
472
- The platform has a read for this,
473
- `GET /v1/games/{game_id}/matches/{match_id}/players/{player_id}/passes`, with the same body, for a
474
- player in your live match. **A server bundle cannot call it yet**, and the SDK has no method for it,
475
- because it needs the allocation nonce: the route requires `X-Alloc-Nonce`, which the platform's
476
- runtime holds and never hands to your bundle. The boost read has the same limit. Reaching it takes a
477
- hook in the runtime, like the one behind `gem.match.end()`.
551
+ `gem.match.playerPasses(playerId)` reads which passes a player in your match holds, in the shape
552
+ `gem.me.passes.list()` gives: `{ pass, held, daysLeft }` for each pass marked visible to games. On a
553
+ server it is the decision itself, because the browser is not in the call:
554
+
555
+ ```js
556
+ export default defineServer((gem) => ({
557
+ async join(playerId) {
558
+ let vip = false;
559
+ try {
560
+ vip = (await gem.match.playerPasses(playerId)).some((p) => p.pass === 'vip' && p.held);
561
+ } catch {
562
+ // unknown: keep the lounge shut, and let the player ask again
563
+ }
564
+ if (vip) admitToLounge(playerId);
565
+ },
566
+ }));
567
+ ```
568
+
569
+ - **The platform half of the runtime makes the read**, with the match's own proof, which never
570
+ enters your bundle. So does `gem.match.end()`.
571
+ - **It throws on a runtime image that predates it, and on a listen host** (a player's browser
572
+ hosting a P2P match), which has no such proof. Decide in a function there.
573
+ - **A player outside your match** is refused `not_found` (`player_not_in_match`), and nothing about
574
+ them is read.
575
+ - **It may be refused `forbidden` (`client_permission_denied`)** until the platform grants game
576
+ servers the pass read. That is unknown, not "not held": keep the door shut and say so, rather
577
+ than telling a VIP they have no VIP.
478
578
 
479
- Until then, decide in a function, or leave a VIP door out of a dedicated-server match.
579
+ `gem.match.playerBoost(playerId)` reads the player's Boost for **display** — "2x Coins" on a results
580
+ screen — never for a decision.
480
581
 
481
582
  ## 12. Outcomes and reasons
482
583
 
@@ -486,9 +587,10 @@ Until then, decide in a function, or leave a VIP door out of a dedicated-server
486
587
  | `openApp` | opened, or `handed_off` | `busy` | `already_in_app`, `unsupported`, `error` |
487
588
  | `openGame` | the next game is starting | `busy`, `same_game` | `not_found`, `unsupported`, `error` |
488
589
  | `similarGames` | cards, possibly none | | `unsupported`, `error` |
489
- | `store.offers` | offers, possibly none | | `sign_in_required`, `store_off`, `unsupported`, `error` |
590
+ | `store.offers` | offers, possibly none | | `store_off`, `sign_in_required` (an older Arcade's guest), `unsupported`, `error` |
490
591
  | `store.purchase` | paid, or `pending` | `cancelled`, `busy` | `sign_in_required`, `store_off`, `not_found`, the provider's reason, `unsupported`, `error` (outcome unknown) |
491
592
  | `store.showOffer` | paid, or `pending` | `cancelled`, `busy` | `sign_in_required`, `store_off`, `not_found`, the provider's reason, `unsupported`, `error` (outcome unknown) |
593
+ | `store.showPass` | held, or `pending` | `cancelled`, `busy` | `not_found`, `store_off`, the provider's reason, `invalid_argument`, `unsupported`, `error` (outcome unknown) |
492
594
 
493
595
  Every reason is a short code of lower-case letters and underscores. The Arcade turns a payment
494
596
  provider's own code into that shape before it reaches you.
@@ -497,10 +599,11 @@ Four reasons come from the SDK itself rather than the Arcade:
497
599
 
498
600
  - `unsupported`: this Arcade does not offer the feature. Nothing was sent.
499
601
  - `invalid_argument`: the SDK refused the argument (an unknown `target`, a `ref` that is not an id
500
- or a web address, a `limit` outside 1–24, an empty `skuId`). Nothing was sent.
602
+ or a web address, a `limit` outside 1–24, an empty `skuId` or pass slug). Nothing was sent.
501
603
  - `busy`: another acting ask from your game is still open. Nothing was sent.
502
604
  - `error`: no reliable answer arrived. The ask could not be sent, a read or an open waited out its
503
605
  timeout (30 seconds for a read, two minutes for `openApp` and `openGame`), the bridge closed
504
606
  before the answer came, or the Arcade said it had no reliable answer. For a read, try again
505
- later. For `store.purchase` and `store.showOffer` it means **the outcome is unknown**, never
607
+ later. For `store.purchase`, `store.showOffer` and `store.showPass` it means **the outcome is
608
+ unknown**, never
506
609
  "nothing was bought" ([§9](#9-buying-from-the-arcade-store)).