@indiegems/gem-web-sdk 13.1.0 → 13.2.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.
@@ -242,6 +242,16 @@
242
242
  "gates": [
243
243
  "gem.gemCorner"
244
244
  ]
245
+ },
246
+ "moments": {
247
+ "implemented": true,
248
+ "gates": [
249
+ "gem.signalMoment",
250
+ "gem.momentResult",
251
+ {
252
+ "namespace": "gem.moments"
253
+ }
254
+ ]
245
255
  }
246
256
  }
247
257
  }
@@ -0,0 +1,109 @@
1
+ {
2
+ "$comment": [
3
+ "The one structured log line a development host writes for each platform",
4
+ "moment: `gem.signalMoment` from the game, and the deprecated",
5
+ "`gem.requestAd` with `format: 'interstitial'` as `kind=legacy`. Every dev",
6
+ "host writes it exactly as below, and asserts the `cases` here, so that a",
7
+ "release checklist reading the log of one host reads every host's.",
8
+ "",
9
+ "WRITTEN ONCE PER MOMENT, WHEN THE HOST ANSWERS IT. Fields in this order,",
10
+ "separated by one space, `name=value`, nothing before `moment` and nothing",
11
+ "after `answer`. A value that is absent is written `-`.",
12
+ "",
13
+ " kind the moment's kind, or `legacy` for an interstitial ask.",
14
+ " placement the game's own name for the screen, when it gave one",
15
+ " that fits `^[a-z0-9_-]{1,32}$`; `-` when it gave none,",
16
+ " and `?` when it gave one outside that grammar. The value",
17
+ " is untrusted text: a host never prints one that fails",
18
+ " the grammar, and never forwards one anywhere.",
19
+ " since_ready_ms whole milliseconds from the frame's `gem.ready` to this",
20
+ " moment's arrival; `-` when `gem.ready` has not arrived.",
21
+ " since_prev_ms whole milliseconds from the previous moment's arrival in",
22
+ " this session to this one's; `-` for the first.",
23
+ " answer `shown` when the host painted something, `not_shown`",
24
+ " otherwise.",
25
+ "",
26
+ "A CONTRACT, NOT A WIRE MESSAGE. It never crosses the bridge and no game",
27
+ "reads it. Adding a field is a new version of this file; a host that",
28
+ "writes this version writes exactly these five."
29
+ ],
30
+ "version": 1,
31
+ "prefix": "moment",
32
+ "fields": [
33
+ {
34
+ "name": "kind",
35
+ "values": ["level_end", "run_end", "match_end", "menu_return", "other_break", "legacy"]
36
+ },
37
+ { "name": "placement", "pattern": "^[a-z0-9_-]{1,32}$", "absent": "-", "outsideGrammar": "?" },
38
+ { "name": "since_ready_ms", "pattern": "^(0|[1-9][0-9]*)$", "absent": "-" },
39
+ { "name": "since_prev_ms", "pattern": "^(0|[1-9][0-9]*)$", "absent": "-" },
40
+ { "name": "answer", "values": ["shown", "not_shown"] }
41
+ ],
42
+ "pattern": "^moment kind=(level_end|run_end|match_end|menu_return|other_break|legacy) placement=([a-z0-9_-]{1,32}|-|\\?) since_ready_ms=(0|[1-9][0-9]*|-) since_prev_ms=(0|[1-9][0-9]*|-) answer=(shown|not_shown)$",
43
+ "cases": [
44
+ {
45
+ "$comment": "The ordinary moment: a level ended, a placement given, nothing shown.",
46
+ "moment": {
47
+ "kind": "level_end",
48
+ "placement": "world-1-3",
49
+ "sinceReadyMs": 84210,
50
+ "sincePrevMs": 61005,
51
+ "shown": false
52
+ },
53
+ "line": "moment kind=level_end placement=world-1-3 since_ready_ms=84210 since_prev_ms=61005 answer=not_shown"
54
+ },
55
+ {
56
+ "$comment": "The first moment of a session, with no placement, and a panel shown.",
57
+ "moment": { "kind": "run_end", "sinceReadyMs": 152000, "shown": true },
58
+ "line": "moment kind=run_end placement=- since_ready_ms=152000 since_prev_ms=- answer=shown"
59
+ },
60
+ {
61
+ "$comment": "A return to the menu.",
62
+ "moment": {
63
+ "kind": "menu_return",
64
+ "placement": "pause_quit",
65
+ "sinceReadyMs": 301442,
66
+ "sincePrevMs": 90010,
67
+ "shown": false
68
+ },
69
+ "line": "moment kind=menu_return placement=pause_quit since_ready_ms=301442 since_prev_ms=90010 answer=not_shown"
70
+ },
71
+ {
72
+ "$comment": "A placement outside the grammar, from a frame that posted on the port directly: never printed.",
73
+ "moment": {
74
+ "kind": "match_end",
75
+ "placement": "Round 3\nanswer=shown",
76
+ "sinceReadyMs": 600000,
77
+ "sincePrevMs": 240000,
78
+ "shown": false
79
+ },
80
+ "line": "moment kind=match_end placement=? since_ready_ms=600000 since_prev_ms=240000 answer=not_shown"
81
+ },
82
+ {
83
+ "$comment": "A moment before `gem.ready`, which the SDK never sends but a frame on the port can.",
84
+ "moment": { "kind": "other_break", "shown": false },
85
+ "line": "moment kind=other_break placement=- since_ready_ms=- since_prev_ms=- answer=not_shown"
86
+ },
87
+ {
88
+ "$comment": "The deprecated interstitial ask, logged on the same line so a checklist sees it.",
89
+ "moment": {
90
+ "kind": "legacy",
91
+ "placement": "level-complete",
92
+ "sinceReadyMs": 45000,
93
+ "sincePrevMs": 0,
94
+ "shown": false
95
+ },
96
+ "line": "moment kind=legacy placement=level-complete since_ready_ms=45000 since_prev_ms=0 answer=not_shown"
97
+ }
98
+ ],
99
+ "notLines": [
100
+ "moment kind=boss_end placement=- since_ready_ms=1 since_prev_ms=- answer=not_shown",
101
+ "moment kind=level_end placement=World-1 since_ready_ms=1 since_prev_ms=- answer=not_shown",
102
+ "moment kind=level_end placement=- since_ready_ms=-5 since_prev_ms=- answer=not_shown",
103
+ "moment kind=level_end placement=- since_ready_ms=1.5 since_prev_ms=- answer=not_shown",
104
+ "moment kind=level_end placement=- since_ready_ms=1 since_prev_ms=- answer=true",
105
+ "moment kind=level_end since_ready_ms=1 placement=- since_prev_ms=- answer=not_shown",
106
+ "moment kind=level_end placement=- since_ready_ms=1 since_prev_ms=- answer=not_shown ",
107
+ "[gem] moment kind=level_end placement=- since_ready_ms=1 since_prev_ms=- answer=not_shown"
108
+ ]
109
+ }
@@ -1,232 +0,0 @@
1
- # Cross-promotion
2
-
3
- The Arcade can show a **full-screen panel about another game** at a break in yours. Your game asks
4
- for one; the Arcade decides whether to show anything, draws it in its own chrome over your paused
5
- game, and tells you when it is done.
6
-
7
- The same call with `format: 'rewarded'` is a button the player presses for something your game
8
- gives — a revive, an extra attempt. That one has its own page,
9
- [`rewarded-ads.md`](rewarded-ads.md). This page is about the interstitial; where a rule here reads
10
- differently for a rewarded ask, it says so.
11
-
12
- ```js
13
- const { outcome } = await gem.ads.requestAd({
14
- placement: 'level-complete',
15
- format: 'interstitial',
16
- });
17
- // Nothing to handle. Carry on.
18
- startNextLevel();
19
- ```
20
-
21
- That is the whole surface, apart from one read that tells a rewarded button whether it could pay
22
- (§3). The rest of this page is the four things a game gets wrong when nobody tells it.
23
-
24
- ## 1. The answer is usually no, and no is ordinary
25
-
26
- `outcome` is one of exactly three values:
27
-
28
- | outcome | what happened | what your game does |
29
- | ------------- | -------------------------------------- | ---------------------------------------- |
30
- | `no_ad` | nothing was shown | carry on exactly as if you had not asked |
31
- | `shown` | a panel was painted and has closed | carry on — and grant nothing |
32
- | `unavailable` | the Arcade could not draw one just now | carry on; read it identically to `no_ad` |
33
-
34
- **`no_ad` is the common answer**, not an edge case. There may be nothing suitable, the player may
35
- be somebody the Arcade will not interrupt, or the break may simply be too soon after the last one.
36
- You are not told which, and there is nothing to infer.
37
-
38
- So write the `no_ad` path as your ordinary path:
39
-
40
- - Do **not** render a placeholder, a spinner, or a "nothing to show right now" message. Empty means
41
- nothing appears.
42
- - Do **not** hold up your own flow waiting for a fill. Ask, and continue when the promise settles.
43
- - Do **not** retry on a no. A second ask at the same break gets the same answer and spends a slot.
44
-
45
- **A rewarded ask reads differently, because the player pressed your button and is waiting on it.**
46
- Its `no_ad` can arrive with the reward already granted (§3), so branch on your inventory, never on
47
- the outcome; and where nothing landed, a neutral line in your own UI — "No revive this time" — is
48
- right, with no reason given. (When no ad was available and nothing was granted, the Arcade has
49
- already shown "No ad available right now" itself.) Draw the button only when
50
- `gem.ads.rewardedStatus` says it can pay (§3); [`rewarded-ads.md`
51
- §5](rewarded-ads.md#5-draw-the-button-only-when-it-can-pay) is that button.
52
-
53
- ## 2. Nothing rejects
54
-
55
- `requestAd` always resolves. There is no error branch and no `try`/`catch` to write. Even a refusal
56
- of your own arguments — a `placement` outside the grammar below, a `format` outside the two —
57
- resolves `no_ad`, and so does a rewarded ask made without the player's press (see
58
- [Where to put the call](#where-to-put-the-call)), so your game keeps exactly one branch. (The
59
- mistake is still reported to your `onError` handler, so you find out in development.)
60
-
61
- ## 3. ⚠️ `shown` grants nothing
62
-
63
- `shown` says a panel was painted and has closed. It is **not** a receipt, **not** an entitlement,
64
- and **not** permission to hand anything out.
65
-
66
- That holds for `format: 'rewarded'` too, the one format that pays the player. The platform grants a
67
- rewarded placement's reward itself, against its own record of the ad the Arcade played and how it
68
- ended — never your game, by reading an outcome. An outcome travels over the same channel as
69
- everything else your frame receives, and a game that unlocks a level or grants a currency on the
70
- strength of one has an unlock that any page embedding your game can forge.
71
-
72
- Treat a break as a break. If your game wants to give the player something for reaching the end of a
73
- level, give it to them for reaching the end of the level.
74
-
75
- **Where a rewarded grant arrives.** On your game's own inventory for this player, written by the
76
- platform — never by your game — and read through `gem.me.inventory`, the same place a purchase
77
- lands, read the same way. (That is the game-scoped inventory `gem.me` shows you, not the player's
78
- platform wallet.) There is **no fourth outcome and no extra field** on the answer. `outcome` is
79
- pinned at three values because a new value would break every game bundle already in the field: its
80
- validator refuses the whole message. A new field would not break anything — validators ignore
81
- fields they do not know — but it is not coming either: anything on that answer can be forged by the
82
- page embedding your game, so a reward carried there would be a reward anyone could hand out. So
83
- re-read your snapshot once a rewarded ask settles, whatever it settled as:
84
-
85
- ```js
86
- await gem.ads.requestAd({ placement: 'run-banked', format: 'rewarded' });
87
- gem.me.inventory.refresh(); // drop what is cached
88
- const snap = await gem.me.inventory.snapshot(); // a reward granted before the answer is here
89
- ```
90
-
91
- A `no_ad` can come back with the reward already in that snapshot, because the platform grants
92
- without an ad in some cases, so branch on the inventory and never on the outcome. And a reward that
93
- an ad provider confirms from its own server can land after this re-read, so read again when the
94
- screen that spends it opens; [`inventory-and-catalogs.md`
95
- §6](inventory-and-catalogs.md#6-when-to-re-read) is the general form of this.
96
- [`rewarded-ads.md`](rewarded-ads.md) is the rest: declaring what a placement grants, spending it,
97
- drawing the button, and testing.
98
-
99
- **Whether a rewarded button could pay right now.** A rewarded placement is a button the player
100
- presses, so you need to know whether to draw it. `gem.ads.rewardedStatus(placement)` reads one
101
- placement's state from **your own** `rewarded_placements` config — its daily cap, cooldown and
102
- `max_held` — applied to this player's use of it:
103
-
104
- ```js
105
- const status = await gem.ads.rewardedStatus('run-banked');
106
- bankButton.disabled = !status.grantable;
107
- ```
108
-
109
- | field | what it says |
110
- | ------------------ | -------------------------------------------------------------------------- |
111
- | `declared` | the config promoted to the channel being played names this placement |
112
- | `grantable` | pressing the button could pay right now — **not** that an ad will play |
113
- | `remainingToday` | grants left today under the daily cap; absent when the placement has none |
114
- | `nextEligibleAt` | when a running cooldown ends, in epoch milliseconds; absent when none runs |
115
- | `heldLimitReached` | the player already holds the placement's `max_held` — spend one first |
116
- | `resetsAt` | when today's count resets, the next 00:00 UTC, in epoch milliseconds |
117
-
118
- **`grantable` is not a promise.** A player can press a `grantable` button and get `no_ad`, for
119
- reasons your game is not told. Read that `no_ad` like any other (§1) — never guess a reason — and
120
- re-read your inventory whatever the ask settled as. The status grants nothing and reserves
121
- nothing.
122
-
123
- It never rejects, like the rest of `gem.ads`. Without a play token, when the read fails or takes
124
- longer than ten seconds, or for a placement outside the grammar below, it answers not declared and
125
- not grantable with no `resetsAt`, and the reason goes to your `onError` handler. It is a read of
126
- the platform, so call it when you draw the button — on a results or death screen — and not every
127
- frame, and draw the rest of the screen first rather than holding it on the answer.
128
-
129
- ## 4. The Arcade pauses your game for you
130
-
131
- While a panel is up, your game is paused the same way it is paused when a player switches tabs —
132
- the Arcade sends the pause your game already handles, and lifts it when the panel closes. You do
133
- not need to pause anything yourself, and you should not resume on a timer: resume when the promise
134
- settles.
135
-
136
- If you have not wired the pause at all yet, that is the thing to fix first;
137
- [`what-a-game-must-do.md`](what-a-game-must-do.md) covers it.
138
-
139
- ## Where to put the call
140
-
141
- At a break **the player already expects**: a level ending, a round finishing, a run being banked, a
142
- return to a menu. Never mid-input, never during a timed sequence, and never on a screen the player
143
- reached by accident.
144
-
145
- **A rewarded ask comes from the player's press, and from nowhere else.** Your button is the
146
- player's opt-in. The Arcade starts the ad as soon as the ask arrives, with no prompt of its own
147
- first, so an ask the player did not make is an ad they did not choose. Call `requestAd` from the
148
- button's own `click`, `pointerup`, `touchend` or `keydown` handler, or straight from the
149
- controller press that chose it. Ask first, before any `await` or timer that could outlast the
150
- press: a browser counts a tap, click or key press for only a few seconds, and the SDK counts a
151
- controller press for one.
152
-
153
- ⚠️ **Not from `pointerdown` or `touchstart`.** On a touchscreen they run before the browser counts
154
- the tap, so the ask is refused on a phone. With a mouse, `pointerdown` counts, so a desk test passes
155
- and hides it.
156
-
157
- Where the browser reports user activation, your frame holds none, and no controller was used in
158
- the last second, the SDK answers `no_ad` without asking. It writes a `[gem] rewarded_requires_user_gesture` line to the console once, and
159
- calls your `onError` handler with code `'internal'` and a message that begins
160
- `rewarded_requires_user_gesture`, which is the one to match in your own error reporting:
161
-
162
- ```js
163
- const gem = createGem({
164
- onError: (code, message) => {
165
- if (message?.startsWith('rewarded_requires_user_gesture')) {
166
- reportBug('rewarded ask outside a press', message); // your own telemetry
167
- }
168
- },
169
- });
170
- ```
171
-
172
- Where the browser cannot tell, the ask goes ahead. The check is there to catch a mistake while you
173
- build; it is not a control. It catches an ask with no recent input, and cannot tell which input
174
- caused the ask, so an ask from another button's handler goes through. An interstitial needs no
175
- press.
176
-
177
- **The button says it is an ad, and names the reward:** "Watch an ad for 3 dice rolls", "Watch an ad
178
- to revive". Never use a label that hides the ad, or one that reads as the only way forward, such as
179
- "Tap to continue". [`rewarded-ads.md` §5](rewarded-ads.md#5-draw-the-button-only-when-it-can-pay)
180
- covers the button.
181
-
182
- `placement` is your own name for that break — `level-complete`, `run-banked`, `back-to-menu`. For
183
- an interstitial, the platform matches it against nothing, never shows it to a player, and does not
184
- vary its decision on it; it exists so your own analytics can tell two break points apart, so name
185
- it for yourself. For `format: 'rewarded'`, the placement names the reward your game config declares
186
- in `rewarded_placements`, and the platform matches it against that config. Either way it **never
187
- reaches an ad network**, and neither does any other string your game writes.
188
-
189
- **It has a grammar, and it is narrow on purpose: 1 to 32 characters of `a-z`, `0-9`, `-` and `_`.**
190
- Lowercase, no spaces, no accents — `'Boss Fight'` is not a placement, `'boss-fight'` is. Anything
191
- else resolves `no_ad` and is reported to your `onError` handler, so you meet it while you are
192
- building rather than later. The platform holds the same grammar where it reads the name: for a
193
- rewarded ask the Arcade forwards the placement unchanged, and a config that declares one outside
194
- the grammar is refused at upload, so a name that did not fit would be a reward your shipped game
195
- could never reach. The bound is the platform's, not the SDK's, and the two agree.
196
-
197
- ```js
198
- async function onLevelComplete() {
199
- showResultsScreen();
200
- await gem.ads.requestAd({ placement: 'level-complete', format: 'interstitial' });
201
- // Settled either way. The results screen is still up; the player taps Next.
202
- enableNextButton();
203
- }
204
- ```
205
-
206
- ## What never crosses
207
-
208
- Your game never learns what was shown — no title, no image, no id, nothing about the other game.
209
- You receive an outcome and nothing else.
210
-
211
- You are also never told _why_ the answer was no. That is deliberate and permanent: a reason would
212
- tell your game something about the player that game code is not told, and there will not be a
213
- fourth outcome value naming one. The Arcade also settles every `no_ad` and `unavailable` on a
214
- schedule that does not depend on which branch produced it, so there is nothing in the timing to
215
- read either. (`shown` is not held back — how long that one takes is how long your player spent
216
- looking at a panel.)
217
-
218
- A rewarded ask is the one case where something else reaches your game: the reward itself, in your
219
- own inventory, when one was granted. That is what the ask is for. It says that a reward landed,
220
- never why, so do not read a reason into it.
221
-
222
- The other thing you can read is your own economy, and never from the answer:
223
- `gem.ads.rewardedStatus` reports **your own declared** rewarded caps as the platform holds them —
224
- the platform can hold a placement's daily cap below what you declared, never above — so you can
225
- tell when a rewarded `no_ad` was your own cap. It never reports anything about the player, the
226
- platform's spacing between offers, or any limit that spans games.
227
-
228
- ## If you do nothing
229
-
230
- Nothing happens. A game that never calls `gem.ads.requestAd` is never interrupted, and the call is
231
- not on the list of things a game must do to ship. This page exists for games that want a break to
232
- be worth something.