@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.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -3
- package/dist/function.d.ts +1 -0
- package/dist/game.d.ts +23 -1
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +2 -2
- package/dist/gem-web-sdk.function.js +2 -2
- 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-api.js +1 -1
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/index.d.ts +27 -4
- package/dist/sdk.d.ts +23 -1
- package/dist/server-api.d.ts +1 -0
- package/dist/server.d.ts +1 -0
- package/docs/analytics.md +6 -3
- package/docs/api/api-reference-function.md +26 -3
- package/docs/api/api-reference-game.md +192 -23
- package/docs/api/api-reference-server.md +23 -2
- package/docs/api/reference.json +218 -16
- package/docs/changelog.md +16 -0
- package/docs/deprecation-policy.md +6 -0
- package/docs/getting-started.md +7 -4
- package/docs/manifest.json +23 -23
- package/docs/platform-moments.md +266 -0
- package/docs/rewarded-ads.md +26 -9
- package/docs/server-bundles.md +2 -2
- package/docs/serverless-functions-js.md +17 -1
- package/docs/serverless-functions.md +13 -7
- package/docs/what-a-game-must-do.md +29 -13
- package/package.json +3 -1
- package/schema/bridge.v1.json +107 -1
- package/schema/capabilities.public.json +10 -0
- package/schema/moment-log-line.json +109 -0
- package/docs/cross-promotion.md +0 -232
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Platform moments
|
|
2
|
+
|
|
3
|
+
At every natural break in play — a level ending, a run over, a match's results, a return to the
|
|
4
|
+
menu — your game signals a **platform moment**. The Arcade then decides, on its own servers,
|
|
5
|
+
whether to show one of **its own** offers over your paused game: a break video, the Arcade app, a
|
|
6
|
+
platform store offer, a subscription, another game. Usually it shows nothing.
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
// From the press that LEAVES the break — Next, Retry, Back to menu.
|
|
10
|
+
async function onNextPressed() {
|
|
11
|
+
startLevelTransition(); // your fade or loading beat, in parallel
|
|
12
|
+
const { shown } = await gem.moments.signal({ kind: 'level_end' });
|
|
13
|
+
startNextLevel(); // settled either way; never grant anything on `shown`
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Every game signals a moment at every break**, whether or not it has a shop of its own. The
|
|
18
|
+
release checklist requires it (§11). That is the whole surface; the rest of this page is where to
|
|
19
|
+
put the call, where never to, and the few things a game gets wrong when nobody tells it.
|
|
20
|
+
|
|
21
|
+
- [1. A platform moment is not your own offer](#1-a-platform-moment-is-not-your-own-offer)
|
|
22
|
+
- [2. The five kinds](#2-the-five-kinds)
|
|
23
|
+
- [3. Where to call it, and where never to](#3-where-to-call-it-and-where-never-to)
|
|
24
|
+
- [4. The answer is usually `false`, and false is ordinary](#4-the-answer-is-usually-false-and-false-is-ordinary)
|
|
25
|
+
- [5. Nothing rejects](#5-nothing-rejects)
|
|
26
|
+
- [6. `shown` grants nothing](#6-shown-grants-nothing)
|
|
27
|
+
- [7. The Arcade pauses your game for you](#7-the-arcade-pauses-your-game-for-you)
|
|
28
|
+
- [8. Multiplayer](#8-multiplayer)
|
|
29
|
+
- [9. What never crosses](#9-what-never-crosses)
|
|
30
|
+
- [10. Testing it in the dev host](#10-testing-it-in-the-dev-host)
|
|
31
|
+
- [11. The release requirement](#11-the-release-requirement)
|
|
32
|
+
- [12. Moving off `format: 'interstitial'`](#12-moving-off-format-interstitial)
|
|
33
|
+
|
|
34
|
+
## 1. A platform moment is not your own offer
|
|
35
|
+
|
|
36
|
+
Two kinds of upsell can happen in a game, and they never mix.
|
|
37
|
+
|
|
38
|
+
| | **Platform moment** (this page) | **Your game's own offer** |
|
|
39
|
+
| ------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
40
|
+
| Who decides | the Arcade, on its servers | your game |
|
|
41
|
+
| What it shows | platform things only: break videos, the Arcade app, platform store offers, subscriptions, other games | your own items, currencies and rewarded buttons |
|
|
42
|
+
| When | only at a moment your game signals, paced by the platform | whenever your game likes, on its own cadence |
|
|
43
|
+
| Drawn by | the Arcade's own chrome, over your paused game | your game's own UI |
|
|
44
|
+
| What your game learns | `shown: true` or `false`, nothing else | everything — it is yours |
|
|
45
|
+
|
|
46
|
+
Three rules follow from it:
|
|
47
|
+
|
|
48
|
+
- **A platform moment never shows your game's own items, and your game never routes its own offer
|
|
49
|
+
through one.** Your shop, your special offers and your rewarded buttons
|
|
50
|
+
([`rewarded-ads.md`](rewarded-ads.md)) stay yours, in your UI, on your schedule.
|
|
51
|
+
- **Signal every natural break, whether or not your game has a shop of its own.** The moment is
|
|
52
|
+
about the break, not about what your game sells.
|
|
53
|
+
- **No stacking.** When a moment settles `shown: true`, do not also pop your own offer at that
|
|
54
|
+
break. Your normal results screen and its shop button are fine; a second popup in the same breath
|
|
55
|
+
is not.
|
|
56
|
+
|
|
57
|
+
## 2. The five kinds
|
|
58
|
+
|
|
59
|
+
`kind` is required, and it is one of five. The set is closed and will not grow: a game written
|
|
60
|
+
today never meets a sixth.
|
|
61
|
+
|
|
62
|
+
| `kind` | the break | for example |
|
|
63
|
+
| ------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
64
|
+
| `level_end` | a level, stage, wave or puzzle finished, won or lost | a platformer's stage clear, a puzzle solved, a tower-defence wave beaten |
|
|
65
|
+
| `run_end` | game over, death, or a run banked | an endless runner's crash, a roguelike death, an arcade game's last life |
|
|
66
|
+
| `match_end` | a multiplayer round or match finished, and the player is leaving its results | a battle royale's results, a card game's round, a race's podium |
|
|
67
|
+
| `menu_return` | the player chose to go back to the title, a main menu or level select — a return, never the first arrival | Back to menu on a results screen, Quit to menu from the pause menu |
|
|
68
|
+
| `other_break` | any other natural stop | the end of a story chapter, a between-waves shop |
|
|
69
|
+
|
|
70
|
+
`kind` is a **label**. The Arcade uses it for its own analytics, the dev host prints it (§10), and
|
|
71
|
+
the release checklist reads it (§11). It never changes what the Arcade decides for your game, so
|
|
72
|
+
there is nothing to gain by choosing one kind over another: pick the one that describes the break.
|
|
73
|
+
|
|
74
|
+
`placement` is optional: your own name for the screen the player is leaving — `world-1-3`,
|
|
75
|
+
`results`, `pause_quit`. It has the grammar every name you hand the SDK has, **1 to 32 characters of
|
|
76
|
+
`a-z`, `0-9`, `-` and `_`**, and it appears in exactly one place: the dev host's log, so a reviewer
|
|
77
|
+
can map each moment to a screen. It is never sent to the platform, never stored, never shown to a
|
|
78
|
+
player and never sent to an ad network.
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
await gem.moments.signal({ kind: 'run_end', placement: 'desert-run' });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 3. Where to call it, and where never to
|
|
85
|
+
|
|
86
|
+
**From the player's press that leaves the break** — Next, Retry, Continue, Back to menu. The
|
|
87
|
+
results screen stays fully interactive until then: the player reads their score, looks at the
|
|
88
|
+
leaderboard, opens your shop. When they press to leave, your game starts its own transition (a fade,
|
|
89
|
+
a loading beat) and signals at the same moment, in parallel, and starts play when the promise
|
|
90
|
+
settles. Usually nothing was shown and the answer arrives inside your transition, so the player
|
|
91
|
+
never sees a wait.
|
|
92
|
+
|
|
93
|
+
Once per break. A break with three ways out signals from whichever press the player made, not from
|
|
94
|
+
all three.
|
|
95
|
+
|
|
96
|
+
**Never:**
|
|
97
|
+
|
|
98
|
+
- mid-input, or during a timed sequence — a jump, a countdown, a combo;
|
|
99
|
+
- on a timer, or on a schedule of your own;
|
|
100
|
+
- on boot, or on the first arrival at the title screen — a `menu_return` is a **return**;
|
|
101
|
+
- from a pause menu the player opened, just because it opened — choosing **Quit to menu** from it
|
|
102
|
+
**is** a `menu_return`, signalled from that press;
|
|
103
|
+
- more than once at one break.
|
|
104
|
+
|
|
105
|
+
No press is required by the SDK: the rule is about when a break really is one, not about user
|
|
106
|
+
activation.
|
|
107
|
+
|
|
108
|
+
## 4. The answer is usually `false`, and false is ordinary
|
|
109
|
+
|
|
110
|
+
The promise resolves `{ shown }`, one boolean:
|
|
111
|
+
|
|
112
|
+
| `shown` | what happened | what your game does |
|
|
113
|
+
| ------- | ---------------------------------------------------------------- | ----------------------------------------- |
|
|
114
|
+
| `false` | nothing was shown | carry on exactly as if you had not asked |
|
|
115
|
+
| `true` | the Arcade showed something of its own, and it has closed | carry on — and grant nothing (§6) |
|
|
116
|
+
|
|
117
|
+
**`false` is the common answer**, not an edge case. The Arcade paces what it shows, gives a new
|
|
118
|
+
player time before it shows anything, and has nothing suitable more often than not. You are not told
|
|
119
|
+
which, and there is nothing to infer.
|
|
120
|
+
|
|
121
|
+
- Do **not** render a placeholder, a spinner or a "nothing right now" message.
|
|
122
|
+
- Do **not** hold your game's flow on a fill. Start your transition, signal, and continue when the
|
|
123
|
+
promise settles.
|
|
124
|
+
- Do **not** retry on a `false`. A second signal at the same break is refused (§5).
|
|
125
|
+
|
|
126
|
+
## 5. Nothing rejects
|
|
127
|
+
|
|
128
|
+
`gem.moments.signal` always resolves. There is no error branch and no `try`/`catch` to write. When
|
|
129
|
+
the SDK refuses a signal itself, it resolves `{ shown: false }` without asking the Arcade, and calls
|
|
130
|
+
your `onError` handler with code `'internal'` and a message that **begins with a stable token**, so
|
|
131
|
+
you find the mistake while you build:
|
|
132
|
+
|
|
133
|
+
| token | when |
|
|
134
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
135
|
+
| `moments_unsupported` | the page your game runs in does not do platform moments. Keep the call; it costs nothing. Said once a session |
|
|
136
|
+
| `moments_invalid_argument` | a `kind` outside the five, or a `placement` outside the grammar |
|
|
137
|
+
| `moments_before_ready` | a signal before `gem.frame.ready()` — play has not started, so there is no break |
|
|
138
|
+
| `moments_in_flight` | a second signal while the first is still waiting for its answer |
|
|
139
|
+
| `moments_called_repeatedly` | a signal within 10 seconds of the last moment's answer — one break signalled twice, or a timer |
|
|
140
|
+
| `moments_timeout` | no answer within three minutes |
|
|
141
|
+
|
|
142
|
+
The 10 seconds count from the last **answer**, not the last call, so a long panel does not eat into
|
|
143
|
+
the next level. A refused signal does not restart the count.
|
|
144
|
+
|
|
145
|
+
Match on the token, not the rest of the message:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const gem = createGem({
|
|
149
|
+
onError: (code, message) => {
|
|
150
|
+
if (message?.startsWith('moments_') && !message.startsWith('moments_unsupported')) {
|
|
151
|
+
reportBug('platform moment refused', message); // your own telemetry
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 6. `shown` grants nothing
|
|
158
|
+
|
|
159
|
+
`shown: true` says the Arcade showed something of its own and it has closed. It is **not** a
|
|
160
|
+
receipt, **not** an entitlement, and **not** permission to hand anything out. Nothing on this call
|
|
161
|
+
pays the player, and a game that unlocks a level or grants a currency on the strength of it has an
|
|
162
|
+
unlock any page embedding your game can forge.
|
|
163
|
+
|
|
164
|
+
If your game wants to give the player something for reaching the end of a level, give it to them
|
|
165
|
+
for reaching the end of the level.
|
|
166
|
+
|
|
167
|
+
## 7. The Arcade pauses your game for you
|
|
168
|
+
|
|
169
|
+
While the Arcade shows something, your game is paused the same way it is when a player switches
|
|
170
|
+
tabs — the Arcade sends the pause your game already handles, and lifts it when the panel closes.
|
|
171
|
+
You do not pause anything yourself, and you never resume on a timer: resume when the promise
|
|
172
|
+
settles. If you have not wired the pause yet, that is the thing to fix first;
|
|
173
|
+
[`what-a-game-must-do.md`](what-a-game-must-do.md) covers it.
|
|
174
|
+
|
|
175
|
+
## 8. Multiplayer
|
|
176
|
+
|
|
177
|
+
Signal `match_end` as the player leaves a match's results, exactly as a single-player game signals
|
|
178
|
+
`level_end`. What the Arcade may show to a player who is still in a live room or a party about to
|
|
179
|
+
start is the Arcade's decision, made from its own knowledge of the room — never from the `kind`
|
|
180
|
+
your game sent — so there is nothing extra for your game to do, and nothing to hold back. The
|
|
181
|
+
Arcade does not keep a player who is about to play waiting on an offer.
|
|
182
|
+
|
|
183
|
+
## 9. What never crosses
|
|
184
|
+
|
|
185
|
+
Your game never learns what was shown — no title, no image, no offer, no id. It receives one
|
|
186
|
+
boolean.
|
|
187
|
+
|
|
188
|
+
You are also never told **why** nothing was shown. That is deliberate and permanent: the reasons
|
|
189
|
+
include pacing, a new player's grace, the player's account, age and consent, and a reason would
|
|
190
|
+
tell your game something about the player that game code is not told. There will not be a second
|
|
191
|
+
field naming one. The Arcade also settles every `false` on a schedule that does not depend on why,
|
|
192
|
+
so there is nothing in the timing to read either. (`true` is not held back — how long that one
|
|
193
|
+
takes is how long your player spent with a panel.)
|
|
194
|
+
|
|
195
|
+
## 10. Testing it in the dev host
|
|
196
|
+
|
|
197
|
+
`gemctl serve` and the Gem Editor's dev host answer moments the way the Arcade does, with a TEST
|
|
198
|
+
panel in place of a real offer. (Both dev hosts gain this in their own releases after the SDK that
|
|
199
|
+
ships `gem.moments`. Until yours has it, a moment there answers `false`, and your `onError`
|
|
200
|
+
handler hears `moments_unsupported` once.)
|
|
201
|
+
|
|
202
|
+
> **Platform moment · level_end · world-1-3**
|
|
203
|
+
> In the Gem Arcade, an offer of the Arcade's own may appear here. Usually nothing does. Your game
|
|
204
|
+
> is paused until this closes.
|
|
205
|
+
> **[Close]**
|
|
206
|
+
|
|
207
|
+
Add `?moments=` to the dev host's page to choose what it does:
|
|
208
|
+
|
|
209
|
+
| mode | what the dev host does |
|
|
210
|
+
| --------- | ----------------------------------------------------------------------------------------- |
|
|
211
|
+
| `none` | shows nothing, and answers `false` after the same short wait the Arcade holds |
|
|
212
|
+
| `panel` | shows the TEST panel at every moment |
|
|
213
|
+
| `paced` | shows the panel at most once every three minutes, so you see the real frequency |
|
|
214
|
+
| `video` | the longest path: a ten-second break video, then its result |
|
|
215
|
+
| `delayed` | paints the panel late, to test a player who presses Continue before it arrives |
|
|
216
|
+
|
|
217
|
+
Each moment writes **one log line**, in the same form on every dev host:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
moment kind=level_end placement=world-1-3 since_ready_ms=84210 since_prev_ms=61005 answer=not_shown
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`since_ready_ms` is how long after `gem.frame.ready()` the moment arrived, `since_prev_ms` how long
|
|
224
|
+
after the previous one (`-` for the first), and `answer` is `shown` or `not_shown`. A
|
|
225
|
+
`placement` you did not give is `-`, and a deprecated interstitial ask is logged on the same line as
|
|
226
|
+
`kind=legacy`. The grammar of the line is published with the SDK, as
|
|
227
|
+
`@indiegems/gem-web-sdk/schema/moment-log-line.json`.
|
|
228
|
+
|
|
229
|
+
Play through every break in your game once and read the log: every results, game-over and
|
|
230
|
+
back-to-menu screen should have produced a line, with the `kind` you meant, and none should appear
|
|
231
|
+
on boot, mid-level or seconds apart. That log is what the release checklist reads (§11).
|
|
232
|
+
|
|
233
|
+
## 11. The release requirement
|
|
234
|
+
|
|
235
|
+
The release checklist requires platform moments, as a platform requirement every game meets:
|
|
236
|
+
|
|
237
|
+
- **MOM-01 — every natural break signals a platform moment.** The checklist inventories your
|
|
238
|
+
breaks from your code and from a play-through under `gemctl serve`, and expects a log line for
|
|
239
|
+
each. A game with no moments at all does not pass; a break with no signal is a finding to fix.
|
|
240
|
+
- **MOM-02 — never at the wrong time.** No signal right after `ready()`, none on a regular
|
|
241
|
+
interval, none twice at one break, none mid-input and none from opening a pause menu. The log
|
|
242
|
+
line is how the first three are checked.
|
|
243
|
+
- **MOM-03 — no stacking** (advisory): no popup of your own at a break that settled
|
|
244
|
+
`shown: true`.
|
|
245
|
+
|
|
246
|
+
**The platform itself does not enforce any of this.** Publishing never refuses a game for missing
|
|
247
|
+
moments, the Arcade never refuses to run one, and `gemctl`'s doctor warns rather than fails. The
|
|
248
|
+
checklist is where it is required, and it is required there at every important break.
|
|
249
|
+
|
|
250
|
+
## 12. Moving off `format: 'interstitial'`
|
|
251
|
+
|
|
252
|
+
`gem.ads.requestAd({ placement, format: 'interstitial' })` is **deprecated**. It still works, and
|
|
253
|
+
the Arcade treats it as a platform moment, but `14.0.0` is the earliest release that may remove it
|
|
254
|
+
([`deprecation-policy.md`](deprecation-policy.md)). Replace it at the same break, and move the call
|
|
255
|
+
to the press that leaves the break if it was made as the results screen opened:
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
// Before
|
|
259
|
+
await gem.ads.requestAd({ placement: 'level-complete', format: 'interstitial' });
|
|
260
|
+
|
|
261
|
+
// After — from the Next press
|
|
262
|
+
await gem.moments.signal({ kind: 'level_end', placement: 'level-complete' });
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`format: 'rewarded'` is not deprecated and does not change: a button the player presses for a
|
|
266
|
+
reward is your game's own offer, and [`rewarded-ads.md`](rewarded-ads.md) is its page.
|
package/docs/rewarded-ads.md
CHANGED
|
@@ -204,10 +204,11 @@ cannot tell which input caused the ask: a browser counts any tap in your game fo
|
|
|
204
204
|
and the SDK counts any controller input for one, so an ask from your jump button's handler, or
|
|
205
205
|
from a death a second after a tap, goes through.
|
|
206
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
207
|
|
|
209
|
-
The answer is one of
|
|
210
|
-
|
|
208
|
+
The answer is one of three outcomes — `shown`, `no_ad` or `unavailable`, the last two read
|
|
209
|
+
identically — and it never rejects: even a `placement` outside the grammar resolves `no_ad`, and
|
|
210
|
+
the mistake goes to your `onError` handler. For `rewarded` none of the three says whether a reward
|
|
211
|
+
was granted. What can happen
|
|
211
212
|
after a press:
|
|
212
213
|
|
|
213
214
|
| what happened | was the reward granted? |
|
|
@@ -230,7 +231,7 @@ player already holds before asking for another (§5's code does both).
|
|
|
230
231
|
A granted reward shows in your order history like any other change, as an order the platform made
|
|
231
232
|
with `source` `reward`.
|
|
232
233
|
|
|
233
|
-
**While the ad plays, your game is paused,** the same way it is
|
|
234
|
+
**While the ad plays, your game is paused,** the same way it is at a [platform moment](platform-moments.md): the Arcade
|
|
234
235
|
sends the pause your game already handles and lifts it when the ad closes. Resume when the promise
|
|
235
236
|
settles, not on a timer. A second `requestAd` while one is still in flight resolves `no_ad` at once,
|
|
236
237
|
so disable the button between the press and the answer.
|
|
@@ -301,13 +302,28 @@ a cap is spent, a cooldown runs, the player holds `max_held`, or rewarded is off
|
|
|
301
302
|
drawing on it never offers a press that is certain to pay nothing. Hide or grey the button out
|
|
302
303
|
otherwise, and say when it is back from `nextEligibleAt` or `resetsAt`. `grantable` is not a
|
|
303
304
|
promise: a press can still pay nothing for reasons the read never reports. The status reserves
|
|
304
|
-
nothing, and it never rejects
|
|
305
|
+
nothing, and it never rejects:
|
|
305
306
|
|
|
306
307
|
```js
|
|
307
308
|
const status = await gem.ads.rewardedStatus('classic_revive');
|
|
308
309
|
watchAdButton.hidden = !status.grantable;
|
|
309
310
|
```
|
|
310
311
|
|
|
312
|
+
| field | what it says |
|
|
313
|
+
| ------------------ | -------------------------------------------------------------------------- |
|
|
314
|
+
| `declared` | the config promoted to the channel being played names this placement |
|
|
315
|
+
| `grantable` | pressing the button could pay right now — **not** that an ad will play |
|
|
316
|
+
| `remainingToday` | grants left today under the daily cap; absent when the placement has none |
|
|
317
|
+
| `nextEligibleAt` | when a running cooldown ends, in epoch milliseconds; absent when none runs |
|
|
318
|
+
| `heldLimitReached` | the player already holds the placement's `max_held` — spend one first |
|
|
319
|
+
| `resetsAt` | when today's count resets, the next 00:00 UTC, in epoch milliseconds |
|
|
320
|
+
|
|
321
|
+
Without a play token, when the read fails or takes longer than ten seconds, or for a placement
|
|
322
|
+
outside the grammar, it answers not declared and not grantable with no `resetsAt`, and the reason
|
|
323
|
+
goes to your `onError` handler. It is a read of the platform, so call it when you draw the button
|
|
324
|
+
— on a results or death screen — not every frame, and draw the rest of the screen first rather than
|
|
325
|
+
holding it on the answer.
|
|
326
|
+
|
|
311
327
|
For a revive, the screen reads what it needs **when it opens**, so the press itself can ask at
|
|
312
328
|
once:
|
|
313
329
|
|
|
@@ -437,10 +453,11 @@ platform sets them all. A rewarded reward is your economy, so the caps are yours
|
|
|
437
453
|
|
|
438
454
|
## 7. Pacing
|
|
439
455
|
|
|
440
|
-
- **A rewarded ask is never held back by
|
|
441
|
-
refused because
|
|
442
|
-
- **
|
|
443
|
-
|
|
456
|
+
- **A rewarded ask is never held back by a platform moment.** A player who presses your button is
|
|
457
|
+
not refused because the Arcade showed something a minute ago, in your game or another.
|
|
458
|
+
- **A rewarded ad does not replace the break's platform moment.** Signal the moment from the press
|
|
459
|
+
that leaves the break as usual ([`platform-moments.md`](platform-moments.md)); whether the Arcade
|
|
460
|
+
shows anything then is its decision.
|
|
444
461
|
- **Rewarded has its own spacing:** your placement's `cooldown_seconds`, and the platform's 15
|
|
445
462
|
seconds (§6). An ask inside any of them answers `no_ad` with nothing granted, so do not re-ask on
|
|
446
463
|
a timer. Never ask on a timer at all: the ad would start with no press. The SDK refuses a timer's
|
package/docs/server-bundles.md
CHANGED
|
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
|
|
|
80
80
|
also publishes on its download host, with the hash beside it:
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
|
-
curl -fsSL -O https://downloads.gemarcade.com/sdk/v13.
|
|
84
|
-
curl -fsSL https://downloads.gemarcade.com/sdk/v13.
|
|
83
|
+
curl -fsSL -O https://downloads.gemarcade.com/sdk/v13.2.0/gem-web-sdk.tgz
|
|
84
|
+
curl -fsSL https://downloads.gemarcade.com/sdk/v13.2.0/gem-web-sdk.tgz.sri
|
|
85
85
|
openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
|
|
86
86
|
```
|
|
87
87
|
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Serverless functions in JavaScript
|
|
2
2
|
|
|
3
|
+
⚠️ **JavaScript functions are disabled for now, due to performance issues. Write a new function in
|
|
4
|
+
Rust.** A JavaScript function runs many times slower on the platform than the same function built
|
|
5
|
+
from Rust as a WebAssembly component, too slow to fit a call's CPU limit, so uploading a new
|
|
6
|
+
JavaScript function is refused with `400 js_functions_disabled`: "New JavaScript functions are not
|
|
7
|
+
accepted: build the function as a WebAssembly component from Rust (the gem:functions world, target
|
|
8
|
+
wasm32-unknown-unknown) and finalize it with kind `component`. Versions already published as
|
|
9
|
+
JavaScript keep running." [serverless-functions.md](serverless-functions.md#3-writing-one) shows how
|
|
10
|
+
to write one in Rust. A JavaScript function you have already published keeps running: it is still
|
|
11
|
+
listed, you can still promote it to a channel or roll back to it, and players' calls still reach
|
|
12
|
+
it. This page stays for those functions, and for when JavaScript functions come back.
|
|
13
|
+
|
|
3
14
|
A serverless function can be written in JavaScript or TypeScript instead of Rust: the language your
|
|
4
15
|
game is already written in, so the verification code in your browser game moves into a function
|
|
5
16
|
with no change beyond bundling. You upload one bundled ES module, and the platform runs it on a
|
|
@@ -218,7 +229,9 @@ gemctl function check functions/score-check/dist/function.js
|
|
|
218
229
|
```
|
|
219
230
|
|
|
220
231
|
`check` runs the platform's publish-time validation for JavaScript on your machine, and an upload
|
|
221
|
-
runs it first and answers `400 source_invalid` with the same `details.reason
|
|
232
|
+
runs it first and answers `400 source_invalid` with the same `details.reason`. While JavaScript
|
|
233
|
+
functions are disabled (the notice at the top of this page), an upload is refused with
|
|
234
|
+
`400 js_functions_disabled` before any of these checks run.
|
|
222
235
|
|
|
223
236
|
| Reason | What it usually is | What to change |
|
|
224
237
|
| ------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
|
@@ -234,6 +247,9 @@ JavaScript; it does not accept TypeScript, so upload what your build wrote.
|
|
|
234
247
|
|
|
235
248
|
## 5. Publishing
|
|
236
249
|
|
|
250
|
+
While JavaScript functions are disabled, a new JavaScript version cannot be uploaded; promoting a
|
|
251
|
+
version you already published, and rolling back to one, work as before.
|
|
252
|
+
|
|
237
253
|
Uploading, promoting, rolling back and channels are the same as for a component
|
|
238
254
|
(serverless-functions.md sections 4 and 5). On the website, the **Functions** tab of your game's
|
|
239
255
|
**Builds** page promotes a version to a channel, rolls back by promoting an earlier one, and shows
|
|
@@ -55,10 +55,15 @@ possible.
|
|
|
55
55
|
|
|
56
56
|
## 2. What a function is
|
|
57
57
|
|
|
58
|
-
A function is one WebAssembly **component**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
A function is one WebAssembly **component** per game per channel, built from Rust.
|
|
59
|
+
|
|
60
|
+
⚠️ **JavaScript functions are disabled for now, due to performance issues. Write your function in
|
|
61
|
+
Rust.** Uploading a new JavaScript function is refused with `400 js_functions_disabled`. A
|
|
62
|
+
JavaScript function you have already published keeps running, and
|
|
63
|
+
[serverless-functions-js.md](serverless-functions-js.md) still covers it.
|
|
64
|
+
|
|
65
|
+
A component is built against the platform's `gem:functions` world, and exports exactly one
|
|
66
|
+
function:
|
|
62
67
|
|
|
63
68
|
```wit
|
|
64
69
|
export handle: func(input: list<u8>) -> result<list<u8>, string>;
|
|
@@ -467,9 +472,9 @@ decision point, such as the end of a run or a claim, and never on a timer or onc
|
|
|
467
472
|
|
|
468
473
|
## 3. Writing one
|
|
469
474
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
475
|
+
A function is written in Rust and built into a component; JavaScript functions are disabled for
|
|
476
|
+
now, due to performance issues (section 2). gemctl writes the starter crate and wraps what your
|
|
477
|
+
build produces, and the build in between is yours:
|
|
473
478
|
|
|
474
479
|
```bash
|
|
475
480
|
gemctl function init functions/score-check
|
|
@@ -844,6 +849,7 @@ Publishing has its own refusals, and nothing is stored when one happens:
|
|
|
844
849
|
| What you get | What happened | What to do |
|
|
845
850
|
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
|
846
851
|
| `400 component_invalid` | The backend's check refused the component. `details.reason` is one of the five in section 3, or `sha256_mismatch`, `size_mismatch` or `not_uploaded` | The five: fix and rebuild. The other three: upload again |
|
|
852
|
+
| `400 js_functions_disabled` | You uploaded a JavaScript function. JavaScript functions are disabled for now, due to performance issues; versions already published as JavaScript keep running | Write it in Rust and upload the component (section 3) |
|
|
847
853
|
| `409 function_version_exists` | That version string is taken, perhaps by a deleted version not yet purged | Choose another version |
|
|
848
854
|
| `409 function_version_in_use` | You tried to delete a version a channel still serves | Promote another version there, or unassign it first |
|
|
849
855
|
| `404 function_version_not_found` | No such version, or it has been deleted | `gemctl function list --all --include-deleted` |
|
|
@@ -45,7 +45,7 @@ const link = await gem.match.connect(); // one link to the authority, whoever th
|
|
|
45
45
|
No `baseUrl`. The Arcade names the backend on every token and the SDK routes there — see
|
|
46
46
|
"which backend" below.
|
|
47
47
|
|
|
48
|
-
## The
|
|
48
|
+
## The things that are yours
|
|
49
49
|
|
|
50
50
|
### 1. The embedder allowlist
|
|
51
51
|
|
|
@@ -154,6 +154,28 @@ The SDK reports; it does not decide. A stale build (`versionUpdateAvailable`), a
|
|
|
154
154
|
allocate, a token the parent refused — what a game does about each is a game decision, and a platform
|
|
155
155
|
layer that acted would be making it for you.
|
|
156
156
|
|
|
157
|
+
### 6. A platform moment at every break
|
|
158
|
+
|
|
159
|
+
**Every game signals a platform moment from every press that leaves a natural break** — Next on a
|
|
160
|
+
level-complete screen, Retry on a game-over screen, Continue on a match's results, Back to menu —
|
|
161
|
+
whether or not it has a shop of its own. The Arcade decides whether to show one of its own offers
|
|
162
|
+
over your paused game, and usually shows nothing:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
async function onNextPressed() {
|
|
166
|
+
startLevelTransition(); // your own fade, in parallel
|
|
167
|
+
await gem.moments.signal({ kind: 'level_end' });
|
|
168
|
+
startNextLevel(); // settled either way; `shown` grants nothing
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The SDK cannot place this call for you, because only your game knows where its breaks are. **The
|
|
173
|
+
release checklist requires it** at every important break (MOM-01), and never at the wrong time —
|
|
174
|
+
on boot, on a timer, mid-input or twice at one break (MOM-02). The platform does not enforce it:
|
|
175
|
+
nothing refuses to publish or run a game without one. Nothing rejects either, so there is no error
|
|
176
|
+
branch to write. It is the platform's moment, never your own offer: your shop and your rewarded
|
|
177
|
+
buttons stay yours. [`platform-moments.md`](platform-moments.md) is the page.
|
|
178
|
+
|
|
157
179
|
## The boot phase: settle the bridge before the heavy bundle
|
|
158
180
|
|
|
159
181
|
The invariant, stated plainly, because it is the hardest-won lesson in this document:
|
|
@@ -1651,20 +1673,14 @@ running entirely in the browser cannot offer competition, and read
|
|
|
1651
1673
|
[its safety rule](async-opponents.md#7-draw-a-set-at-match-start) before you render anything a
|
|
1652
1674
|
drawn record carries, because those strings were typed by another player.
|
|
1653
1675
|
|
|
1654
|
-
## A
|
|
1655
|
-
|
|
1656
|
-
Not on the list above either, and a game that never asks is never interrupted. If yours wants a
|
|
1657
|
-
break to carry a promotion for another game on the Arcade, `gem.ads.requestAd` asks for one and the
|
|
1658
|
-
Arcade draws it in its own chrome over your paused game.
|
|
1676
|
+
## A rewarded button, if your game has a reward to offer for an ad
|
|
1659
1677
|
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
nothing, so a reward must never be handed out on the strength of it.
|
|
1663
|
-
[`cross-promotion.md`](cross-promotion.md) is the page.
|
|
1678
|
+
Not on the list above either: a game with no rewarded button is complete without one. (A platform
|
|
1679
|
+
moment at every break is on it — [§6 above](#6-a-platform-moment-at-every-break).)
|
|
1664
1680
|
|
|
1665
|
-
A button the player presses for a reward — a revive, an extra attempt — is
|
|
1666
|
-
`format: 'rewarded'`, made from that press itself. The button
|
|
1667
|
-
reward, and the Arcade starts the ad as soon as the ask arrives. Your game config declares what
|
|
1681
|
+
A button the player presses for a reward — a revive, an extra attempt — is
|
|
1682
|
+
`gem.ads.requestAd({ placement, format: 'rewarded' })`, made from that press itself. The button
|
|
1683
|
+
says it is an ad and names the reward, and the Arcade starts the ad as soon as the ask arrives. Your game config declares what
|
|
1668
1684
|
each such button grants, the platform writes the reward into your game's inventory, and your game
|
|
1669
1685
|
reads it there rather than from the answer.
|
|
1670
1686
|
[`rewarded-ads.md`](rewarded-ads.md) is that page.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indiegems/gem-web-sdk",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.2.0",
|
|
4
4
|
"description": "postMessage bridge contract between gem-web and browser-playable games",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"import": "./dist/gem-web-sdk.quality.js"
|
|
41
41
|
},
|
|
42
42
|
"./schema": "./schema/bridge.v1.json",
|
|
43
|
+
"./schema/moment-log-line.json": "./schema/moment-log-line.json",
|
|
43
44
|
"./brand/*": "./brand/*",
|
|
44
45
|
"./rigs/*": "./rigs/*"
|
|
45
46
|
},
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
"!dist/gem-web-sdk.devtools.min.js",
|
|
51
52
|
"schema/bridge.v1.json",
|
|
52
53
|
"schema/capabilities.public.json",
|
|
54
|
+
"schema/moment-log-line.json",
|
|
53
55
|
"brand/*.png",
|
|
54
56
|
"rigs/*.glb",
|
|
55
57
|
"docs/*.md",
|
package/schema/bridge.v1.json
CHANGED
|
@@ -3995,7 +3995,7 @@
|
|
|
3995
3995
|
"format": {
|
|
3996
3996
|
"kind": "enum",
|
|
3997
3997
|
"values": ["interstitial", "rewarded"],
|
|
3998
|
-
"$comment": "A closed set, so a typo is refused at the boundary rather than left for the parent to interpret. `rewarded` IS SERVED WHERE THE PARENT SERVES IT, and answered `no_ad` where it does not. Either way a `shown` grants nothing - see gem.adResult's outcome: the platform grants a rewarded placement's reward itself, into the game's own inventory, against its own record. A game that grants a reward on an outcome has a reward any page that can post on this port can forge."
|
|
3998
|
+
"$comment": "A closed set, so a typo is refused at the boundary rather than left for the parent to interpret. `interstitial` IS DEPRECATED in favour of gem.signalMoment: it keeps working, a parent routes it into the same decision as a platform moment and records it as a legacy one, and 14.0.0 is the earliest SDK release that may stop sending it. `rewarded` IS SERVED WHERE THE PARENT SERVES IT, and answered `no_ad` where it does not. Either way a `shown` grants nothing - see gem.adResult's outcome: the platform grants a rewarded placement's reward itself, into the game's own inventory, against its own record. A game that grants a reward on an outcome has a reward any page that can post on this port can forge."
|
|
3999
3999
|
}
|
|
4000
4000
|
},
|
|
4001
4001
|
"rateLimit": {
|
|
@@ -4093,6 +4093,112 @@
|
|
|
4093
4093
|
},
|
|
4094
4094
|
"rateLimit": { "perMinute": 12 }
|
|
4095
4095
|
},
|
|
4096
|
+
{
|
|
4097
|
+
"type": "gem.signalMoment",
|
|
4098
|
+
"direction": "frameToParent",
|
|
4099
|
+
"transport": "port",
|
|
4100
|
+
"$comment": [
|
|
4101
|
+
"The game has reached a natural break in play - a level, a run or a match",
|
|
4102
|
+
"has ended, or the player is going back to a menu - and is leaving it.",
|
|
4103
|
+
"Gated by the `moments` capability.",
|
|
4104
|
+
"",
|
|
4105
|
+
"A SIGNAL, NOT AN ASK FOR ANYTHING. The parent decides, in its own",
|
|
4106
|
+
"server-side arbiter, whether to show one of ITS OWN offers over the paused",
|
|
4107
|
+
"game at this break - a break video, the app, a platform store offer, a",
|
|
4108
|
+
"subscription, another game - and almost always decides to show nothing.",
|
|
4109
|
+
"The game never names what it would like shown, and never routes its own",
|
|
4110
|
+
"offer through this: its own shop and its own rewarded buttons are its own",
|
|
4111
|
+
"business, on its own cadence, in its own UI.",
|
|
4112
|
+
"",
|
|
4113
|
+
"IT SUPERSEDES `gem.requestAd` WITH `format: 'interstitial'`, which keeps",
|
|
4114
|
+
"working through the N-1 window. A parent routes both into the same",
|
|
4115
|
+
"decision and holds ONE single-flight shared by both message types,",
|
|
4116
|
+
"because a hostile frame can post both at once.",
|
|
4117
|
+
"",
|
|
4118
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce. Everything",
|
|
4119
|
+
"gem.requestAd's comment says of an interstitial holds here word for word:",
|
|
4120
|
+
"advertise `moments` as a deployment-wide fact; render in your own chrome",
|
|
4121
|
+
"over the game paused with `gem.pause`; answer every signal exactly once;",
|
|
4122
|
+
"settle every `shown: false` on a schedule that does not depend on which",
|
|
4123
|
+
"branch produced it; and treat every field as untrusted text. Two more:",
|
|
4124
|
+
"",
|
|
4125
|
+
" `kind` IS A LABEL AND DECIDES NOTHING OF VALUE. It is for analytics,",
|
|
4126
|
+
" the dev-host log and the release checklist. A frame-to-parent field is",
|
|
4127
|
+
" routing only (DESIGN.md section 9), so anything that matters - a",
|
|
4128
|
+
" multiplayer rule, say - is derived from the parent's own state, never",
|
|
4129
|
+
" from what the game says the break was. Re-validate it yourself: the",
|
|
4130
|
+
" frame posts on the port directly.",
|
|
4131
|
+
"",
|
|
4132
|
+
" REFUSE RATHER THAN QUEUE. A signal that arrives while the player has",
|
|
4133
|
+
" something of the parent's open, or while the parent itself has paused",
|
|
4134
|
+
" the game, is answered `shown: false`, through the same floor as every",
|
|
4135
|
+
" other no. It is never held to paint after the player closes something.",
|
|
4136
|
+
"",
|
|
4137
|
+
"THE `perMinute` BELOW BOUNDS A BUGGY GAME, NOT A HOSTILE ONE, for the",
|
|
4138
|
+
"reason gem.requestAd's gives. The SDK also refuses a second signal within",
|
|
4139
|
+
"10 s of the last answer and one before `gem.ready`, which is the sender",
|
|
4140
|
+
"keeping a promise, not a guarantee a parent holds."
|
|
4141
|
+
],
|
|
4142
|
+
"fields": {
|
|
4143
|
+
"requestId": {
|
|
4144
|
+
"kind": "string",
|
|
4145
|
+
"maxLength": 64,
|
|
4146
|
+
"$comment": "Opaque and frame-chosen, and echo-only, exactly as gem.requestAd's. Never keyed on, and not safe to log unescaped."
|
|
4147
|
+
},
|
|
4148
|
+
"kind": {
|
|
4149
|
+
"kind": "enum",
|
|
4150
|
+
"values": ["level_end", "run_end", "match_end", "menu_return", "other_break"],
|
|
4151
|
+
"$comment": "Which break this is. CLOSED NOW, AND PINNED BY A TEST ON THE SDK SIDE: a new value on a frame-to-parent enum is breaking under DESIGN.md section 9, because an N-1 parent's generated validator refuses the whole message. `level_end` - a level, stage, wave or puzzle finished, won or lost. `run_end` - game over, death, or a run banked. `match_end` - a multiplayer round or match finished and the player is leaving its results. `menu_return` - the player chose to go back to the title, a main menu or level select; a return, never the first arrival. `other_break` - any other natural stop, defined by exclusion. A LABEL ONLY: it never decides anything of value on the platform."
|
|
4152
|
+
},
|
|
4153
|
+
"placement": {
|
|
4154
|
+
"kind": "string",
|
|
4155
|
+
"maxLength": 32,
|
|
4156
|
+
"optional": true,
|
|
4157
|
+
"$comment": "The game's own name for the screen it is leaving - 'world-1-3', 'results'. UNTRUSTED TEXT. Shown ONLY in a development host's log, so a reviewer can map moments to screens. NEVER FORWARDED TO THE PLATFORM, NEVER STORED, NEVER RENDERED TO A PLAYER AND NEVER SENT TO AN AD NETWORK: a game-authored string that fits this grammar fits a dashless UUID too. The SDK sends only `^[a-z0-9_-]{1,32}$`; codegen emits type and length only, so the charset is the sender's to keep and the receiver's to re-check."
|
|
4158
|
+
}
|
|
4159
|
+
},
|
|
4160
|
+
"rateLimit": {
|
|
4161
|
+
"perMinute": 6,
|
|
4162
|
+
"$comment": "As gem.requestAd's: bounds a BUGGY game, not a hostile one. Breaks in play come minutes apart; six a minute is generous for them and mean for a loop."
|
|
4163
|
+
}
|
|
4164
|
+
},
|
|
4165
|
+
{
|
|
4166
|
+
"type": "gem.momentResult",
|
|
4167
|
+
"direction": "parentToFrame",
|
|
4168
|
+
"transport": "port",
|
|
4169
|
+
"$comment": [
|
|
4170
|
+
"The answer to one gem.signalMoment. Gated by the `moments` capability.",
|
|
4171
|
+
"",
|
|
4172
|
+
"ONE BOOLEAN, AND NO REASON. `shown: true` - the parent painted something",
|
|
4173
|
+
"of its own over the paused game and it has closed. `shown: false` -",
|
|
4174
|
+
"nothing was painted, which is the ordinary answer. Either way the game",
|
|
4175
|
+
"carries on exactly as it would have; `shown` GRANTS NOTHING and tells the",
|
|
4176
|
+
"game nothing about what was shown or why nothing was.",
|
|
4177
|
+
"",
|
|
4178
|
+
"NO REASON, EVER, for the reason gem.adResult carries none: a `false`",
|
|
4179
|
+
"absorbs the feature being off, pacing, a new player's grace, an",
|
|
4180
|
+
"entitlement, an age and a consent state, and a reason naming any of them",
|
|
4181
|
+
"hands untrusted game code a fact about a person. The boolean is permanent",
|
|
4182
|
+
"for the same reason the ad pair's three values are.",
|
|
4183
|
+
"",
|
|
4184
|
+
"THE VALUE IS NOT THE WHOLE CHANNEL. As gem.adResult's comment says, a",
|
|
4185
|
+
"parent settles every `shown: false` on a schedule that does not depend on",
|
|
4186
|
+
"which branch produced it. `shown: true` is not delayed: its latency is a",
|
|
4187
|
+
"player reading a panel."
|
|
4188
|
+
],
|
|
4189
|
+
"fields": {
|
|
4190
|
+
"requestId": {
|
|
4191
|
+
"kind": "string",
|
|
4192
|
+
"maxLength": 64,
|
|
4193
|
+
"$comment": "Echoes the gem.signalMoment that asked. Echo-only."
|
|
4194
|
+
},
|
|
4195
|
+
"shown": {
|
|
4196
|
+
"kind": "boolean",
|
|
4197
|
+
"$comment": "Whether the parent painted anything at this break. It grants nothing, and a game never branches anything of value on it."
|
|
4198
|
+
}
|
|
4199
|
+
},
|
|
4200
|
+
"rateLimit": { "perMinute": 12 }
|
|
4201
|
+
},
|
|
4096
4202
|
{
|
|
4097
4203
|
"type": "gem.requestSignIn",
|
|
4098
4204
|
"direction": "frameToParent",
|