@indiegems/gem-web-sdk 9.0.0 → 9.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,23 +1,29 @@
1
1
  # Rewarded ads
2
2
 
3
- A rewarded placement is a button your player presses for something your game gives — a revive, an
4
- extra attempt at today's puzzle. Your game asks the Arcade. Where an ad serves, the Arcade shows its
5
- own offer sheet and then the ad; either way the platform decides whether the player is rewarded and
6
- writes the reward into **your game's own inventory** for that player. Your game never grants the
7
- reward, and never learns from the answer whether one was granted. It reads its inventory.
3
+ A rewarded placement is a button your player presses for something your game gives, such as a
4
+ revive or three extra dice rolls. The button says it is an ad and names the reward: "Watch an ad
5
+ for 3 dice rolls". Pressing it is the player's choice to watch, so the Arcade starts the ad
6
+ straight away, labelled as an ad, with no prompt of its own first. The platform then decides
7
+ whether the player is rewarded, and writes the reward into **your game's own inventory** for that
8
+ player. Your game never grants the reward, and never learns from the answer whether one was
9
+ granted. It reads its inventory.
8
10
 
9
11
  ```js
10
- await gem.ads.requestAd({ placement: 'classic_revive', format: 'rewarded' });
11
- gem.me.inventory.refresh(); // drop what is cached
12
- const snap = await gem.me.inventory.snapshot(); // a reward granted before the answer is here
12
+ watchAdButton.addEventListener('click', async () => {
13
+ // First, inside the click: the press is what lets the ad start.
14
+ await gem.ads.requestAd({ placement: 'dice_rolls', format: 'rewarded' });
15
+ gem.me.inventory.refresh(); // drop what is cached
16
+ const snap = await gem.me.inventory.snapshot(); // a reward granted before the answer is here
17
+ });
13
18
  ```
14
19
 
15
20
  That is the whole of the asking. What makes it pay is four things you set up once:
16
21
 
17
- 1. **A reward** — an item or currency of your own that the player keeps (§1).
18
- 2. **A placement** in your game config, saying what one press grants and how often (§2).
19
- 3. **A spend** — the recipe your game runs when the player uses the reward (§4).
20
- 4. **A button** that names the reward (§5).
22
+ 1. **A reward**: an item or currency of your own that the player keeps (§1).
23
+ 2. **A placement** in your game config. It says what one press grants, how long the ad plays, how
24
+ often it can pay, and what a press gets when no ad plays (§2).
25
+ 3. **A spend**: the recipe your game runs when the player uses the reward (§4).
26
+ 4. **A button** that says it is an ad, names the reward, and is drawn only when it can pay (§5).
21
27
 
22
28
  The same asking and spending code runs whether the Arcade plays a test ad, grants the reward with no
23
29
  ad, or plays a real one (§8), and moving between those needs no new SDK version and no config
@@ -42,8 +48,8 @@ the upload refuses a config where it is not:
42
48
  refused, and the finding names the path from the reward to the thing it reached.
43
49
  - **Never spent on a loot draw.** Neither the reward nor anything it converts into may be a cost
44
50
  of a recipe that rolls a loot table, so "spend a token to spin the wheel" is refused too.
45
- - **A plain `display_name`.** The Arcade prints it on its offer sheet — "1 × Revive token" — in
46
- its own chrome, so a name with markup or invisible characters is refused.
51
+ - **A plain `display_name`.** It is the reward's name wherever the Arcade shows it in its own
52
+ chrome, so a name with markup or invisible characters is refused.
47
53
 
48
54
  And three things the upload warns about rather than refuses:
49
55
 
@@ -55,7 +61,8 @@ And three things the upload warns about rather than refuses:
55
61
  allowed, with a warning, but it must say `without_ads: nothing` (§2) — the upload refuses one
56
62
  that says `grant` — and outside test ads it is only ever served behind an ad whose completion
57
63
  the platform verifies with the ad provider.
58
- - **Keep its `display_name` to 40 characters or fewer.** The offer sheet cuts a longer one short.
64
+ - **Keep its `display_name` to 40 characters or fewer.** The Arcade cuts a longer one short where
65
+ it shows it.
59
66
 
60
67
  For a revive, the reward is a single-use token:
61
68
 
@@ -64,7 +71,7 @@ items:
64
71
  - id: '<uuid>'
65
72
  slug: revive_token
66
73
  item_type: item
67
- display_name: 'Revive token' # the offer sheet says "1 × Revive token"
74
+ display_name: 'Revive token' # the reward's name in the Arcade's own chrome
68
75
  fungible: true
69
76
  stack_size: 1
70
77
  consumable: true
@@ -86,71 +93,147 @@ rewarded_placements:
86
93
  - { type: item_grant, item_definition_slug: revive_token, quantity: 1 }
87
94
  max_per_day: 5 # optional: leave it out for no daily cap
88
95
  max_held: 1 # no grant while the player already holds one
89
- without_ads: grant # required: what happens where no ad serves
96
+ without_ads: grant # required: what a press gets when no ad plays
90
97
  - placement: daily_drop_extra
91
98
  grants:
92
99
  - { type: item_grant, item_definition_slug: daily_drop_attempt, quantity: 1 }
100
+ min_watch_secs: 15 # optional: leave it out for the platform's default, 5 seconds
93
101
  max_per_day: 2
94
102
  max_held: 1
95
103
  without_ads: grant
96
104
  ```
97
105
 
98
- | field | what it says |
99
- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
- | `placement` | The name your game passes. 1 to 32 characters of `a-z`, `0-9`, `-` and `_`, unique in the document. At most 16 placements per game. |
101
- | `grants` | 1 to 4 `item_grant` or `currency_grant` operations on your own items and currencies (§1). A `currency_grant` takes an `amount`; an `item_grant` takes a `quantity`. |
102
- | `max_per_day` | Grants per player per UTC day. **Optional**: leave it out and the placement has no daily cap (§6). |
103
- | `cooldown_seconds` | Seconds between two grants to one player, 0 to 86400. Defaults to 0. |
104
- | `max_held` | No grant while the player already holds at least this many of the granted item. 1 to 100, and only on a placement whose grants are all items. |
105
- | `without_ads` | **Required, with no default:** `grant` or `nothing`. What an ask gets where no ad serves your channel (§8). |
106
- | `allowance_currency_slug` | Optional: one of your own currencies that each grant also spends 1 of. Use it when another path in your game must share the same daily cap. It must not be `client_writable`, must have `transfer_policy: none`, and needs a bucket that resets (daily, say) or it never refills. |
106
+ | field | what it says |
107
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | `placement` | The name your game passes. 1 to 32 characters of `a-z`, `0-9`, `-` and `_`, unique in the document. At most 16 placements per game. |
109
+ | `grants` | 1 to 4 `item_grant` or `currency_grant` operations on your own items and currencies (§1). A `currency_grant` takes a `currency_slug` and an `amount`; an `item_grant` takes an `item_definition_slug` and a `quantity`. |
110
+ | `min_watch_secs` | How long the ad plays before the press pays, in whole seconds, 1 to 150. The ceiling is there because the SDK settles a rewarded ask after 180 seconds and the Arcade gives up on an ad still open at 170 seconds; an ad still open then is a skip and pays nothing. **Optional**: leave it out and the platform's default applies, which is 5 seconds unless the platform sets another. A test ad that closes sooner pays nothing. A real ad provider decides for itself when its ad has finished, so for a real ad this is the length the Arcade asks for, not a rule (§8). |
111
+ | `max_per_day` | Grants per player per UTC day. **Optional**: leave it out and the placement has no daily cap (§6). |
112
+ | `cooldown_seconds` | Seconds between two grants to one player, 0 to 86400. Defaults to 0. |
113
+ | `max_held` | No grant while the player already holds at least this many of the granted item. 1 to 100, and only on a placement whose grants are all items. |
114
+ | `without_ads` | **Required, with no default:** `grant` or `nothing`. What a press gets when no ad plays: where no ad serves your channel at all, and where one does but no ad was available for this press or it failed to play (§8). |
115
+ | `allowance_currency_slug` | Optional: one of your own currencies that each grant also spends 1 of. Use it when another path in your game must share the same daily cap. It must not be `client_writable`, must have `transfer_policy: none`, and needs a bucket that resets (daily, say) or it never refills. |
107
116
 
108
117
  The placement only **selects** a reward your own config declared. Your game sends no reward, no
109
- amount and no item in the request — nothing from your frame is trusted for that — so a modified
110
- client can ask for a declared placement and nothing else.
118
+ amount, no watch time and no item in the request — nothing from your frame is trusted for that — so
119
+ a modified client can ask for a declared placement and nothing else.
111
120
 
112
121
  Ship it the way every config section ships: upload, then promote to a channel. A channel serves
113
- the placements in the config version promoted to it, and a reward can be retuned, or `without_ads`
114
- flipped, with a new upload and a promote, with no new build.
122
+ the placements in the config version promoted to it, and a reward can be retuned, its watch time
123
+ changed, or `without_ads` flipped, with a new upload and a promote, with no new build.
115
124
 
116
125
  ⚠️ **Check that `gemctl config schema` lists `rewarded_placements` before you rely on it.** The
117
126
  upload drops a top-level section the backend does not know without a word, so a placement uploaded
118
- where the schema does not list it does nothing at all.
127
+ where the schema does not list it does nothing at all. The same goes for `min_watch_secs`: if the
128
+ schema does not list it, the placement plays for the platform's default.
119
129
 
120
- ## 3. Ask, then re-read
130
+ ### More for watching longer: two placements
121
131
 
122
- ```js
123
- await gem.ads.requestAd({ placement: 'classic_revive', format: 'rewarded' });
124
- gem.me.inventory.refresh();
125
- const snap = await gem.me.inventory.snapshot();
132
+ To pay more for a longer ad, declare **two placements**, each with its own watch time, reward and
133
+ caps, and draw two buttons:
134
+
135
+ ```yaml
136
+ rewarded_placements:
137
+ - placement: dice_rolls_short
138
+ grants:
139
+ - { type: item_grant, item_definition_slug: bonus_roll, quantity: 1 }
140
+ min_watch_secs: 5
141
+ max_per_day: 10
142
+ without_ads: grant
143
+ - placement: dice_rolls_full
144
+ grants:
145
+ - { type: item_grant, item_definition_slug: bonus_roll, quantity: 3 }
146
+ min_watch_secs: 30
147
+ max_per_day: 3
148
+ without_ads: nothing # see below
126
149
  ```
127
150
 
128
- The answer is one of the same three outcomes every `requestAd` gets, and for `rewarded` none of
129
- them says whether a reward was granted:
151
+ "Watch a short ad for 1 dice roll" asks for `dice_rolls_short`. "Watch a full ad for 3 dice rolls"
152
+ asks for `dice_rolls_full`. There is no partial payout: a player who closes the full ad after ten
153
+ seconds has skipped it and gets nothing, not the short reward.
130
154
 
131
- | outcome | what happened | was anything granted? |
132
- | ------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
133
- | `shown` | the Arcade painted its offer sheet, and it has closed, whatever the player chose in it | if the player watched to the end, yes, before the answer; if they declined or skipped, or no ad was available after all, no |
134
- | `no_ad` | nothing was painted | maybe: in some cases the platform grants with no ad — where no ad serves and the placement says `without_ads: grant` (§8), for one — and then the reward was granted before the answer. Otherwise no |
135
- | `unavailable` | the Arcade could not ask | almost always no, but read your inventory anyway |
155
+ Choose `without_ads` for each placement on its own. Where no ad plays (§8), a `grant` placement
156
+ pays without one. If both said `grant`, the bigger reward would cost the player nothing extra, and
157
+ everyone would press that button. `nothing` on the bigger one keeps it a reward for watching.
136
158
 
137
- So **branch on your inventory, never on the outcome.** Re-read it once the ask settles, whatever it
138
- settled as.
159
+ ## 3. Ask from the press, then re-read
160
+
161
+ ```js
162
+ watchAdButton.addEventListener('click', async () => {
163
+ watchAdButton.disabled = true; // only between the press and the answer
164
+ try {
165
+ await gem.ads.requestAd({ placement: 'classic_revive', format: 'rewarded' });
166
+ gem.me.inventory.refresh();
167
+ const snap = await gem.me.inventory.snapshot();
168
+ // ...decide from `snap`, never from the outcome
169
+ } finally {
170
+ watchAdButton.disabled = false; // or redraw it from `rewardedStatus` (§5)
171
+ }
172
+ });
173
+ ```
174
+
175
+ **Ask first, inside the button's own handler.** The press is the player's opt-in, and the Arcade
176
+ starts the ad as soon as the ask arrives. A browser counts a press for only a few seconds, so put
177
+ the ask before any `await` or timer that could outlast it. A tap, click or key press counts, and
178
+ so does a controller press in the last second. Where the browser reports user activation, your
179
+ frame holds none, and no controller was used in the last second, the SDK (releases after 9.0.0)
180
+ answers `no_ad` without asking. Your `onError` handler then gets code `'internal'` and a message that begins
181
+ `rewarded_requires_user_gesture`, and the SDK writes the same line to the console once, as a
182
+ `[gem]` line, unless you set `log: null`. Where the browser cannot tell, the ask goes ahead.
183
+
184
+ **Which handler counts is the browser's decision, and two common ones do not:**
185
+
186
+ - **Ask from `click`, `pointerup`, `touchend` or `keydown`.** On a phone, `click` and `pointerup`
187
+ arrive after the finger lifts, which is when the browser counts the tap.
188
+ - **Never from `pointerdown` or `touchstart`.** On a touchscreen they run _before_ the browser
189
+ counts the tap, so the ask is refused, except when an earlier tap happened a few seconds before.
190
+ With a mouse, `pointerdown` does count, so **testing at a desk will not show this**. Test the
191
+ button on a phone. A three.js button picked by a raycast on `pointerdown` is the usual way to hit
192
+ it: raycast on `pointerup` or `click` for this button.
193
+ - **A controller press counts for one second.** Browsers do not treat gamepad input as a press,
194
+ so the SDK counts it itself: the ask goes ahead when any connected controller's state changed in
195
+ the last second. Ask from the frame of your input loop that saw the button go down, not after a
196
+ menu animation or a timer. A TV remote needs none of this: its buttons arrive as key presses,
197
+ and they count like any other.
198
+
199
+ A refusal is your bug to fix. It is usually a read placed in front of the ask, a handler on
200
+ `pointerdown`, or an ask on a timer.
201
+
202
+ **It is a check for a mistake, not a control.** It catches an ask with no recent input at all. It
203
+ cannot tell which input caused the ask: a browser counts any tap in your game for a few seconds,
204
+ and the SDK counts any controller input for one, so an ask from your jump button's handler, or
205
+ from a death a second after a tap, goes through.
206
+ Making the ask only from the button is yours to get right.
207
+ [`cross-promotion.md`](cross-promotion.md#where-to-put-the-call) has the handler.
208
+
209
+ The answer is one of the same three outcomes every `requestAd` gets — `shown`, `no_ad` or
210
+ `unavailable` — and for `rewarded` none of them says whether a reward was granted. What can happen
211
+ after a press:
212
+
213
+ | what happened | was the reward granted? |
214
+ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
215
+ | the ad played for its full length | yes, before the answer |
216
+ | the player closed the ad early | no: closing early is a skip, and a skip pays nothing |
217
+ | an ad source serves, but no ad was available for this press, or it failed to play | your `without_ads` decides: `grant` grants within your caps, with no ad; `nothing` grants nothing, and the Arcade shows "No ad available right now" |
218
+ | no ad source serves the channel (§8) | your `without_ads` decides, with no ad |
219
+ | a cap, a cooldown, `max_held`, the platform's spacing (§6), or something about the player | no |
220
+
221
+ In some cases the platform also grants with no ad for reasons your game is not told. So **branch on
222
+ your inventory, never on the outcome.** Re-read it once the ask settles, whatever it settled as.
139
223
 
140
224
  **A reward can also arrive after that re-read.** Some ad providers confirm a finished ad from their
141
- own server, a little after the player closes it. The Arcade waits a few seconds for that, then
142
- tells the player "Your reward is on its way", and the ask answers `shown` when its sheet closes,
143
- with the reward still to come. So also re-read when the screen that spends the reward opens, and
144
- spend a token the player already holds before asking for another (§5's code does both).
225
+ own server, a little after the player closes it, so the reward can land after the ask has
226
+ answered. So also re-read when the screen that spends the reward opens, and spend a token the
227
+ player already holds before asking for another (§5's code does both).
145
228
  [`inventory-and-catalogs.md` §6](inventory-and-catalogs.md#6-when-to-re-read) is the general rule.
146
229
 
147
230
  A granted reward shows in your order history like any other change, as an order the platform made
148
231
  with `source` `reward`.
149
232
 
150
- **While the Arcade's sheet is up, your game is paused,** the same way it is for an interstitial:
151
- the Arcade sends the pause your game already handles and lifts it when the sheet closes. Resume
152
- when the promise settles, not on a timer. A second `requestAd` while one is still in flight
153
- resolves `no_ad` at once, so disable the button between the press and the answer.
233
+ **While the ad plays, your game is paused,** the same way it is for an interstitial: the Arcade
234
+ sends the pause your game already handles and lifts it when the ad closes. Resume when the promise
235
+ settles, not on a timer. A second `requestAd` while one is still in flight resolves `no_ad` at once,
236
+ so disable the button between the press and the answer.
154
237
 
155
238
  ## 4. Spend it with a recipe
156
239
 
@@ -190,35 +273,43 @@ daily puzzle that posts to a leaderboard is only as honest as whatever accepts t
190
273
  server or function that does should confirm each attempt beyond the free one spent a token. A
191
274
  client-side check is a check a modified client skips.
192
275
 
193
- ## 5. Draw the button, and name the reward
276
+ ## 5. Draw the button only when it can pay
277
+
278
+ **The button says it is an ad, and names the reward:** "Watch an ad to revive", "Watch an ad for 3
279
+ dice rolls". The press is the player's choice to watch, and the Arcade starts the ad without asking
280
+ again, so your label is what tells the player an ad follows.
194
281
 
195
- **Label the button with what the player gets — "Revive", "+1 attempt" — and nothing else.**
282
+ - **Never a label that hides the ad,** and never one that reads as the only way forward. "Tap to
283
+ continue" on a button that starts an ad disguises the ask. Draw the ordinary way on — end the
284
+ run, back to the menu — beside it, so pressing it is a choice.
285
+ - **The same label in every state.** Where no ad plays (§8), the press pays without one. The label
286
+ promised an ad the player did not have to watch, which is fine.
287
+ - **When the press paid nothing**, carry on, and show a neutral line in your own UI such as "No
288
+ revive this time". Never guess a reason: you are not told one. When no ad was available and
289
+ nothing was granted, the Arcade has already said "No ad available right now", so your line need
290
+ not repeat it.
196
291
 
197
- - **Do not write "Watch an ad to …".** Where no ad serves (§8), no ad plays, and the label would be
198
- false. The Arcade's offer sheet is the ad disclosure: it says that it is an ad and what the player
199
- gets, and offers "No thanks", before anything plays, and it appears only when an ad will.
200
- - **Never a label that reads as the only way forward.** "Tap to continue" on a button that asks
201
- for an ad disguises the ask. Draw the ordinary way on — end the run, back to the menu — beside
202
- it, so pressing it is a choice.
203
- - **When the press paid nothing**, show a neutral line in your own UI — "No revive this time" —
204
- and carry on. Never guess a reason: you are not told one.
292
+ **A game built under the earlier guide must be rebuilt and relabelled.** The guide before this one put an offer sheet of the Arcade's own in front of every rewarded ad, so its button could say "Revive" with no mention of an ad. There is no sheet any more, so that button now starts an ad unannounced. Rebuild on an SDK with the press check (§3) and relabel the button by this section before paid ads come to the Arcade. Until then, rewarded on such a game only ever shows test ads to its own team and testers.
205
293
 
206
294
  Your own rules decide when the button appears at all — once per run, only on the death screen,
207
295
  never in a ranked mode — because the platform cannot see a run (§6).
208
296
 
209
- **Whether it can pay right now is `gem.ads.rewardedStatus`** (SDK 8.1.0 and later). It reads the
210
- placement's caps as the platform holds them, `declared`, `grantable`, `remainingToday`,
211
- `nextEligibleAt`, `heldLimitReached` and `resetsAt`, so you can grey the button out or say when it
212
- is back instead of offering a press that cannot pay. `grantable` is not a promise: an ask can still
213
- answer `no_ad` for reasons the read never reports. It reserves nothing, and it never rejects. See
214
- [cross-promotion.md](cross-promotion.md):
297
+ **Within those rules, draw it only when `gem.ads.rewardedStatus` says it can pay** (SDK 8.1.0 and
298
+ later). The status reads the placement's caps as the platform holds them: `declared`, `grantable`,
299
+ `remainingToday`, `nextEligibleAt`, `heldLimitReached` and `resetsAt`. `grantable` is `false` when
300
+ a cap is spent, a cooldown runs, the player holds `max_held`, or rewarded is off on the channel, so
301
+ drawing on it never offers a press that is certain to pay nothing. Hide or grey the button out
302
+ otherwise, and say when it is back from `nextEligibleAt` or `resetsAt`. `grantable` is not a
303
+ promise: a press can still pay nothing for reasons the read never reports. The status reserves
304
+ nothing, and it never rejects. See [cross-promotion.md](cross-promotion.md):
215
305
 
216
306
  ```js
217
307
  const status = await gem.ads.rewardedStatus('classic_revive');
218
- reviveButton.disabled = !status.grantable;
308
+ watchAdButton.hidden = !status.grantable;
219
309
  ```
220
310
 
221
- For a revive, the button's handler:
311
+ For a revive, the screen reads what it needs **when it opens**, so the press itself can ask at
312
+ once:
222
313
 
223
314
  ```js
224
315
  async function reviveTokens() {
@@ -247,27 +338,59 @@ async function spendRevive(before) {
247
338
  }
248
339
  }
249
340
 
250
- async function onRevivePressed() {
341
+ // When the death screen opens: the reads happen here, before any press.
342
+ async function drawReviveButtons() {
343
+ useTokenButton.hidden = true;
344
+ watchAdButton.hidden = true;
251
345
  if (run.revived) return; // the game's own rule: one revive per run
252
- reviveButton.disabled = true; // only between the press and the answer
253
- let revived = false;
254
346
  try {
255
- if ((await reviveTokens()) === 0) {
256
- // A token left over from earlier is spent first, so ask only when there is none.
257
- await gem.ads.requestAd({ placement: 'classic_revive', format: 'rewarded' });
347
+ if ((await reviveTokens()) > 0) {
348
+ // A token left over from earlier is spent first, with no ad.
349
+ useTokenButton.textContent = 'Use a revive token';
350
+ useTokenButton.hidden = false;
351
+ return;
258
352
  }
259
- const held = await reviveTokens();
353
+ } catch {
354
+ return; // a read failed: no revive on offer this time
355
+ }
356
+ const status = await gem.ads.rewardedStatus('classic_revive');
357
+ watchAdButton.textContent = 'Watch an ad to revive';
358
+ watchAdButton.hidden = !status.grantable;
359
+ }
360
+
361
+ async function onWatchAdPressed() {
362
+ watchAdButton.disabled = true; // only between the press and the answer
363
+ // FIRST, inside the click. Anything awaited before this can outlast the press.
364
+ await gem.ads.requestAd({ placement: 'classic_revive', format: 'rewarded' });
365
+ await reviveIfHeld(); // whatever the answer was
366
+ watchAdButton.disabled = false;
367
+ }
368
+
369
+ async function onUseTokenPressed() {
370
+ useTokenButton.disabled = true;
371
+ await reviveIfHeld();
372
+ useTokenButton.disabled = false;
373
+ }
374
+
375
+ // `click`, never `pointerdown`: on a phone, a `pointerdown` runs before the tap counts (§3).
376
+ watchAdButton.addEventListener('click', onWatchAdPressed);
377
+ useTokenButton.addEventListener('click', onUseTokenPressed);
378
+
379
+ async function reviveIfHeld() {
380
+ let revived = false;
381
+ try {
382
+ const held = await reviveTokens(); // the re-read after the ask
260
383
  revived = held > 0 && (await spendRevive(held));
261
384
  } catch {
262
- // A read failed. That is no revive this time; the player can press again.
385
+ // A read failed. That is no revive this time.
263
386
  }
264
- reviveButton.disabled = false;
265
387
  if (revived) {
266
388
  run.revived = true;
267
389
  revive(); // only once the spend is known to have landed
268
390
  } else {
269
391
  showNoRevive(); // "No revive this time": neutral, and no reason
270
392
  }
393
+ await drawReviveButtons(); // redraw from the new state
271
394
  }
272
395
  ```
273
396
 
@@ -282,6 +405,7 @@ cannot reach them. They apply the same with an ad and without one.
282
405
  | `max_per_day` — grants per player per UTC day | you, optionally | the platform |
283
406
  | `cooldown_seconds` — the time between two grants | you | the platform |
284
407
  | `max_held` — no grant while the player holds this many | you, for item rewards | the platform |
408
+ | `min_watch_secs` — how long a test ad plays before it pays | you, optionally | the platform |
285
409
  | at least 15 seconds between two rewarded offers to one player in one game | the platform | the platform |
286
410
  | at least 15 seconds between two rewards granted with no ad, in any of the player's games | the platform | the platform |
287
411
  | once per run, only on the death screen, never in ranked | you | **your game** |
@@ -289,19 +413,22 @@ cannot reach them. They apply the same with an ad and without one.
289
413
  - **The second spacing reaches across games.** If another game granted this player a reward with
290
414
  no ad a few seconds ago, an ask in yours that would also grant with no ad (§8) answers `no_ad`
291
415
  with nothing granted, even though your game has offered nothing.
292
- - **What counts toward a cap** is a grant, with or without an ad, and an ad that is still playing.
293
- An offer the player declined, one no ad was available for, and an ad that was skipped or failed
294
- do not count, so "No thanks" costs the player nothing against your caps. Every offer does start
295
- the first spacing, though: a player who says "No thanks" and presses again within 15 seconds
296
- gets `no_ad` and nothing.
416
+ - **What counts toward a cap** is a grant, after an ad or with none, and an ad that is still
417
+ playing. A grant `without_ads: grant` makes because no ad could play counts the same as any
418
+ other. An ad the player closed early, and a press that paid nothing, do not count, so a skip
419
+ costs the player nothing against your caps. Every press does start the first spacing, though:
420
+ a player who closes the ad early and presses again within 15 seconds gets `no_ad` and nothing.
297
421
  - **The platform may hold a daily cap lower than the one you declared, never higher.** It sets no
298
422
  daily limit of its own unless one is configured, and a configured one applies to every placement
299
423
  from the next ask, with no upload from you.
300
424
  - ⚠️ **Nothing protects your economy by default but your own caps.** A placement with no
301
- `max_per_day` and no `cooldown_seconds` can pay once every 15 seconds — with an ad, or, under
302
- `without_ads: grant`, without one. `max_held: 1` stops a player banking a second token; it does
303
- not stop them spending one and earning the next. Declare the daily cap and cooldown your economy
304
- needs.
425
+ `max_per_day` and no `cooldown_seconds` can pay once every 15 seconds. That holds with an ad,
426
+ and under `without_ads: grant` without one, wherever no ad plays. `max_held: 1` stops a player
427
+ banking a second token; it does not stop them spending one and earning the next. Declare the
428
+ daily cap and cooldown your economy needs.
429
+ - **`without_ads: grant` means the reward is fine to give free, within its caps.** Every press
430
+ that finds no ad pays under it, so the caps are all that limit it. Choose `nothing` for a reward
431
+ that should only ever follow an ad.
305
432
  - **"Once per run" is yours.** The platform has no idea what a run is. `max_held: 1` is the
306
433
  backstop that keeps it hard to abuse: a player can never hold two revives at once.
307
434
 
@@ -316,7 +443,9 @@ platform sets them all. A rewarded reward is your economy, so the caps are yours
316
443
  interstitial at the same break; that ask answers `no_ad`.
317
444
  - **Rewarded has its own spacing:** your placement's `cooldown_seconds`, and the platform's 15
318
445
  seconds (§6). An ask inside any of them answers `no_ad` with nothing granted, so do not re-ask on
319
- a timer. A player who presses again later gets a fresh ask.
446
+ a timer. Never ask on a timer at all: the ad would start with no press. The SDK refuses a timer's
447
+ ask only when no input came in the last few seconds (§3), so do not count on it. A player who
448
+ presses again later gets a fresh ask.
320
449
  - A grant made with no ad holds nothing off, because nothing was shown.
321
450
 
322
451
  ## 8. Where an ad plays, and where it does not
@@ -324,32 +453,41 @@ platform sets them all. A rewarded reward is your economy, so the caps are yours
324
453
  Each channel of your game is in one of these states at a time. Which one is the platform's to set,
325
454
  not yours, and moving between them needs **no rebuild and no config change**:
326
455
 
327
- | state | channels | what pressing the button does |
328
- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
329
- | **Off** | any | Every rewarded ask answers `no_ad`, and nothing is granted. |
330
- | **A — test ads** | `dev` and `test` only | The Arcade paints a sheet marked **Test ad**. Finishing the test ad grants the reward for real, into that channel's inventory only (§10). |
331
- | **B — no ad source** | `release` | No ad plays. Under `without_ads: grant` the reward is granted within your caps, with no ad, and the ask answers `no_ad`. Under `nothing`, nothing is granted. |
332
- | **C — an ad source** | `release` | The Arcade's offer sheet, then an ad; the reward is granted once the ad finishes. `without_ads` does not apply. |
456
+ | state | channels | what pressing the button does |
457
+ | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
458
+ | **Off** | any | Every rewarded ask answers `no_ad`, and nothing is granted. |
459
+ | **A — test ads** | `dev` and `test` only | The Arcade plays a test ad at once, marked **TEST AD**, for the placement's `min_watch_secs`. Watching it to the end grants the reward for real, into that channel's inventory only (§10). Where the test ad has no fill, `without_ads` decides. |
460
+ | **B — no ad source** | `release` | No ad plays. Under `without_ads: grant` the reward is granted within your caps, with no ad, and the ask answers `no_ad`. Under `nothing`, nothing is granted. |
461
+ | **C — an ad source** | `release` | The Arcade plays an ad at once, labelled as an ad, and the reward is granted once the ad finishes. Where no ad is available for the press, or it fails to play, `without_ads` decides. |
333
462
 
334
463
  **On gemarcade.com, every channel is Off today**, `dev` and `test` included. Every rewarded ask
335
- there answers `no_ad` and grants nothing, whatever your placement's `without_ads` says, so a button
336
- you ship pays nothing yet. Test ads (state A) are switched on there first; until then you can ship
337
- the code in §3–§5, which answers `no_ad`, and the day a channel's state changes it starts working
338
- with no rebuild. The platform's internal test environment already runs state A on `dev` and `test`
339
- and state B on `release`, which is where the platform rehearses it before gemarcade.com.
340
- **Test ads never serve on `release`**, and a reward granted on `dev` or `test` never reaches
341
- `release`: inventory is per channel.
464
+ there answers `no_ad` and grants nothing, whatever your placement's `without_ads` says, and
465
+ `rewardedStatus` answers not `grantable`, so a button drawn on it (§5) does not show yet. Test ads
466
+ (state A) are switched on there first; until then you can ship the code in §3–§5, and the day a
467
+ channel's state changes it starts working with no rebuild. The platform's internal test
468
+ environment already runs state A on `dev` and `test` and state B on `release`, which is where the
469
+ platform rehearses it before gemarcade.com. **Test ads never serve on `release`**, and a reward
470
+ granted on `dev` or `test` never reaches `release`: inventory is per channel.
342
471
 
343
472
  What each state means for a game:
344
473
 
345
- - **Build the final shape now.** The code in §5 is the same code in every state. Under `without_ads:
346
- grant`, a game whose reward should be free until ads serve — the same benefit, under the same caps
347
- — gets exactly that in state B, and the same placement shows an ad first in state C, with nothing
348
- to change. Choose `nothing` if a reward should only ever follow an ad.
349
- - **In state C, some players get nothing for a press.** A player the ad source cannot serve gets
350
- `no_ad` and no reward, and `without_ads` does not apply to them. Your game is not told which
351
- players those are. So a placement that is free for everyone in state B is not free for every
352
- player in state C. Write §5's "No revive this time" path as an ordinary path, because it is one.
474
+ - **Build the final shape now.** The code in §5 is the same code in every state. Under
475
+ `without_ads: grant`, a reward that should be free until ads serve is free in state B: the same
476
+ benefit, under the same caps. In state C the same placement plays an ad when one is available.
477
+ It still pays with no ad whenever no ad can play for a press, because no ad was available or the
478
+ ad failed. So in state C too, a `grant` placement's caps are the only limit on it. There is
479
+ nothing to change between states. Choose `nothing` if a reward should only ever follow an ad.
480
+ - **The watch time is the platform's to measure, and the provider's to decide.** For a test ad,
481
+ the platform refuses a completion that comes sooner than the placement's `min_watch_secs`. A
482
+ real ad provider decides for itself when its ad has finished, so for a real ad the value is the
483
+ length the Arcade asks for, and the ad may run longer or shorter.
484
+ - **In state C, some players get nothing for a press.** The platform does not show network ads to
485
+ some players at all: that is a rule about the player, such as where they are or a privacy setting
486
+ their browser sends. Such a player gets `no_ad` and no reward, and `without_ads` does not apply to
487
+ them. This is not the same as a press that found no ad available, which `without_ads` decides
488
+ (the table above). Your game is not told which players those are. So a placement that is free for
489
+ everyone in state B is not free for every player in state C. Write §5's "No revive this time"
490
+ path as an ordinary path, because it is one.
353
491
  - **A visitor with no Arcade account, not even a guest one, is never rewarded**, in any state.
354
492
 
355
493
  ## 9. The placement stays on the platform
@@ -373,42 +511,50 @@ channel's inventory, so your game's re-read finds them and §4's recipe spends t
373
511
 
374
512
  Before you start:
375
513
 
376
- 1. `gemctl config schema` lists `rewarded_placements` (§2).
514
+ 1. `gemctl config schema` lists `rewarded_placements` (§2), and `min_watch_secs` if you set it.
377
515
  2. The config version promoted to the channel declares your placement, and your items and recipe.
378
516
  3. The channel has a build and a store page, so it appears in the Arcade at all.
379
517
 
518
+ **Press the button the way a player does.** An ask with no user activation is refused before it
519
+ leaves your game (§3), so an automated test should press the button through its driver's real
520
+ input, such as Playwright's `click` or `tap`. Do not test the check itself from the console or
521
+ from `page.evaluate`, because both usually count as a press:
522
+
523
+ - Playwright runs every `evaluate` as a user gesture. An ask made straight from one goes through in
524
+ Chromium, Firefox and WebKit.
525
+ - Chrome's DevTools console treats what you type as a user action by default.
526
+
527
+ So an ask from either proves nothing about your button. To see the refusal on purpose, make the
528
+ ask from a timer in your page that fires well after the last input, ten seconds say, with no
529
+ driver call into your game in between.
530
+
531
+ **Test the button on a phone,** or with touch emulation in Chromium or Firefox (Playwright's `tap`
532
+ in a context with `hasTouch`). A `pointerdown` handler passes with a mouse and is refused on a
533
+ touchscreen (§3), so a desk test with a mouse cannot show it.
534
+
380
535
  ### At the Arcade, on `dev` or `test`
381
536
 
382
537
  Play your game on its `dev` or `test` channel. `dev` is open to your team and `test` to your
383
- testers. Pressing the button paints the Arcade's sheet, marked **Test ad**, naming your reward and
384
- saying the reward lands on that channel only. **Watch** opens a test stage with a short countdown,
385
- then **Claim reward**; the reward lands, the sheet says what was received, and the ask answers
386
- `shown` when the player presses **Continue**. **No thanks** answers `shown` with nothing granted,
387
- and so does a sheet nobody answers, which declines itself after a minute.
388
-
389
- The test sheet also has **Simulate no fill**, **Simulate failure** and **Skip (no reward)**, each
390
- answering `shown` with nothing granted. To pick a behaviour before the sheet opens, add
391
- `gem_test_ads=<mode>` to the play page's address:
392
-
393
- | `gem_test_ads=` | what it does |
394
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
395
- | `instant` | after **Watch**, claims the reward when the countdown ends, with no **Claim reward** click; **Continue** still closes the sheet |
396
- | `no_fill` | the sheet reports that no ad was available; **Close** answers `shown`, no reward |
397
- | `fail` | **Watch** fails at once; **Continue** answers `shown`, no reward |
398
- | `skip` | **Watch** starts the ad and skips it: `shown`, no reward |
399
- | `free` | asks as if no ad source served the channel, so your placement's `without_ads` decides: `grant` grants with no sheet and answers `no_ad`; `nothing` gives nothing. This is how you see state B before you ship |
400
-
401
- **Only `free` needs no click.** The default, `instant`, `fail` and `skip` open on the offer sheet
402
- and wait for a person to press **Watch**, so an automated test that cannot press it waits out the
403
- minute, after which the sheet declines itself and the ask answers `shown` with no reward.
404
- `no_fill` has no such timer: its sheet waits for **Close**, so an unattended test gets
405
- `unavailable` when the SDK stops waiting, after three minutes, with the sheet still up.
538
+ testers. Pressing the button starts a test ad at once, marked **TEST AD**, which counts down the
539
+ placement's `min_watch_secs` (5 seconds unless you set it). Watch it to the end and the reward
540
+ lands in that channel's inventory. Close it before then and that is a skip, which pays nothing.
541
+ Your game is paused while it plays, and the ask answers when it closes.
542
+
543
+ To pick a behaviour before you press, add `gem_test_ads=<mode>` to the play page's address:
544
+
545
+ | `gem_test_ads=` | what it does |
546
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
547
+ | `instant` | completes the test ad when its countdown ends, with no click |
548
+ | `no_fill` | the test source has no ad, so your placement's `without_ads` decides: `grant` grants with no ad; `nothing` grants nothing, and the Arcade shows "No ad available right now" |
549
+ | `fail` | the test ad fails to play, which `without_ads` decides exactly as it does for `no_fill` |
550
+ | `skip` | the test ad starts and is skipped: no reward |
551
+ | `free` | asks as if no ad source served the channel, so your placement's `without_ads` decides with no ad at all. This is how you see state B before you ship |
406
552
 
407
553
  `gem_test_ads` is for the Arcade's play page, not your game, and it does nothing on `release`.
408
554
 
409
- On `dev` and `test`, when an ask paints no sheet for a reason that is about your config or your
410
- caps, the Arcade writes **one console line** saying which. It is never shown to a player, and never
411
- written on `release`:
555
+ On `dev` and `test`, when a press pays nothing, or pays with no ad, for a reason that is about your
556
+ config or your caps, the Arcade writes **one console line** saying which. It is never shown to a
557
+ player, and never written on `release`:
412
558
 
413
559
  ```text
414
560
  [gem] rewarded 'clasic_revive' on dev: placement not declared in this channel's config; nothing granted
@@ -422,8 +568,14 @@ the ask came inside the 15-second spacing, the allowance is spent, the reward wa
422
568
  ad, or rewarded is off on the channel. Your game sees none of them, and a no that is about the
423
569
  player is never written there.
424
570
 
425
- **No sheet and no console line at all** means the no was about something those lines never cover:
571
+ An ask made without the player's press (§3) never reaches the Arcade, so the Arcade writes nothing
572
+ for it. The SDK writes its own line instead, once a session, beginning
573
+ `[gem] rewarded_requires_user_gesture`, and your `onError` handler gets the same message.
574
+
575
+ **No ad and no console line at all** means the no was about something those lines never cover:
426
576
 
577
+ - an ask made without the player's press, under `log: null`, which silences the SDK's line; your
578
+ `onError` handler still has it;
427
579
  - rewarded is not switched on in the Arcade you are playing in at all (where it is on but off for
428
580
  your channel, the line says so);
429
581
  - your game asked while its page was hidden, or while another Arcade panel was over it;
@@ -439,12 +591,12 @@ player is never written there.
439
591
  game's **`dev`** channel, as **your own account**. A claimed reward is real: it lands in your own
440
592
  `dev` inventory for that game, so your re-read and §4's recipe behave as they will at the Arcade.
441
593
 
442
- - **Pick the behaviour on the framing page's Start screen**: the default sheet, then **Watch**, a
443
- 2-second test stage, then **Claim reward**; `instant`, which pays with no clicks (the one an
444
- automated test wants); `decline`; `abandon`; `no_fill`; `error`, which answers `unavailable`
445
- with nothing asked; and `free`, which rehearses `without_ads` with no sheet (§8). You can also add
446
- `?rewarded=<mode>` to the framing page's own address, but not to the link `gemctl serve` prints,
447
- because its redirect drops the query.
594
+ - **Pick the behaviour on the framing page's Start screen.** The default plays the test ad for
595
+ the placement's watch time. `instant` pays with no clicks, which is the one an automated test
596
+ wants. `abandon` starts the ad and closes it before the end: a skip, so nothing is paid. `no_fill` has no ad, so `without_ads` decides. `error` answers `unavailable` with nothing
597
+ asked. `free` rehearses `without_ads` with no ad (§8). You can also add `?rewarded=<mode>` to
598
+ the framing page's own address, but not to the link `gemctl serve` prints, because its redirect
599
+ drops the query.
448
600
  - **Each ask writes one line** saying what happened, and why when nothing arrived, to
449
601
  `gemctl serve log`, the Editor's Log page and the page's console.
450
602
  - **Signed out, a game not linked to your project, the Editor's game window, or another device on
@@ -459,6 +611,6 @@ game's **`dev`** channel, as **your own account**. A claimed reward is real: it
459
611
 
460
612
  ## If you do nothing
461
613
 
462
- Nothing happens. A game that never asks for `rewarded` is never shown a sheet, and nothing here is
463
- on the list of things a game must do to ship. A rewarded ask for a placement your config does not
614
+ Nothing happens. A game that never asks for `rewarded` never starts an ad, and nothing here is on
615
+ the list of things a game must do to ship. A rewarded ask for a placement your config does not
464
616
  declare answers `no_ad` and grants nothing.