@indiegems/gem-web-sdk 8.1.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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -0
- package/dist/function.d.ts +1 -1
- package/dist/game.d.ts +1 -1
- package/dist/gem-web-sdk.esm.js +1 -1
- package/dist/gem-web-sdk.function.js +1 -1
- package/dist/gem-web-sdk.game.js +2 -2
- package/dist/gem-web-sdk.min.js +2 -2
- package/dist/gem-web-sdk.sdk.js +2 -2
- package/dist/gem-web-sdk.server.js +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/server.d.ts +1 -1
- package/docs/api/api-reference-game.md +69 -24
- package/docs/api/api-reference-server.md +6 -2
- package/docs/api/reference.json +20 -20
- package/docs/changelog.md +14 -0
- package/docs/cross-promotion.md +103 -44
- package/docs/getting-started.md +1 -1
- package/docs/manifest.json +23 -18
- package/docs/match-session.md +1 -1
- package/docs/rewarded-ads.md +616 -0
- package/docs/room-shapes.md +54 -76
- package/docs/rooms-and-matches.md +2 -3
- package/docs/server-bundles.md +2 -2
- package/docs/serverless-functions.md +42 -32
- package/docs/what-a-game-must-do.md +14 -3
- package/package.json +1 -1
- package/schema/bridge.v1.json +42 -6
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
# Rewarded ads
|
|
2
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
```js
|
|
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
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That is the whole of the asking. What makes it pay is four things you set up once:
|
|
21
|
+
|
|
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).
|
|
27
|
+
|
|
28
|
+
The same asking and spending code runs whether the Arcade plays a test ad, grants the reward with no
|
|
29
|
+
ad, or plays a real one (§8), and moving between those needs no new SDK version and no config
|
|
30
|
+
change.
|
|
31
|
+
|
|
32
|
+
## 1. The reward is your own, and the player keeps it
|
|
33
|
+
|
|
34
|
+
A reward is one to four grants of **your game's own** items or currencies, up to 100 of each (a
|
|
35
|
+
platform currency only as below). It must be something the player keeps and cannot pass on, and
|
|
36
|
+
the upload refuses a config where it is not:
|
|
37
|
+
|
|
38
|
+
- **Soulbound.** Every item or currency it grants has `transfer_policy: none`. A reward cannot be
|
|
39
|
+
traded, gifted or moved.
|
|
40
|
+
- **An ordinary item or a currency.** Not a bundle, not a loot box, and not a progression.
|
|
41
|
+
- **A platform currency only where the platform lists it.** The upload refuses a reward that
|
|
42
|
+
names a platform currency unless `gemctl platform config` lists that currency, for your game, as
|
|
43
|
+
grantable by `rewarded`.
|
|
44
|
+
- **Never convertible into one of those.** The upload follows every recipe and catalog entry that
|
|
45
|
+
spends the reward, then everything that spends what those grant, and so on. If that chain reaches
|
|
46
|
+
anything transferable, a platform currency `gemctl platform config` does not list as grantable by
|
|
47
|
+
`rewarded`, a bundle, a loot box, or a progression that fires a platform grant, the upload is
|
|
48
|
+
refused, and the finding names the path from the reward to the thing it reached.
|
|
49
|
+
- **Never spent on a loot draw.** Neither the reward nor anything it converts into may be a cost
|
|
50
|
+
of a recipe that rolls a loot table, so "spend a token to spin the wheel" is refused too.
|
|
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.
|
|
53
|
+
|
|
54
|
+
And three things the upload warns about rather than refuses:
|
|
55
|
+
|
|
56
|
+
- **Keep `client_writable: false` on the reward.** If your game's client may write it, a modified
|
|
57
|
+
client can grant itself one without an ad, and the placement is decoration.
|
|
58
|
+
- **Prefer a reward that is not also sold for a platform currency.** A reward counts as sold for
|
|
59
|
+
one when it carries a platform price itself, when a recipe priced in a platform currency grants
|
|
60
|
+
it, or when anything it converts into (the chain above) is sold either way. Such a placement is
|
|
61
|
+
allowed, with a warning, but it must say `without_ads: nothing` (§2) — the upload refuses one
|
|
62
|
+
that says `grant` — and outside test ads it is only ever served behind an ad whose completion
|
|
63
|
+
the platform verifies with the ad provider.
|
|
64
|
+
- **Keep its `display_name` to 40 characters or fewer.** The Arcade cuts a longer one short where
|
|
65
|
+
it shows it.
|
|
66
|
+
|
|
67
|
+
For a revive, the reward is a single-use token:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
items:
|
|
71
|
+
- id: '<uuid>'
|
|
72
|
+
slug: revive_token
|
|
73
|
+
item_type: item
|
|
74
|
+
display_name: 'Revive token' # the reward's name in the Arcade's own chrome
|
|
75
|
+
fungible: true
|
|
76
|
+
stack_size: 1
|
|
77
|
+
consumable: true
|
|
78
|
+
transfer_policy: none # a reward is never transferable
|
|
79
|
+
client_writable: false # or a modified client can grant itself one
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`gemctl config schema` is the authoritative field list for all of this, and for §2 and §4.
|
|
83
|
+
|
|
84
|
+
## 2. Declare the placement in your game config
|
|
85
|
+
|
|
86
|
+
Each placement your game asks for is one entry in the `rewarded_placements` section, keyed by the
|
|
87
|
+
same string your game passes as `placement`:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
rewarded_placements:
|
|
91
|
+
- placement: classic_revive # exactly the string your game passes
|
|
92
|
+
grants:
|
|
93
|
+
- { type: item_grant, item_definition_slug: revive_token, quantity: 1 }
|
|
94
|
+
max_per_day: 5 # optional: leave it out for no daily cap
|
|
95
|
+
max_held: 1 # no grant while the player already holds one
|
|
96
|
+
without_ads: grant # required: what a press gets when no ad plays
|
|
97
|
+
- placement: daily_drop_extra
|
|
98
|
+
grants:
|
|
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
|
|
101
|
+
max_per_day: 2
|
|
102
|
+
max_held: 1
|
|
103
|
+
without_ads: grant
|
|
104
|
+
```
|
|
105
|
+
|
|
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. |
|
|
116
|
+
|
|
117
|
+
The placement only **selects** a reward your own config declared. Your game sends no reward, no
|
|
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.
|
|
120
|
+
|
|
121
|
+
Ship it the way every config section ships: upload, then promote to a channel. A channel serves
|
|
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.
|
|
124
|
+
|
|
125
|
+
⚠️ **Check that `gemctl config schema` lists `rewarded_placements` before you rely on it.** The
|
|
126
|
+
upload drops a top-level section the backend does not know without a word, so a placement uploaded
|
|
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.
|
|
129
|
+
|
|
130
|
+
### More for watching longer: two placements
|
|
131
|
+
|
|
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
|
|
149
|
+
```
|
|
150
|
+
|
|
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.
|
|
154
|
+
|
|
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.
|
|
158
|
+
|
|
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.
|
|
223
|
+
|
|
224
|
+
**A reward can also arrive after that re-read.** Some ad providers confirm a finished ad from their
|
|
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).
|
|
228
|
+
[`inventory-and-catalogs.md` §6](inventory-and-catalogs.md#6-when-to-re-read) is the general rule.
|
|
229
|
+
|
|
230
|
+
A granted reward shows in your order history like any other change, as an order the platform made
|
|
231
|
+
with `source` `reward`.
|
|
232
|
+
|
|
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.
|
|
237
|
+
|
|
238
|
+
## 4. Spend it with a recipe
|
|
239
|
+
|
|
240
|
+
Using the reward — the revive itself — is a server call: a recipe that removes the token. There is
|
|
241
|
+
no `consume` recipe type; a consume is an `exchange` whose `costs` remove the item. In one of your
|
|
242
|
+
catalogs:
|
|
243
|
+
|
|
244
|
+
```yaml
|
|
245
|
+
- id: '<uuid>'
|
|
246
|
+
slug: use_revive
|
|
247
|
+
recipe_type: exchange
|
|
248
|
+
client_writable: true # the RECIPE is client-executable; the ITEM stays client_writable: false
|
|
249
|
+
costs:
|
|
250
|
+
- { type: item_remove, item_definition_slug: revive_token, quantity: 1 }
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
await gem.me.mutations.executeRecipe(ACTIONS_CATALOG_ID, USE_REVIVE_RECIPE_ID);
|
|
255
|
+
revive(); // only after the recipe resolves
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The server checks that the player holds a token and removes it in the same step, which a modified
|
|
259
|
+
client cannot fake. **Apply the revive only after the recipe resolves.**
|
|
260
|
+
|
|
261
|
+
**If it fails, do not send it again; re-read the inventory.** A timeout or a dropped connection
|
|
262
|
+
proves nothing — the spend may have landed — so let the inventory decide, as it does for the
|
|
263
|
+
reward itself: a token that was there before the call and is gone now was spent, and one that is
|
|
264
|
+
still there was not. [`inventory-and-catalogs.md`
|
|
265
|
+
§11](inventory-and-catalogs.md#11-conditional-writes-and-why-most-writes-do-not-want-them) is the
|
|
266
|
+
general rule for a write that timed out.
|
|
267
|
+
|
|
268
|
+
Why not remove the item from the client with a mutation? Because that needs the item to be
|
|
269
|
+
`client_writable`, and then a modified client can grant it too (§1).
|
|
270
|
+
|
|
271
|
+
**If the reward feeds anything competitive, check the spend on your server.** An extra attempt at a
|
|
272
|
+
daily puzzle that posts to a leaderboard is only as honest as whatever accepts the result: the
|
|
273
|
+
server or function that does should confirm each attempt beyond the free one spent a token. A
|
|
274
|
+
client-side check is a check a modified client skips.
|
|
275
|
+
|
|
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.
|
|
281
|
+
|
|
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.
|
|
291
|
+
|
|
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.
|
|
293
|
+
|
|
294
|
+
Your own rules decide when the button appears at all — once per run, only on the death screen,
|
|
295
|
+
never in a ranked mode — because the platform cannot see a run (§6).
|
|
296
|
+
|
|
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):
|
|
305
|
+
|
|
306
|
+
```js
|
|
307
|
+
const status = await gem.ads.rewardedStatus('classic_revive');
|
|
308
|
+
watchAdButton.hidden = !status.grantable;
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
For a revive, the screen reads what it needs **when it opens**, so the press itself can ask at
|
|
312
|
+
once:
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
async function reviveTokens() {
|
|
316
|
+
gem.me.inventory.refresh(); // drop what is cached
|
|
317
|
+
try {
|
|
318
|
+
// Every revive_token the player holds, read by the slug from your config.
|
|
319
|
+
const held = await gem.me.inventory.instances('revive_token');
|
|
320
|
+
return held.reduce((n, item) => n + item.quantity, 0);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// A player who holds none of an item is answered `not_found`, not an empty
|
|
323
|
+
// list. That is a count of zero, and it is the usual case: the first press,
|
|
324
|
+
// and the re-read after a spend that took the last token.
|
|
325
|
+
if (err?.reason === 'not_found') return 0;
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function spendRevive(before) {
|
|
331
|
+
try {
|
|
332
|
+
await gem.me.mutations.executeRecipe(ACTIONS_CATALOG_ID, USE_REVIVE_RECIPE_ID);
|
|
333
|
+
return true;
|
|
334
|
+
} catch {
|
|
335
|
+
// Refused, or no answer. A spend that timed out may still have landed, so never
|
|
336
|
+
// send it again: the inventory decides (§4).
|
|
337
|
+
return (await reviveTokens()) < before;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
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;
|
|
345
|
+
if (run.revived) return; // the game's own rule: one revive per run
|
|
346
|
+
try {
|
|
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;
|
|
352
|
+
}
|
|
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
|
|
383
|
+
revived = held > 0 && (await spendRevive(held));
|
|
384
|
+
} catch {
|
|
385
|
+
// A read failed. That is no revive this time.
|
|
386
|
+
}
|
|
387
|
+
if (revived) {
|
|
388
|
+
run.revived = true;
|
|
389
|
+
revive(); // only once the spend is known to have landed
|
|
390
|
+
} else {
|
|
391
|
+
showNoRevive(); // "No revive this time": neutral, and no reason
|
|
392
|
+
}
|
|
393
|
+
await drawReviveButtons(); // redraw from the new state
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## 6. Caps live on the placement
|
|
398
|
+
|
|
399
|
+
The caps are **yours to declare and the platform's to enforce.** The reward is your game's own
|
|
400
|
+
economy, so you choose its limits, and the platform holds them on its side, where a modified client
|
|
401
|
+
cannot reach them. They apply the same with an ad and without one.
|
|
402
|
+
|
|
403
|
+
| limit | declared by | enforced by |
|
|
404
|
+
| ---------------------------------------------------------------------------------------- | --------------------- | ------------- |
|
|
405
|
+
| `max_per_day` — grants per player per UTC day | you, optionally | the platform |
|
|
406
|
+
| `cooldown_seconds` — the time between two grants | you | the platform |
|
|
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 |
|
|
409
|
+
| at least 15 seconds between two rewarded offers to one player in one game | the platform | the platform |
|
|
410
|
+
| at least 15 seconds between two rewards granted with no ad, in any of the player's games | the platform | the platform |
|
|
411
|
+
| once per run, only on the death screen, never in ranked | you | **your game** |
|
|
412
|
+
|
|
413
|
+
- **The second spacing reaches across games.** If another game granted this player a reward with
|
|
414
|
+
no ad a few seconds ago, an ask in yours that would also grant with no ad (§8) answers `no_ad`
|
|
415
|
+
with nothing granted, even though your game has offered 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.
|
|
421
|
+
- **The platform may hold a daily cap lower than the one you declared, never higher.** It sets no
|
|
422
|
+
daily limit of its own unless one is configured, and a configured one applies to every placement
|
|
423
|
+
from the next ask, with no upload from you.
|
|
424
|
+
- ⚠️ **Nothing protects your economy by default but your own caps.** A placement with no
|
|
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.
|
|
432
|
+
- **"Once per run" is yours.** The platform has no idea what a run is. `max_held: 1` is the
|
|
433
|
+
backstop that keeps it hard to abuse: a player can never hold two revives at once.
|
|
434
|
+
|
|
435
|
+
This is the opposite of the rule for platform currencies, where a game declares no caps and the
|
|
436
|
+
platform sets them all. A rewarded reward is your economy, so the caps are yours.
|
|
437
|
+
|
|
438
|
+
## 7. Pacing
|
|
439
|
+
|
|
440
|
+
- **A rewarded ask is never held back by an interstitial.** A player who presses your button is not
|
|
441
|
+
refused because a promotion played a minute ago, in your game or another.
|
|
442
|
+
- **An ad that starts holds off the next interstitial.** Do not follow a rewarded ad with an
|
|
443
|
+
interstitial at the same break; that ask answers `no_ad`.
|
|
444
|
+
- **Rewarded has its own spacing:** your placement's `cooldown_seconds`, and the platform's 15
|
|
445
|
+
seconds (§6). An ask inside any of them answers `no_ad` with nothing granted, so do not re-ask on
|
|
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.
|
|
449
|
+
- A grant made with no ad holds nothing off, because nothing was shown.
|
|
450
|
+
|
|
451
|
+
## 8. Where an ad plays, and where it does not
|
|
452
|
+
|
|
453
|
+
Each channel of your game is in one of these states at a time. Which one is the platform's to set,
|
|
454
|
+
not yours, and moving between them needs **no rebuild and no config change**:
|
|
455
|
+
|
|
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. |
|
|
462
|
+
|
|
463
|
+
**On gemarcade.com, every channel is Off today**, `dev` and `test` included. Every rewarded ask
|
|
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.
|
|
471
|
+
|
|
472
|
+
What each state means for a game:
|
|
473
|
+
|
|
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.
|
|
491
|
+
- **A visitor with no Arcade account, not even a guest one, is never rewarded**, in any state.
|
|
492
|
+
|
|
493
|
+
## 9. The placement stays on the platform
|
|
494
|
+
|
|
495
|
+
Your placement name goes to the platform, which matches it against your config, and **never to an
|
|
496
|
+
ad network** — and neither does any other string your game writes. So:
|
|
497
|
+
|
|
498
|
+
- **Name a placement for the reward, for your own reading** — `classic_revive`, `daily_drop_extra`.
|
|
499
|
+
There is nothing to provision and nothing to register with a network, so a placement you declare
|
|
500
|
+
later serves like any other.
|
|
501
|
+
- **Do not put an identifier in it**, and do not expect to see it on any ad report. Per-placement
|
|
502
|
+
numbers are the platform's, not a network's.
|
|
503
|
+
- **Never embed an ad SDK or ad tag in your game.** The Arcade plays every ad itself; your game
|
|
504
|
+
asks, and reads its inventory.
|
|
505
|
+
|
|
506
|
+
## 10. Testing it
|
|
507
|
+
|
|
508
|
+
Where your `dev` and `test` channels are in state A (§8), rewarded is testable end to end against
|
|
509
|
+
the **test ad** source, before any real ad serves. Test grants are real: they land in that
|
|
510
|
+
channel's inventory, so your game's re-read finds them and §4's recipe spends them.
|
|
511
|
+
|
|
512
|
+
Before you start:
|
|
513
|
+
|
|
514
|
+
1. `gemctl config schema` lists `rewarded_placements` (§2), and `min_watch_secs` if you set it.
|
|
515
|
+
2. The config version promoted to the channel declares your placement, and your items and recipe.
|
|
516
|
+
3. The channel has a build and a store page, so it appears in the Arcade at all.
|
|
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
|
+
|
|
535
|
+
### At the Arcade, on `dev` or `test`
|
|
536
|
+
|
|
537
|
+
Play your game on its `dev` or `test` channel. `dev` is open to your team and `test` to your
|
|
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 |
|
|
552
|
+
|
|
553
|
+
`gem_test_ads` is for the Arcade's play page, not your game, and it does nothing on `release`.
|
|
554
|
+
|
|
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`:
|
|
558
|
+
|
|
559
|
+
```text
|
|
560
|
+
[gem] rewarded 'clasic_revive' on dev: placement not declared in this channel's config; nothing granted
|
|
561
|
+
[gem] rewarded 'classic_revive' on dev: the player already holds the placement's max_held; nothing granted
|
|
562
|
+
[gem] rewarded 'classic_revive' on dev: granted without an ad (no ad source on this channel)
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The lines it can write are config and cap facts only: the placement is not declared, no config is
|
|
566
|
+
promoted to the channel, the daily cap is reached, the cooldown is running, `max_held` is reached,
|
|
567
|
+
the ask came inside the 15-second spacing, the allowance is spent, the reward was granted with no
|
|
568
|
+
ad, or rewarded is off on the channel. Your game sees none of them, and a no that is about the
|
|
569
|
+
player is never written there.
|
|
570
|
+
|
|
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:
|
|
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;
|
|
579
|
+
- rewarded is not switched on in the Arcade you are playing in at all (where it is on but off for
|
|
580
|
+
your channel, the line says so);
|
|
581
|
+
- your game asked while its page was hidden, or while another Arcade panel was over it;
|
|
582
|
+
- the reward would not fit under a limit on the item or currency itself — a currency's maximum
|
|
583
|
+
balance, say. Only `max_held` is reported, so declare it rather than lean on the item's own
|
|
584
|
+
limit;
|
|
585
|
+
- under `gem_test_ads=free`, a placement that says `without_ads: nothing`;
|
|
586
|
+
- something about the player or their session, which is never written.
|
|
587
|
+
|
|
588
|
+
### In the dev host
|
|
589
|
+
|
|
590
|
+
`gemctl serve` and the Gem Editor play a rewarded ask through the same test-ad source, for your
|
|
591
|
+
game's **`dev`** channel, as **your own account**. A claimed reward is real: it lands in your own
|
|
592
|
+
`dev` inventory for that game, so your re-read and §4's recipe behave as they will at the Arcade.
|
|
593
|
+
|
|
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.
|
|
600
|
+
- **Each ask writes one line** saying what happened, and why when nothing arrived, to
|
|
601
|
+
`gemctl serve log`, the Editor's Log page and the page's console.
|
|
602
|
+
- **Signed out, a game not linked to your project, the Editor's game window, or another device on
|
|
603
|
+
the network** get a panel marked **Simulated**, and nothing is granted.
|
|
604
|
+
- **It works where your game's `dev` channel serves test ads (state A).** The dev host talks to
|
|
605
|
+
gemarcade.com, so until test ads are switched on there, every offer answers that rewarded ads
|
|
606
|
+
are not served yet.
|
|
607
|
+
- **To test a cap again today**, clear your own rewarded use for the day:
|
|
608
|
+
`gemctl rewarded reset --game <game id> --channel dev` (or `test`, and `--placement P` for one
|
|
609
|
+
placement). It is refused on `release`, it touches only your own account, and it leaves your
|
|
610
|
+
inventory alone, so a `max_held` that is reached stays reached until you spend the reward.
|
|
611
|
+
|
|
612
|
+
## If you do nothing
|
|
613
|
+
|
|
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
|
|
616
|
+
declare answers `no_ad` and grants nothing.
|