@indiegems/gem-web-sdk 8.0.0 → 9.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.
@@ -4,6 +4,11 @@ The Arcade can show a **full-screen panel about another game** at a break in you
4
4
  for one; the Arcade decides whether to show anything, draws it in its own chrome over your paused
5
5
  game, and tells you when it is done.
6
6
 
7
+ The same call with `format: 'rewarded'` is a button the player presses for something your game
8
+ gives — a revive, an extra attempt. That one has its own page,
9
+ [`rewarded-ads.md`](rewarded-ads.md). This page is about the interstitial; where a rule here reads
10
+ differently for a rewarded ask, it says so.
11
+
7
12
  ```js
8
13
  const { outcome } = await gem.ads.requestAd({
9
14
  placement: 'level-complete',
@@ -13,8 +18,8 @@ const { outcome } = await gem.ads.requestAd({
13
18
  startNextLevel();
14
19
  ```
15
20
 
16
- That is the whole surface. The rest of this page is the four things a game gets wrong when nobody
17
- tells it.
21
+ That is the whole surface, apart from one read that tells a rewarded button whether it could pay
22
+ (§3). The rest of this page is the four things a game gets wrong when nobody tells it.
18
23
 
19
24
  ## 1. The answer is usually no, and no is ordinary
20
25
 
@@ -37,6 +42,12 @@ So write the `no_ad` path as your ordinary path:
37
42
  - Do **not** hold up your own flow waiting for a fill. Ask, and continue when the promise settles.
38
43
  - Do **not** retry on a no. A second ask at the same break gets the same answer and spends a slot.
39
44
 
45
+ **A rewarded ask reads differently, because the player pressed your button and is waiting on it.**
46
+ Its `no_ad` can arrive with the reward already granted (§3), so branch on your inventory, never on
47
+ the outcome; and where nothing landed, a neutral line in your own UI — "No revive this time" — is
48
+ right, with no reason given. [`rewarded-ads.md`
49
+ §5](rewarded-ads.md#5-draw-the-button-and-name-the-reward) is that button.
50
+
40
51
  ## 2. Nothing rejects
41
52
 
42
53
  `requestAd` always resolves. There is no error branch and no `try`/`catch` to write. Even a refusal
@@ -49,36 +60,68 @@ resolves `no_ad`, so your game keeps exactly one branch. (The mistake is still r
49
60
  `shown` says a panel was painted and has closed. It is **not** a receipt, **not** an entitlement,
50
61
  and **not** permission to hand anything out.
51
62
 
52
- `format: 'rewarded'` exists in the vocabulary and is answered `no_ad` today. Even when that
53
- changes, a reward will be granted by the platform against its own record of what was shown — never
54
- by your game reading an outcome. An outcome travels over the same channel as everything else your
55
- frame receives, and a game that unlocks a level or grants a currency on the strength of one has an
56
- unlock that any page embedding your game can forge.
63
+ That holds for `format: 'rewarded'` too, the one format that pays the player. The platform grants a
64
+ rewarded placement's reward itself, against its own record of what the player did on the Arcade's
65
+ offer sheet — never your game, by reading an outcome. An outcome travels over the same channel as
66
+ everything else your frame receives, and a game that unlocks a level or grants a currency on the
67
+ strength of one has an unlock that any page embedding your game can forge.
57
68
 
58
69
  Treat a break as a break. If your game wants to give the player something for reaching the end of a
59
70
  level, give it to them for reaching the end of the level.
60
71
 
61
- **Where a rewarded grant will arrive, so you can write the code now.** On your game's own inventory
62
- for this player, written by the platform — never by your game — and read through
63
- `gem.me.inventory`, the same place a purchase lands, read the same way. (That is the game-scoped
64
- inventory `gem.me` shows you, not the player's platform wallet.) There will be **no fourth outcome
65
- and no extra field** on the answer. `outcome` is pinned at three values because a new value would
66
- break every game bundle already in the field: its validator refuses the whole message. A new field
67
- would not break anything — validators ignore fields they do not know — but it is not coming either:
68
- anything on that answer can be forged by the page embedding your game, so a reward carried there
69
- would be a reward anyone could hand out. If you serve `rewarded`, re-read your snapshot once the ask
70
- settles, whatever it settled as:
72
+ **Where a rewarded grant arrives.** On your game's own inventory for this player, written by the
73
+ platform — never by your game — and read through `gem.me.inventory`, the same place a purchase
74
+ lands, read the same way. (That is the game-scoped inventory `gem.me` shows you, not the player's
75
+ platform wallet.) There is **no fourth outcome and no extra field** on the answer. `outcome` is
76
+ pinned at three values because a new value would break every game bundle already in the field: its
77
+ validator refuses the whole message. A new field would not break anything — validators ignore
78
+ fields they do not know — but it is not coming either: anything on that answer can be forged by the
79
+ page embedding your game, so a reward carried there would be a reward anyone could hand out. So
80
+ re-read your snapshot once a rewarded ask settles, whatever it settled as:
71
81
 
72
82
  ```js
73
83
  await gem.ads.requestAd({ placement: 'run-banked', format: 'rewarded' });
74
84
  gem.me.inventory.refresh(); // drop what is cached
75
- const snap = await gem.me.inventory.snapshot(); // the grant, if there was one, is here
85
+ const snap = await gem.me.inventory.snapshot(); // a reward granted before the answer is here
76
86
  ```
77
87
 
78
- Today that re-read finds nothing new, because nothing is served. It costs one request at a break
79
- your player is already sitting in, and it is the whole of what changes on the day one is — no
80
- rebuild. [`inventory-and-catalogs.md` §6](inventory-and-catalogs.md#6-when-to-re-read) is the
81
- general form of this.
88
+ A `no_ad` can come back with the reward already in that snapshot, because the platform grants
89
+ without an ad in some cases, so branch on the inventory and never on the outcome. And a reward that
90
+ an ad provider confirms from its own server can land after this re-read, so read again when the
91
+ screen that spends it opens; [`inventory-and-catalogs.md`
92
+ §6](inventory-and-catalogs.md#6-when-to-re-read) is the general form of this.
93
+ [`rewarded-ads.md`](rewarded-ads.md) is the rest: declaring what a placement grants, spending it,
94
+ drawing the button, and testing.
95
+
96
+ **Whether a rewarded button could pay right now.** A rewarded placement is a button the player
97
+ presses, so you need to know whether to draw it. `gem.ads.rewardedStatus(placement)` reads one
98
+ placement's state from **your own** `rewarded_placements` config — its daily cap, cooldown and
99
+ `max_held` — applied to this player's use of it:
100
+
101
+ ```js
102
+ const status = await gem.ads.rewardedStatus('run-banked');
103
+ bankButton.disabled = !status.grantable;
104
+ ```
105
+
106
+ | field | what it says |
107
+ |---|---|
108
+ | `declared` | the config promoted to the channel being played names this placement |
109
+ | `grantable` | pressing the button could pay right now — **not** that an ad will play |
110
+ | `remainingToday` | grants left today under the daily cap; absent when the placement has none |
111
+ | `nextEligibleAt` | when a running cooldown ends, in epoch milliseconds; absent when none runs |
112
+ | `heldLimitReached` | the player already holds the placement's `max_held` — spend one first |
113
+ | `resetsAt` | when today's count resets, the next 00:00 UTC, in epoch milliseconds |
114
+
115
+ **`grantable` is not a promise.** A player can press a `grantable` button and get `no_ad`, for
116
+ reasons your game is not told. Read that `no_ad` like any other (§1) — never guess a reason — and
117
+ re-read your inventory whatever the ask settled as. The status grants nothing and reserves
118
+ nothing.
119
+
120
+ It never rejects, like the rest of `gem.ads`. Without a play token, when the read fails or takes
121
+ longer than ten seconds, or for a placement outside the grammar below, it answers not declared and
122
+ not grantable with no `resetsAt`, and the reason goes to your `onError` handler. It is a read of
123
+ the platform, so call it when you draw the button — on a results or death screen — and not every
124
+ frame, and draw the rest of the screen first rather than holding it on the answer.
82
125
 
83
126
  ## 4. The Arcade pauses your game for you
84
127
 
@@ -94,20 +137,23 @@ If you have not wired the pause at all yet, that is the thing to fix first;
94
137
 
95
138
  At a break **the player already expects**: a level ending, a round finishing, a run being banked, a
96
139
  return to a menu. Never mid-input, never during a timed sequence, and never on a screen the player
97
- reached by accident.
140
+ reached by accident. (A rewarded ask goes where the player pressed your button for it, and nowhere
141
+ else; [`rewarded-ads.md`](rewarded-ads.md) covers the button.)
98
142
 
99
- `placement` is your own name for that break — `level-complete`, `run-banked`, `back-to-menu`. The
100
- platform matches it against nothing today, never shows it to a player, and does not vary its
101
- decision on it. It exists so your own analytics can tell two break points apart, so name it for
102
- yourself.
143
+ `placement` is your own name for that break — `level-complete`, `run-banked`, `back-to-menu`. For
144
+ an interstitial, the platform matches it against nothing, never shows it to a player, and does not
145
+ vary its decision on it; it exists so your own analytics can tell two break points apart, so name
146
+ it for yourself. For `format: 'rewarded'`, the placement names the reward your game config declares
147
+ in `rewarded_placements`, and the platform matches it against that config. Either way it **never
148
+ reaches an ad network**, and neither does any other string your game writes.
103
149
 
104
150
  **It has a grammar, and it is narrow on purpose: 1 to 32 characters of `a-z`, `0-9`, `-` and `_`.**
105
151
  Lowercase, no spaces, no accents — `'Boss Fight'` is not a placement, `'boss-fight'` is. Anything
106
152
  else resolves `no_ad` and is reported to your `onError` handler, so you meet it while you are
107
- building rather than later. The reason it is enforced now, while nothing depends on it, is that
108
- this is the field a promotion is eventually **keyed by**: the Arcade will forward it unchanged, and
109
- a name that does not fit the platform's grammar would be a name that stopped working after your
110
- game had shipped. The bound is the platform's, not the SDK's, and the two agree.
153
+ building rather than later. The platform holds the same grammar where it reads the name: for a
154
+ rewarded ask the Arcade forwards the placement unchanged, and a config that declares one outside
155
+ the grammar is refused at upload, so a name that did not fit would be a reward your shipped game
156
+ could never reach. The bound is the platform's, not the SDK's, and the two agree.
111
157
 
112
158
  ```js
113
159
  async function onLevelComplete() {
@@ -130,6 +176,16 @@ schedule that does not depend on which branch produced it, so there is nothing i
130
176
  read either. (`shown` is not held back — how long that one takes is how long your player spent
131
177
  looking at a panel.)
132
178
 
179
+ A rewarded ask is the one case where something else reaches your game: the reward itself, in your
180
+ own inventory, when one was granted. That is what the ask is for. It says that a reward landed,
181
+ never why, so do not read a reason into it.
182
+
183
+ The other thing you can read is your own economy, and never from the answer:
184
+ `gem.ads.rewardedStatus` reports **your own declared** rewarded caps as the platform holds them —
185
+ the platform can hold a placement's daily cap below what you declared, never above — so you can
186
+ tell when a rewarded `no_ad` was your own cap. It never reports anything about the player, the
187
+ platform's spacing between offers, or any limit that spans games.
188
+
133
189
  ## If you do nothing
134
190
 
135
191
  Nothing happens. A game that never calls `gem.ads.requestAd` is never interrupted, and the call is
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "8.0.0"
53
+ "@indiegems/gem-web-sdk": "9.0.0"
54
54
  }
55
55
  ```
56
56
 
@@ -355,7 +355,8 @@ What reaches the frame:
355
355
  only keys the arcade keeps for itself; it never interprets yours:
356
356
  - Store and play-page switches: `play`, `channel`, `purchase`, and `build`, which pins one
357
357
  of your own builds when you play it from your Builds page.
358
- - Platform switches: `devtools`, `devLocale`.
358
+ - Platform switches: `devtools`, `devLocale`, and `gem_test_ads`, the mode of the arcade's
359
+ rewarded TEST ads.
359
360
  - The arcade's own sign-in return marker: `social`.
360
361
  - Attribution and campaign keys, which the arcade keeps out of every game: `_s`, `utm_*`
361
362
  (every key with that prefix, matched case-sensitively, so UTM_X and utmost are yours),
@@ -8,8 +8,8 @@
8
8
  "documents": [
9
9
  {
10
10
  "file": "achievements.md",
11
- "sha256": "e67bc5d0e6abd9ff842be46fa82bf5117fce74b2eb9d28a3954919ddc8a11c73",
12
- "updated": "2026-10-02T23:09:37Z"
11
+ "sha256": "adc8bfaa769fbb283a53de1190ed85cdf4c1c9753e8cf78335b4595e98f8fad4",
12
+ "updated": "2026-10-06T22:14:30Z"
13
13
  },
14
14
  {
15
15
  "file": "analytics.md",
@@ -33,13 +33,13 @@
33
33
  },
34
34
  {
35
35
  "file": "changelog.md",
36
- "sha256": "37bae4170e89587ee8c498891fa2dac008f09fb01fcfbcf87b1310340d2cfc62",
37
- "updated": "2026-10-06T19:43:21Z"
36
+ "sha256": "dd8afde50d0e34052ebdb8b894d30faa4814d4209fc62644fdc5ab53a1395e32",
37
+ "updated": "2026-10-08T00:55:05Z"
38
38
  },
39
39
  {
40
40
  "file": "cross-promotion.md",
41
- "sha256": "3a8121a9c973e4a1bfed076a3226d139f8b4e38a173a06ea9f08000c72bca4b4",
42
- "updated": "2026-09-29T17:32:40Z"
41
+ "sha256": "4336d72595115950919361c36ab1af9beaa787c3c57d27fea27d42dac69d81cf",
42
+ "updated": "2026-10-07T19:15:38Z"
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
@@ -68,8 +68,8 @@
68
68
  },
69
69
  {
70
70
  "file": "getting-started.md",
71
- "sha256": "77ce7b2223d18267ac3ca2f49e09481752539f2674910e2cb12832825c945000",
72
- "updated": "2026-10-06T19:43:21Z"
71
+ "sha256": "95f645a73bfb522537fe14562d4a87b10a7f06af036c5658996c05bddced875b",
72
+ "updated": "2026-10-08T00:55:05Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -83,8 +83,8 @@
83
83
  },
84
84
  {
85
85
  "file": "match-session.md",
86
- "sha256": "738c54766f5489ba7509f5f31ef1387a2a80ad89f03eaccd52113a18f2765b0d",
87
- "updated": "2026-09-25T21:03:24Z"
86
+ "sha256": "33fdef4edc170cadd2284ff399fcf16455b7691d9aeac52bd09bade24b8b051a",
87
+ "updated": "2026-10-07T19:27:16Z"
88
88
  },
89
89
  {
90
90
  "file": "p2p-and-parties.md",
@@ -101,15 +101,20 @@
101
101
  "sha256": "df1e39628ccba04b6f7bcd0b90381dfa34a4480330a6af041d9b71bcaefddb5f",
102
102
  "updated": "2026-09-17T20:51:57Z"
103
103
  },
104
+ {
105
+ "file": "rewarded-ads.md",
106
+ "sha256": "39332bd7b08adbb33d3b6382142d214e17d8f24a7776061a97f372d658e48974",
107
+ "updated": "2026-10-07T19:17:55Z"
108
+ },
104
109
  {
105
110
  "file": "room-shapes.md",
106
- "sha256": "5aab02d65a7573ac690649b70e5db4fed4f779c3102c35e64a8ced02218410a9",
107
- "updated": "2026-09-29T23:14:43Z"
111
+ "sha256": "78fad1eee7602b10a9b39f100d3daedfed6c313a884f95382233434a3206930b",
112
+ "updated": "2026-10-07T19:27:16Z"
108
113
  },
109
114
  {
110
115
  "file": "rooms-and-matches.md",
111
- "sha256": "d8c397bc3657d7f46dba7ca0af81dcf181148df798dbaced27dfd294889a1da6",
112
- "updated": "2026-09-25T02:14:45Z"
116
+ "sha256": "0abf3717a42c94ec832dfa1310c2d93a9f58236fd75b0aacaab301976bffe286",
117
+ "updated": "2026-10-07T19:27:16Z"
113
118
  },
114
119
  {
115
120
  "file": "saving-player-progress.md",
@@ -118,8 +123,8 @@
118
123
  },
119
124
  {
120
125
  "file": "server-bundles.md",
121
- "sha256": "49f51b37e969b6120d2df6cf6d2f33d2856c1602fd7504a7b308a9ea21c9b502",
122
- "updated": "2026-10-06T19:43:21Z"
126
+ "sha256": "584ace965c1be20ca90ee1cd3beff7c1473f1ca9bccef428b3efb6846b2315a7",
127
+ "updated": "2026-10-08T00:55:05Z"
123
128
  },
124
129
  {
125
130
  "file": "serverless-functions-js.md",
@@ -128,8 +133,8 @@
128
133
  },
129
134
  {
130
135
  "file": "serverless-functions.md",
131
- "sha256": "1edaa60a8aaf20499f24d661f169790cacfdb11c26529735a93e5b7b996e5432",
132
- "updated": "2026-10-03T00:26:43Z"
136
+ "sha256": "b1562b4442b69bb6895198def7b00ccd7a8c56133862a939c2cce9b6e9d30290",
137
+ "updated": "2026-10-07T19:27:16Z"
133
138
  },
134
139
  {
135
140
  "file": "trading-and-gifting.md",
@@ -143,8 +148,8 @@
143
148
  },
144
149
  {
145
150
  "file": "what-a-game-must-do.md",
146
- "sha256": "436a8b61c46a3de6c7d5db65293bdcd17426412742dffd21f976bf87d1418d88",
147
- "updated": "2026-10-06T17:36:25Z"
151
+ "sha256": "7dc6bdc55e3cfbc88d667bbc2ebf908e357a1b6d768d5e3f4ce486ded614c1bf",
152
+ "updated": "2026-10-07T19:15:38Z"
148
153
  }
149
154
  ]
150
155
  }
@@ -204,7 +204,7 @@ next round is one call:
204
204
 
205
205
  **A template's `auto_start_host` starts the first match, not the second.** It fires when the room
206
206
  reaches `min_players`; a reopened room already has them, and nothing fires again. A shape whose
207
- first match started itself — a hub, a worker, a matchmade lobby — still needs a client to call
207
+ first match started itself — a hub, a matchmade lobby — still needs a client to call
208
208
  `startMatch()` for the next one, and that client is whoever `gem.rooms.isHost` says. Watch
209
209
  `hostChanged`: if the host closed their tab during the match, the room has a new host, and the
210
210
  start belongs to them now.