@indiegems/gem-web-sdk 7.9.1 → 8.1.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,6 +1,6 @@
1
1
  # The browser surface — API reference
2
2
 
3
- Generated from `@indiegems/gem-web-sdk/sdk` v7.9.1. Every symbol below links to its page on the Gem Arcade developer site.
3
+ Generated from `@indiegems/gem-web-sdk/sdk` v8.1.0. Every symbol below links to its page on the Gem Arcade developer site.
4
4
 
5
5
  `@indiegems/gem-web-sdk/sdk` is the one import a game writes, wherever its code runs. `@indiegems/gem-web-sdk/game` exports exactly the symbols on this page and nothing else, for a build that wants only this half.
6
6
 
@@ -247,6 +247,47 @@ check that a funnel you declared names events you are actually sending.
247
247
 
248
248
  Absent when your game declares none.
249
249
 
250
+ ## GameConfig.rewarded_placements
251
+
252
+ [GameConfig.rewarded_placements](https://gemarcade.com/developer/api/game#gameconfig-rewarded_placements)
253
+
254
+ ```ts
255
+ rewarded_placements?: SnapshotRewardedPlacement[]
256
+ ```
257
+
258
+ Your rewarded placements — what pressing a rewarded button grants. An
259
+ array, one row per placement, each naming itself in `placement`, with its
260
+ `grants`, the caps the platform enforces on it (`max_per_day`,
261
+ `cooldown_seconds`, `max_held`) and what happens where no ad serves
262
+ (`without_ads`).
263
+
264
+ Read it to LABEL your button — "+1 Revive" — never to decide a reward. A
265
+ grant is an `item_grant`, with a `quantity` of the item its
266
+ `item_definition_id` names, or a `currency_grant`, with an `amount` of the
267
+ currency its `currency_id` names; the word on the button is that
268
+ definition's `display_name`, from [GameConfig.item](https://gemarcade.com/developer/api/game#gameconfig-item) or
269
+ [GameConfig.currency](https://gemarcade.com/developer/api/game#gameconfig-currency):
270
+
271
+ ```js
272
+ const row = config?.rewarded_placements?.find((p) => p.placement === 'classic_revive');
273
+ const grant = row?.grants[0];
274
+ const label =
275
+ grant?.type === 'item_grant'
276
+ ? `+${grant.quantity} ${config.item(grant.item_definition_id)?.display_name ?? ''}`
277
+ : grant?.type === 'currency_grant'
278
+ ? `+${grant.amount} ${config.currency(grant.currency_id)?.display_name ?? ''}`
279
+ : undefined;
280
+ ```
281
+
282
+ Whether an ask grants anything is the platform's decision, made against
283
+ this config server-side; the grant lands on your game's inventory, and
284
+ re-reading `gem.me.inventory` is the only way to learn that it did. Whether
285
+ the button could pay right now is [AdsApi.rewardedStatus](https://gemarcade.com/developer/api/game#adsapi-rewardedstatus).
286
+
287
+ Absent when your game declares none.
288
+
289
+ **Guides.** [cross-promotion](https://gemarcade.com/developer/docs/cross-promotion)
290
+
250
291
  ## GameConfig.version
251
292
 
252
293
  [GameConfig.version](https://gemarcade.com/developer/api/game#gameconfig-version)
@@ -2934,6 +2975,11 @@ No reason is given for a `no_ad`, and one will never be added: a reason would
2934
2975
  tell your game something about the player — an age, a consent state, a
2935
2976
  subscription — that game code is deliberately not told.
2936
2977
 
2978
+ The one thing you can learn is about **your own economy**, and not from this
2979
+ answer: [AdsApi.rewardedStatus](https://gemarcade.com/developer/api/game#adsapi-rewardedstatus) reports your own declared rewarded caps
2980
+ as the platform holds them (a platform-wide per-placement maximum, where one
2981
+ is set, can lower a cap). It never reports anything about the player.
2982
+
2937
2983
  ## AdOutcome
2938
2984
 
2939
2985
  [AdOutcome](https://gemarcade.com/developer/api/game#adoutcome)
@@ -3009,6 +3055,160 @@ startNextLevel();
3009
3055
 
3010
3056
  **Guides.** [cross-promotion](https://gemarcade.com/developer/docs/cross-promotion)
3011
3057
 
3058
+ ## AdsApi.rewardedStatus
3059
+
3060
+ [AdsApi.rewardedStatus](https://gemarcade.com/developer/api/game#adsapi-rewardedstatus)
3061
+
3062
+ ```ts
3063
+ rewardedStatus(placement: string): Promise<RewardedStatus>
3064
+ ```
3065
+
3066
+ Whether a rewarded placement could pay right now, so you can decide whether
3067
+ to draw its button. **It never says whether an ad will play.**
3068
+
3069
+ The answer comes from your own `rewarded_placements` config — its daily
3070
+ cap, cooldown and `max_held` — applied to this player's use of it, as the
3071
+ platform holds them. Nothing in it describes the player. `grantable: true`
3072
+ means the platform would grant if an ad is watched or, where no ad serves,
3073
+ under the placement's `without_ads: grant`; a player can still press the
3074
+ button and get `no_ad`, for reasons your game is not told.
3075
+
3076
+ NOTHING REJECTS, like everything else on `gem.ads`. Without a play token,
3077
+ when the read fails or takes longer than ten seconds, or for a `placement`
3078
+ outside the grammar [AdRequest.placement](https://gemarcade.com/developer/api/game#adrequest-placement) documents, it resolves not
3079
+ declared and not grantable, with no `resetsAt`, and the reason goes to your
3080
+ `onError` handler.
3081
+
3082
+ It is a read of the platform, so call it when you draw the button — on a
3083
+ results or death screen — not every frame. Calls for one placement that
3084
+ overlap share one request. Draw the rest of the screen first rather than
3085
+ holding it on this answer.
3086
+
3087
+ ⚠️ It grants nothing and reserves nothing. The reward is granted by the
3088
+ platform when the player asks with `requestAd`; re-read `gem.me.inventory`
3089
+ after that ask settles, whatever it settled as.
3090
+
3091
+ | Parameter | Type | Description |
3092
+ | --- | --- | --- |
3093
+ | `placement` | `string` | A placement your `rewarded_placements` config declares. |
3094
+
3095
+ **Returns.** The placement's state. Always resolves; never rejects.
3096
+
3097
+ ```ts
3098
+ const status = await gem.ads.rewardedStatus('classic_revive');
3099
+ reviveButton.disabled = !status.grantable;
3100
+ if (status.nextEligibleAt !== undefined) {
3101
+ reviveButton.title = `Back at ${new Date(status.nextEligibleAt).toLocaleTimeString()}`;
3102
+ }
3103
+ ```
3104
+
3105
+ **Guides.** [cross-promotion](https://gemarcade.com/developer/docs/cross-promotion)
3106
+
3107
+ ## RewardedStatus
3108
+
3109
+ [RewardedStatus](https://gemarcade.com/developer/api/game#rewardedstatus)
3110
+
3111
+ ```ts
3112
+ interface RewardedStatus
3113
+ ```
3114
+
3115
+ What [AdsApi.rewardedStatus](https://gemarcade.com/developer/api/game#adsapi-rewardedstatus) answers: the state of one rewarded
3116
+ placement for this player, from your own declared caps.
3117
+
3118
+ Every figure comes from **your** `rewarded_placements` config — `max_per_day`,
3119
+ `cooldown_seconds`, `max_held` — applied to this player's use of it, as the
3120
+ platform holds them. The platform can hold a placement's daily cap below what
3121
+ you declared, never above it. Nothing here describes the player.
3122
+
3123
+ ⚠️ **It never says whether an ad will play.** Whether one does depends on
3124
+ things your game is deliberately not told. A player can see your button,
3125
+ press it, and get `no_ad`: read that `no_ad` like any other, and never guess
3126
+ a reason.
3127
+
3128
+ ## RewardedStatus.declared
3129
+
3130
+ [RewardedStatus.declared](https://gemarcade.com/developer/api/game#rewardedstatus-declared)
3131
+
3132
+ ```ts
3133
+ readonly declared: boolean
3134
+ ```
3135
+
3136
+ Whether this channel's published config declares the placement.
3137
+
3138
+ `false` for a placement your config does not name, a config that is not
3139
+ promoted to the channel being played, and every answer the platform did not
3140
+ give (see [RewardedStatus.resetsAt](https://gemarcade.com/developer/api/game#rewardedstatus-resetsat)).
3141
+
3142
+ ## RewardedStatus.grantable
3143
+
3144
+ [RewardedStatus.grantable](https://gemarcade.com/developer/api/game#rewardedstatus-grantable)
3145
+
3146
+ ```ts
3147
+ readonly grantable: boolean
3148
+ ```
3149
+
3150
+ Whether pressing your button could pay right now: the platform would grant
3151
+ the reward if an ad is watched or, where no ad serves, under the
3152
+ placement's `without_ads: grant`.
3153
+
3154
+ **Not a promise that an ad will play, and not a promise of a reward.** It
3155
+ is `false` whenever a cap is spent, a cooldown is running, the player holds
3156
+ `max_held`, rewarded is off on this channel, or the arcade running your
3157
+ game does not take ad requests at all.
3158
+
3159
+ ## RewardedStatus.remainingToday
3160
+
3161
+ [RewardedStatus.remainingToday](https://gemarcade.com/developer/api/game#rewardedstatus-remainingtoday)
3162
+
3163
+ ```ts
3164
+ readonly remainingToday?: number
3165
+ ```
3166
+
3167
+ Grants left today under the placement's daily cap, a non-negative integer.
3168
+
3169
+ ABSENT — no key, never a sentinel — when the placement has no daily cap,
3170
+ and on any answer where `declared` is `false`.
3171
+
3172
+ ## RewardedStatus.nextEligibleAt
3173
+
3174
+ [RewardedStatus.nextEligibleAt](https://gemarcade.com/developer/api/game#rewardedstatus-nexteligibleat)
3175
+
3176
+ ```ts
3177
+ readonly nextEligibleAt?: number
3178
+ ```
3179
+
3180
+ When the placement's cooldown ends, in epoch milliseconds — render it with
3181
+ `new Date(nextEligibleAt)`.
3182
+
3183
+ ABSENT when no cooldown is running.
3184
+
3185
+ ## RewardedStatus.heldLimitReached
3186
+
3187
+ [RewardedStatus.heldLimitReached](https://gemarcade.com/developer/api/game#rewardedstatus-heldlimitreached)
3188
+
3189
+ ```ts
3190
+ readonly heldLimitReached: boolean
3191
+ ```
3192
+
3193
+ Whether the player already holds the placement's `max_held` of the item it
3194
+ grants. Spend one first.
3195
+
3196
+ ## RewardedStatus.resetsAt
3197
+
3198
+ [RewardedStatus.resetsAt](https://gemarcade.com/developer/api/game#rewardedstatus-resetsat)
3199
+
3200
+ ```ts
3201
+ readonly resetsAt?: number
3202
+ ```
3203
+
3204
+ When the daily count resets — the next 00:00 UTC — in epoch milliseconds.
3205
+ Take it from here rather than computing midnight in game code.
3206
+
3207
+ ABSENT when the platform gave no answer: no play token yet, a read that
3208
+ failed or took longer than ten seconds, or a platform that does not serve
3209
+ this read. Then the rest of the status reads not declared and not
3210
+ grantable, and the reason went to your `onError` handler.
3211
+
3012
3212
  ## AnalyticsValue
3013
3213
 
3014
3214
  [AnalyticsValue](https://gemarcade.com/developer/api/game#analyticsvalue)
@@ -4863,6 +5063,29 @@ sends only while connected, and only to an arcade that draws a wallet.
4863
5063
  **Returns.** whether it entered the send path — `false` before the bridge is
4864
5064
  up, and `false` for an arcade with no wallet to move.
4865
5065
 
5066
+ ## GemFrame.showPortraitBorders
5067
+
5068
+ [GemFrame.showPortraitBorders](https://gemarcade.com/developer/api/game#gemframe-showportraitborders)
5069
+
5070
+ ```ts
5071
+ showPortraitBorders(name: string | null): boolean
5072
+ ```
5073
+
5074
+ Tell the arcade which of your store page's portrait borders to paint beside
5075
+ your game, or `null` for none.
5076
+
5077
+ Prefer `gem.showPortraitBorders`, which holds your choice until the bridge
5078
+ is up and paces a burst of changes for you. This one sends every call it
5079
+ accepts, only while connected, and only to an arcade that paints borders.
5080
+
5081
+ | Parameter | Type |
5082
+ | --- | --- |
5083
+ | `name` | `string \| null` |
5084
+
5085
+ **Returns.** whether the message left the frame — `false` for a string that is
5086
+ not a border name (with a console warning), before the bridge is up, for
5087
+ an arcade that paints no borders, and past this frame's own limit.
5088
+
4866
5089
  ## GemFrame.setWalletVisible
4867
5090
 
4868
5091
  [GemFrame.setWalletVisible](https://gemarcade.com/developer/api/game#gemframe-setwalletvisible)
@@ -4935,6 +5158,30 @@ arcade that handles share.
4935
5158
  up, for an arcade that does not handle share, and past this frame's own
4936
5159
  limit of six a minute.
4937
5160
 
5161
+ ## GemFrame.requestVibrate
5162
+
5163
+ [GemFrame.requestVibrate](https://gemarcade.com/developer/api/game#gemframe-requestvibrate)
5164
+
5165
+ ```ts
5166
+ requestVibrate(pattern: readonly number[]): boolean
5167
+ ```
5168
+
5169
+ Ask the arcade to play a vibration pattern where this frame has no
5170
+ `navigator.vibrate` of its own. Nothing comes back.
5171
+
5172
+ Prefer `gem.vibration.vibrate`, which calls `navigator.vibrate` itself
5173
+ where it exists, normalizes the pattern and caps its total length. This one
5174
+ checks only the message's bounds, sends only while connected, and only to
5175
+ an arcade that handles vibration.
5176
+
5177
+ | Parameter | Type |
5178
+ | --- | --- |
5179
+ | `pattern` | `readonly number[]` |
5180
+
5181
+ **Returns.** whether the message left the frame — `false` before the bridge is
5182
+ up, for an arcade that does not handle vibration, and past this frame's
5183
+ own limit of 600 a minute.
5184
+
4938
5185
  ## GemFrame.reportFrameStats
4939
5186
 
4940
5187
  [GemFrame.reportFrameStats](https://gemarcade.com/developer/api/game#gemframe-reportframestats)
@@ -5067,8 +5314,9 @@ draw your own placeholder rather than a broken image. Pass it through
5067
5314
  readonly badges: readonly PlayerBadge[]
5068
5315
  ```
5069
5316
 
5070
- The player's equipped badges, in display order. Always a list, and empty
5071
- until platform achievements equip any.
5317
+ The player's equipped badges, at most two, in the order the player
5318
+ arranged them. Always a list — empty when they wear none, or when their
5319
+ identity is withheld — so draw what is there and key on `itemId`.
5072
5320
 
5073
5321
  ## FriendsPage
5074
5322
 
@@ -5121,7 +5369,9 @@ readonly limit?: number
5121
5369
  ```
5122
5370
 
5123
5371
  Friends per page — default 50, at most 200. A player holds at most 1000
5124
- friends, so five max-size pages is the whole list.
5372
+ friends, so five max-size pages is the whole list. A larger value is
5373
+ clamped to 200; a value that is not a non-negative integer is refused with
5374
+ `invalid`.
5125
5375
 
5126
5376
  ## ListFriendsOptions.cursor
5127
5377
 
@@ -5144,6 +5394,181 @@ readonly signal?: AbortSignal
5144
5394
 
5145
5395
  Abort the read.
5146
5396
 
5397
+ ## FriendChallenge
5398
+
5399
+ [FriendChallenge](https://gemarcade.com/developer/api/game#friendchallenge)
5400
+
5401
+ ```ts
5402
+ interface FriendChallenge
5403
+ ```
5404
+
5405
+ One friend challenge this player received, in THIS game on THIS channel.
5406
+
5407
+ What the platform holds, not what the card said: there is no rendered line,
5408
+ because your game renders its own copy from [FriendChallenge.template](https://gemarcade.com/developer/api/game#friendchallenge-template)
5409
+ and [FriendChallenge.params](https://gemarcade.com/developer/api/game#friendchallenge-params). Who sent it is
5410
+ [FriendChallenge.from](https://gemarcade.com/developer/api/game#friendchallenge-from), resolved by the platform from its own records.
5411
+
5412
+ ## FriendChallenge.id
5413
+
5414
+ [FriendChallenge.id](https://gemarcade.com/developer/api/game#friendchallenge-id)
5415
+
5416
+ ```ts
5417
+ readonly id: string
5418
+ ```
5419
+
5420
+ The challenge's id — the value the Arcade puts in the `gemChallenge`
5421
+ launch key, and what [FriendsApi.markAnswered](https://gemarcade.com/developer/api/game#friendsapi-markanswered) takes. Opaque: compare
5422
+ it, store it, never parse it.
5423
+
5424
+ ## FriendChallenge.from
5425
+
5426
+ [FriendChallenge.from](https://gemarcade.com/developer/api/game#friendchallenge-from)
5427
+
5428
+ ```ts
5429
+ readonly from: Friend
5430
+ ```
5431
+
5432
+ Who sent it, with the same display identity a [Friend](https://gemarcade.com/developer/api/game#friend) row carries.
5433
+
5434
+ The ONLY name to show. Never read a name out of
5435
+ [FriendChallenge.params](https://gemarcade.com/developer/api/game#friendchallenge-params): a name in a param is one nobody checked.
5436
+
5437
+ ## FriendChallenge.template
5438
+
5439
+ [FriendChallenge.template](https://gemarcade.com/developer/api/game#friendchallenge-template)
5440
+
5441
+ ```ts
5442
+ readonly template: string
5443
+ ```
5444
+
5445
+ The `friend_challenge_templates[].key` it was sent with — which of your
5446
+ templates, so your game knows what was asked.
5447
+
5448
+ ## FriendChallenge.params
5449
+
5450
+ [FriendChallenge.params](https://gemarcade.com/developer/api/game#friendchallenge-params)
5451
+
5452
+ ```ts
5453
+ readonly params: Readonly<Record<string, string | number>>
5454
+ ```
5455
+
5456
+ The params exactly as sent: integers as numbers, date, enum and code
5457
+ params as strings. The template constrained them when the challenge was
5458
+ sent, but the config may have changed since, so check each one against
5459
+ what your game expects before it does anything — as you would a launch
5460
+ key.
5461
+
5462
+ ## FriendChallenge.createdAt
5463
+
5464
+ [FriendChallenge.createdAt](https://gemarcade.com/developer/api/game#friendchallenge-createdat)
5465
+
5466
+ ```ts
5467
+ readonly createdAt: number
5468
+ ```
5469
+
5470
+ When it was sent, in Unix seconds.
5471
+
5472
+ ## FriendChallenge.openedAt
5473
+
5474
+ [FriendChallenge.openedAt](https://gemarcade.com/developer/api/game#friendchallenge-openedat)
5475
+
5476
+ ```ts
5477
+ readonly openedAt?: number
5478
+ ```
5479
+
5480
+ When the player pressed Play on it in the Arcade, in Unix seconds. Absent
5481
+ until they have.
5482
+
5483
+ ## FriendChallenge.answeredAt
5484
+
5485
+ [FriendChallenge.answeredAt](https://gemarcade.com/developer/api/game#friendchallenge-answeredat)
5486
+
5487
+ ```ts
5488
+ readonly answeredAt?: number
5489
+ ```
5490
+
5491
+ When a game marked it answered with [FriendsApi.markAnswered](https://gemarcade.com/developer/api/game#friendsapi-markanswered), in
5492
+ Unix seconds. Absent until one has — so present on a relaunch of a
5493
+ challenge already played, which is how a replay is told from a first
5494
+ attempt.
5495
+
5496
+ ## FriendChallengesPage
5497
+
5498
+ [FriendChallengesPage](https://gemarcade.com/developer/api/game#friendchallengespage)
5499
+
5500
+ ```ts
5501
+ interface FriendChallengesPage
5502
+ ```
5503
+
5504
+ One page of the friend challenges this player holds in this game.
5505
+
5506
+ ## FriendChallengesPage.challenges
5507
+
5508
+ [FriendChallengesPage.challenges](https://gemarcade.com/developer/api/game#friendchallengespage-challenges)
5509
+
5510
+ ```ts
5511
+ readonly challenges: readonly FriendChallenge[]
5512
+ ```
5513
+
5514
+ The page's challenges, newest first, answered ones last.
5515
+
5516
+ ## FriendChallengesPage.nextCursor
5517
+
5518
+ [FriendChallengesPage.nextCursor](https://gemarcade.com/developer/api/game#friendchallengespage-nextcursor)
5519
+
5520
+ ```ts
5521
+ readonly nextCursor?: string
5522
+ ```
5523
+
5524
+ Present exactly when another page exists — pass it back as
5525
+ [ListFriendChallengesOptions.cursor](https://gemarcade.com/developer/api/game#listfriendchallengesoptions-cursor) to continue. Opaque: mint
5526
+ nothing, store nothing, compare nothing.
5527
+
5528
+ ## ListFriendChallengesOptions
5529
+
5530
+ [ListFriendChallengesOptions](https://gemarcade.com/developer/api/game#listfriendchallengesoptions)
5531
+
5532
+ ```ts
5533
+ interface ListFriendChallengesOptions
5534
+ ```
5535
+
5536
+ What [FriendsApi.inbox](https://gemarcade.com/developer/api/game#friendsapi-inbox) takes.
5537
+
5538
+ ## ListFriendChallengesOptions.limit
5539
+
5540
+ [ListFriendChallengesOptions.limit](https://gemarcade.com/developer/api/game#listfriendchallengesoptions-limit)
5541
+
5542
+ ```ts
5543
+ readonly limit?: number
5544
+ ```
5545
+
5546
+ Challenges per page — default 50, at most 200. The platform keeps at most
5547
+ 200 per player, so one max-size page is the whole inbox. A larger value is
5548
+ clamped to 200; a value that is not a non-negative integer is refused with
5549
+ `invalid`.
5550
+
5551
+ ## ListFriendChallengesOptions.cursor
5552
+
5553
+ [ListFriendChallengesOptions.cursor](https://gemarcade.com/developer/api/game#listfriendchallengesoptions-cursor)
5554
+
5555
+ ```ts
5556
+ readonly cursor?: string
5557
+ ```
5558
+
5559
+ The previous page's [FriendChallengesPage.nextCursor](https://gemarcade.com/developer/api/game#friendchallengespage-nextcursor), verbatim. A
5560
+ cursor the platform did not mint is refused with `invalid`.
5561
+
5562
+ ## ListFriendChallengesOptions.signal
5563
+
5564
+ [ListFriendChallengesOptions.signal](https://gemarcade.com/developer/api/game#listfriendchallengesoptions-signal)
5565
+
5566
+ ```ts
5567
+ readonly signal?: AbortSignal
5568
+ ```
5569
+
5570
+ Abort the read.
5571
+
5147
5572
  ## FriendChallengeRequest
5148
5573
 
5149
5574
  [FriendChallengeRequest](https://gemarcade.com/developer/api/game#friendchallengerequest)
@@ -5423,15 +5848,18 @@ channel: the version the template is missing from.
5423
5848
  class FriendsApi
5424
5849
  ```
5425
5850
 
5426
- Reading the signed-in player's accepted friends, and asking the Arcade to
5427
- send one of them a friend challenge.
5851
+ Reading the signed-in player's accepted friends, asking the Arcade to send
5852
+ one of them a friend challenge, and reading the friend challenges this
5853
+ player received in this game.
5428
5854
 
5429
5855
  Reached as `gem.friends`. Display identity only, and every accepted friend
5430
5856
  appears whether or not they have played this game — an invite-a-friend flow
5431
5857
  needs exactly the ones who have not. The graph itself — requests, accepts,
5432
5858
  blocks — is managed on the Arcade, not here. So is a friend challenge's
5433
5859
  send: [FriendsApi.challenge](https://gemarcade.com/developer/api/game#friendsapi-challenge) asks, and the Arcade confirms with the
5434
- player and sends.
5860
+ player and sends. The receiving side is [FriendsApi.launchedChallenge](https://gemarcade.com/developer/api/game#friendsapi-launchedchallenge)
5861
+ (the challenge this frame was launched with), [FriendsApi.inbox](https://gemarcade.com/developer/api/game#friendsapi-inbox) and
5862
+ [FriendsApi.markAnswered](https://gemarcade.com/developer/api/game#friendsapi-markanswered).
5435
5863
 
5436
5864
  ```js
5437
5865
  let page = await gem.friends.list();
@@ -5460,7 +5888,8 @@ have played this one.
5460
5888
  | --- | --- |
5461
5889
  | `options` | `ListFriendsOptions` |
5462
5890
 
5463
- **Throws.** `invalid` when the cursor is not one a previous page minted,
5891
+ **Throws.** `invalid` when the cursor is not one a previous page minted or
5892
+ `limit` is not a non-negative integer,
5464
5893
  `forbidden` in a context with no player — this is a player surface, and
5465
5894
  a game server has no friend list.
5466
5895
 
@@ -5520,6 +5949,123 @@ challengeButton.onclick = async () => {
5520
5949
  };
5521
5950
  ```
5522
5951
 
5952
+ ## FriendsApi.inbox
5953
+
5954
+ [FriendsApi.inbox](https://gemarcade.com/developer/api/game#friendsapi-inbox)
5955
+
5956
+ ```ts
5957
+ inbox(options?: ListFriendChallengesOptions): Promise<FriendChallengesPage>
5958
+ ```
5959
+
5960
+ Read one page of the friend challenges this player holds in THIS game on
5961
+ THIS channel — in every state. Answered challenges are INCLUDED, and sort
5962
+ after the rest; within each part, newest first.
5963
+
5964
+ Pass the result's `nextCursor` back as `cursor` until it stops arriving.
5965
+ A guest holds no challenges, so a guest's page is empty rather than a
5966
+ refusal — build the screen for the empty state, not around an error.
5967
+
5968
+ For the one challenge this frame was launched with, use
5969
+ [FriendsApi.launchedChallenge](https://gemarcade.com/developer/api/game#friendsapi-launchedchallenge) rather than searching this list.
5970
+
5971
+ | Parameter | Type |
5972
+ | --- | --- |
5973
+ | `options` | `ListFriendChallengesOptions` |
5974
+
5975
+ **Throws.** `invalid` when the cursor is not one a previous page minted or
5976
+ `limit` is not a non-negative integer,
5977
+ `forbidden` in a context with no player of this game — a game server,
5978
+ or a token that is not a game-scoped player token.
5979
+
5980
+ ```ts
5981
+ const page = await gem.friends.inbox();
5982
+ for (const c of page.challenges) {
5983
+ if (c.template !== 'beat_code_jar') continue;
5984
+ renderChallengeRow(c.from, c.params, c.answeredAt !== undefined);
5985
+ }
5986
+ ```
5987
+
5988
+ ## FriendsApi.launchedChallenge
5989
+
5990
+ [FriendsApi.launchedChallenge](https://gemarcade.com/developer/api/game#friendsapi-launchedchallenge)
5991
+
5992
+ ```ts
5993
+ launchedChallenge(options?: { signal?: AbortSignal }): Promise<FriendChallenge | null>
5994
+ ```
5995
+
5996
+ The friend challenge this frame was launched with, or `null`.
5997
+
5998
+ When a player opens a friend challenge in the Arcade, the Arcade launches
5999
+ your game with the challenge's params as launch keys plus its own
6000
+ `gemChallenge` key. This reads that key — never parse it yourself — and
6001
+ looks the challenge up, so you get the sender from the platform's records
6002
+ and the params as they were sent.
6003
+
6004
+ The key is read ONCE, when `createGem` runs, so cleaning your URL with
6005
+ `history.replaceState` afterwards changes nothing here.
6006
+
6007
+ RESOLVES `null`, never rejects, for every launch that is not this
6008
+ player's challenge in this game: no `gemChallenge` key, a value that is
6009
+ not a challenge id, a challenge that is not this player's (a link
6010
+ someone shared), one from another game or channel, and every guest
6011
+ launch. Treat `null` as an ordinary launch.
6012
+
6013
+ A RELAUNCH REPLAYS. Pressing Play again in the Arcade relaunches the
6014
+ same challenge, so the same one comes back; it carries
6015
+ [FriendChallenge.answeredAt](https://gemarcade.com/developer/api/game#friendchallenge-answeredat) once a game has called
6016
+ [FriendsApi.markAnswered](https://gemarcade.com/developer/api/game#friendsapi-markanswered), which is how a replay is told from a
6017
+ first attempt. Asks the platform on every call, so `answeredAt` is
6018
+ current.
6019
+
6020
+ | Parameter | Type |
6021
+ | --- | --- |
6022
+ | `options` | `{ signal?: AbortSignal }` |
6023
+
6024
+ **Throws.** What any platform read throws: `forbidden` in a context with no
6025
+ player of this game, `unavailable` and `rate_limited` when the platform
6026
+ cannot answer now — and `not_found` for a 404 that is NOT the platform's
6027
+ uniform challenge answer (no `inbox_item_not_found` code), which is a
6028
+ platform that does not serve this read rather than an ordinary launch.
6029
+
6030
+ ```ts
6031
+ const challenge = await gem.friends.launchedChallenge();
6032
+ if (challenge?.template === 'beat_code_jar') {
6033
+ // Check every param before it does anything: the config may have changed since.
6034
+ const jar = challenge.params.jar;
6035
+ if (typeof jar === 'string' && /^[A-Z0-9]{7}$/.test(jar)) {
6036
+ startJar(jar, { from: challenge.from, replay: challenge.answeredAt !== undefined });
6037
+ }
6038
+ }
6039
+ ```
6040
+
6041
+ ## FriendsApi.markAnswered
6042
+
6043
+ [FriendsApi.markAnswered](https://gemarcade.com/developer/api/game#friendsapi-markanswered)
6044
+
6045
+ ```ts
6046
+ markAnswered(id: string): Promise<void>
6047
+ ```
6048
+
6049
+ Mark one of this player's friend challenges answered — the player has
6050
+ played it. Call it when your game has finished the attempt the challenge
6051
+ asked for, win or lose.
6052
+
6053
+ Idempotent: a second call changes nothing, and a challenge keeps the
6054
+ first time it was answered. The sender is told nothing — there is no read
6055
+ receipt — so this is your game's own record, which
6056
+ [FriendChallenge.answeredAt](https://gemarcade.com/developer/api/game#friendchallenge-answeredat) reads back.
6057
+
6058
+ | Parameter | Type | Description |
6059
+ | --- | --- | --- |
6060
+ | `id` | `string` | A [FriendChallenge.id](https://gemarcade.com/developer/api/game#friendchallenge-id), from
6061
+ [FriendsApi.launchedChallenge](https://gemarcade.com/developer/api/game#friendsapi-launchedchallenge) or [FriendsApi.inbox](https://gemarcade.com/developer/api/game#friendsapi-inbox). |
6062
+
6063
+ **Throws.** `not_found` when no challenge of this player's in this game and
6064
+ channel has that id — one answer for unknown and someone else's;
6065
+ `invalid` when `id` cannot be a challenge id at all, before anything is
6066
+ sent; `forbidden` in a context with no player of this game;
6067
+ `rate_limited` past the platform's per-player budget.
6068
+
5523
6069
  ## FunctionInvokeOptions
5524
6070
 
5525
6071
  [FunctionInvokeOptions](https://gemarcade.com/developer/api/game#functioninvokeoptions)
@@ -6561,8 +7107,9 @@ draw your own placeholder rather than a broken image. Pass it through
6561
7107
  readonly badges: readonly PlayerBadge[]
6562
7108
  ```
6563
7109
 
6564
- The player's equipped badges, in display order. Always a list, and empty
6565
- until platform achievements equip any.
7110
+ The player's equipped badges, at most two, in the order the player
7111
+ arranged them. Always a list — empty when they wear none, or when their
7112
+ identity is withheld — so draw what is there and key on `itemId`.
6566
7113
 
6567
7114
  ## GemPrefs
6568
7115
 
@@ -7342,7 +7889,10 @@ resumes it when the promise settles, so you do not pause anything yourself.
7342
7889
  ⚠️ `shown` **grants nothing.** It is not a receipt and not an entitlement,
7343
7890
  and a reward must never be handed out on the strength of it.
7344
7891
 
7345
- You are told no reason for a no, and there is nothing to infer from one.
7892
+ You are told no reason for a no, and there is nothing to infer from one
7893
+ about the player. What you can read is your own economy:
7894
+ `rewardedStatus(placement)` says whether a rewarded placement could pay
7895
+ right now under your own declared caps — never whether an ad will play.
7346
7896
 
7347
7897
  **Guides.** [cross-promotion](https://gemarcade.com/developer/docs/cross-promotion)
7348
7898
 
@@ -7405,6 +7955,40 @@ shareButton.onclick = () => {
7405
7955
 
7406
7956
  **Guides.** [getting-started](https://gemarcade.com/developer/docs/getting-started)
7407
7957
 
7958
+ ## Gem.vibration
7959
+
7960
+ [Gem.vibration](https://gemarcade.com/developer/api/game#gem-vibration)
7961
+
7962
+ ```ts
7963
+ readonly vibration: VibrationApi
7964
+ ```
7965
+
7966
+ Vibrate the player's device — call this instead of `navigator.vibrate`,
7967
+ which does nothing on any iPhone. It takes the same pattern and returns the
7968
+ same boolean, calls `navigator.vibrate` itself where that works, and asks
7969
+ the arcade to vibrate where it does not.
7970
+
7971
+ Read [VibrationApi.available](https://gemarcade.com/developer/api/game#vibrationapi-available) live, after `awaitBridge()`, to decide
7972
+ whether to show your game's vibration toggle — on an iPhone it is `false`
7973
+ until the bridge connects — and check that toggle before every call:
7974
+
7975
+ ```js
7976
+ await gem.awaitBridge();
7977
+ vibrationRow.hidden = !gem.vibration.available;
7978
+
7979
+ function onHit() {
7980
+ if (settings.vibration) gem.vibration.vibrate(30);
7981
+ }
7982
+ ```
7983
+
7984
+ - **Never call `navigator.vibrate`**, guarded or not, and never fall back
7985
+ to it: this already does.
7986
+ - **Nothing vibrates before the player's first tap** in your game; the
7987
+ call returns `false` until then.
7988
+ - **Stop on pause** with `gem.vibration.vibrate(0)`.
7989
+
7990
+ **Guides.** [vibration](https://gemarcade.com/developer/docs/vibration)
7991
+
7408
7992
  ## Gem.analytics
7409
7993
 
7410
7994
  [Gem.analytics](https://gemarcade.com/developer/api/game#gem-analytics)
@@ -7499,12 +8083,17 @@ readonly friends: FriendsApi
7499
8083
  The signed-in player's accepted friends — display identity only,
7500
8084
  read-only, paginated — and the one ask that reaches past them:
7501
8085
  [FriendsApi.challenge](https://gemarcade.com/developer/api/game#friendsapi-challenge), which asks the Arcade to send a friend a
7502
- challenge after the player confirms it in the Arcade's own sheet.
8086
+ challenge after the player confirms it in the Arcade's own sheet. The
8087
+ receiving side reads the challenges this player holds in this game:
8088
+ [FriendsApi.launchedChallenge](https://gemarcade.com/developer/api/game#friendsapi-launchedchallenge) for the one this frame was launched
8089
+ with, [FriendsApi.inbox](https://gemarcade.com/developer/api/game#friendsapi-inbox) for all of them, and
8090
+ [FriendsApi.markAnswered](https://gemarcade.com/developer/api/game#friendsapi-markanswered) once one has been played.
7503
8091
 
7504
8092
  Every accepted friend appears whether or not they have played this game;
7505
8093
  an invite-a-friend flow needs exactly the ones who have not. The graph
7506
8094
  itself — requests, accepts, blocks, presence — stays on the Arcade: there
7507
- is nothing here to mutate and nothing that says who is online.
8095
+ is nothing here to mutate but a challenge's own answered mark, and nothing
8096
+ that says who is online.
7508
8097
 
7509
8098
  A player surface only. In a context with no player — a game server — the
7510
8099
  platform refuses the read; a server has no friend list.
@@ -7698,6 +8287,51 @@ arcade just said is not sent.
7698
8287
  | --- | --- |
7699
8288
  | `muted` | `boolean` |
7700
8289
 
8290
+ ## Gem.showPortraitBorders
8291
+
8292
+ [Gem.showPortraitBorders](https://gemarcade.com/developer/api/game#gem-showportraitborders)
8293
+
8294
+ ```ts
8295
+ showPortraitBorders(name: string | null): void
8296
+ ```
8297
+
8298
+ Choose which of your store page's **portrait borders** the arcade paints on
8299
+ either side of your game, or `null` for none.
8300
+
8301
+ On a desktop browser, the arcade's desktop app or a television, the arcade
8302
+ shows a portrait-only game as a centred portrait column. With borders
8303
+ declared on your store page (`media.portrait_borders`), it paints one of
8304
+ those images beside the column instead of its own bars. Call this at
8305
+ start-up and on every theme change, with the border that goes with what is
8306
+ on screen:
8307
+
8308
+ ```js
8309
+ const BORDERS = new Set(['meadow', 'alpine']); // the names your store page declares
8310
+
8311
+ function applyTheme(id) {
8312
+ document.body.dataset.theme = id;
8313
+ gem.showPortraitBorders(BORDERS.has(id) ? id : null); // null: a theme with no art
8314
+ }
8315
+ ```
8316
+
8317
+ Until your first call the arcade shows the page's first border. Whether
8318
+ your game is letterboxed at all is decided by your store page's
8319
+ `orientations`, never by this call, so call it unconditionally: before the
8320
+ connection exists your choice is held and sent when it connects, a burst of
8321
+ changes (a picker previewing on hover) reaches the arcade as its final
8322
+ value, and an arcade that paints no borders is simply not told. The arcade
8323
+ owns the change itself — when and how the image swaps — and sends no
8324
+ acknowledgement.
8325
+
8326
+ A name is 1 to 40 characters of `a-z`, `0-9`, `_` and `-`, starting with a
8327
+ letter or digit — the store page's own rule. Anything else is refused with
8328
+ a console warning and never sent; a valid name your page does not declare
8329
+ is ignored by the arcade, which keeps the image it is showing.
8330
+
8331
+ | Parameter | Type |
8332
+ | --- | --- |
8333
+ | `name` | `string \| null` |
8334
+
7701
8335
  ## Gem.cueReward
7702
8336
 
7703
8337
  [Gem.cueReward](https://gemarcade.com/developer/api/game#gem-cuereward)
@@ -8798,7 +9432,8 @@ interface PlayerBadge
8798
9432
  One badge a player has equipped — an icon the platform shows beside their
8799
9433
  name.
8800
9434
 
8801
- Always an empty list today; platform achievements fill it with at most two.
9435
+ A player wears at most two, in the order they arranged them. Each is an icon
9436
+ they own — an achievement in any game may have awarded it.
8802
9437
 
8803
9438
  ## PlayerBadge.itemId
8804
9439
 
@@ -8922,8 +9557,9 @@ draw your own placeholder rather than a broken image. Pass it through
8922
9557
  readonly badges: readonly PlayerBadge[]
8923
9558
  ```
8924
9559
 
8925
- The player's equipped badges, in display order. Always a list, and empty
8926
- until platform achievements equip any.
9560
+ The player's equipped badges, at most two, in the order the player
9561
+ arranged them. Always a list — empty when they wear none, or when their
9562
+ identity is withheld — so draw what is there and key on `itemId`.
8927
9563
 
8928
9564
  ## mapIdentity
8929
9565
 
@@ -11247,6 +11883,61 @@ A unit test that constructs the SDK in a simulated DOM has to give its
11247
11883
  window a parent of its own, because a simulated top-level window is exactly
11248
11884
  what this refuses.
11249
11885
 
11886
+ ## VibrationApi
11887
+
11888
+ [VibrationApi](https://gemarcade.com/developer/api/game#vibrationapi)
11889
+
11890
+ ```ts
11891
+ interface VibrationApi
11892
+ ```
11893
+
11894
+ `gem.vibration` — vibrate the player's device, on every platform that can.
11895
+ Call it instead of `navigator.vibrate`, which does nothing on any iPhone.
11896
+
11897
+ ## VibrationApi.available
11898
+
11899
+ [VibrationApi.available](https://gemarcade.com/developer/api/game#vibrationapi-available)
11900
+
11901
+ ```ts
11902
+ readonly available: boolean
11903
+ ```
11904
+
11905
+ Whether this device can vibrate for your game: decide from this whether to
11906
+ show your game's vibration toggle. Read it live, every time — after
11907
+ `awaitBridge()`, and again when your settings screen opens — never once at
11908
+ boot: on an iPhone it is `false` until the bridge connects.
11909
+
11910
+ `false` on a desktop with a mouse, in iOS Safari, in Firefox, before the
11911
+ bridge is up where vibration goes through the arcade, and after the
11912
+ session ends there.
11913
+
11914
+ ## VibrationApi.vibrate
11915
+
11916
+ [VibrationApi.vibrate](https://gemarcade.com/developer/api/game#vibrationapi-vibrate)
11917
+
11918
+ ```ts
11919
+ vibrate(pattern: number | readonly number[]): boolean
11920
+ ```
11921
+
11922
+ Vibrate: takes what `navigator.vibrate` takes and returns what it returns.
11923
+ `pattern` is a duration in milliseconds, or an array of alternating
11924
+ vibration and pause durations, starting with a vibration. `0` or `[]`
11925
+ stops a pattern that is still playing, and every call replaces it.
11926
+
11927
+ The pattern is capped at 99 entries of at most 10 000 ms each, and at
11928
+ 10 000 ms in all: a longer one is cut short, not refused.
11929
+
11930
+ Check your game's own vibration toggle before every call. Returns `false`,
11931
+ and vibrates nothing, when [VibrationApi.available](https://gemarcade.com/developer/api/game#vibrationapi-available) is false, before
11932
+ the player's first tap or key press in your game, for a pattern with a
11933
+ negative or non-finite entry, and past 600 calls a minute; each of the last
11934
+ three says so once a session through your `log`. `true` means the pattern
11935
+ was accepted, never that a motor moved.
11936
+
11937
+ | Parameter | Type |
11938
+ | --- | --- |
11939
+ | `pattern` | `number \| readonly number[]` |
11940
+
11250
11941
  ## LeaderboardPeriod.id
11251
11942
 
11252
11943
  [LeaderboardPeriod.id](https://gemarcade.com/developer/api/game#leaderboardperiod-id)
@@ -11415,8 +12106,9 @@ draw your own placeholder rather than a broken image. Pass it through
11415
12106
  readonly badges: readonly PlayerBadge[]
11416
12107
  ```
11417
12108
 
11418
- The player's equipped badges, in display order. Always a list, and empty
11419
- until platform achievements equip any.
12109
+ The player's equipped badges, at most two, in the order the player
12110
+ arranged them. Always a list — empty when they wear none, or when their
12111
+ identity is withheld — so draw what is there and key on `itemId`.
11420
12112
 
11421
12113
  ## LeaderboardEntry.rank
11422
12114
 
@@ -12856,8 +13548,9 @@ draw your own placeholder rather than a broken image. Pass it through
12856
13548
  readonly badges: readonly PlayerBadge[]
12857
13549
  ```
12858
13550
 
12859
- The player's equipped badges, in display order. Always a list, and empty
12860
- until platform achievements equip any.
13551
+ The player's equipped badges, at most two, in the order the player
13552
+ arranged them. Always a list — empty when they wear none, or when their
13553
+ identity is withheld — so draw what is there and key on `itemId`.
12861
13554
 
12862
13555
  ## PlayerProfile.playerId
12863
13556
 
@@ -14890,8 +15583,9 @@ draw your own placeholder rather than a broken image. Pass it through
14890
15583
  readonly badges: readonly PlayerBadge[]
14891
15584
  ```
14892
15585
 
14893
- The player's equipped badges, in display order. Always a list, and empty
14894
- until platform achievements equip any.
15586
+ The player's equipped badges, at most two, in the order the player
15587
+ arranged them. Always a list — empty when they wear none, or when their
15588
+ identity is withheld — so draw what is there and key on `itemId`.
14895
15589
 
14896
15590
  ## RoomMember.playerId
14897
15591
 
@@ -15654,8 +16348,9 @@ draw your own placeholder rather than a broken image. Pass it through
15654
16348
  readonly badges: readonly PlayerBadge[]
15655
16349
  ```
15656
16350
 
15657
- The player's equipped badges, in display order. Always a list, and empty
15658
- until platform achievements equip any.
16351
+ The player's equipped badges, at most two, in the order the player
16352
+ arranged them. Always a list — empty when they wear none, or when their
16353
+ identity is withheld — so draw what is there and key on `itemId`.
15659
16354
 
15660
16355
  ## ShowcaseFriendEntry.playerId
15661
16356