@indiegems/gem-web-sdk 7.9.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/dist/function.d.ts +26 -1
- package/dist/game.d.ts +32 -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.js +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/sdk.d.ts +32 -1
- package/docs/api/api-reference-game.md +424 -8
- package/docs/api/api-reference-server.md +1 -1
- package/docs/api/reference.json +441 -5
- package/docs/changelog.md +30 -0
- package/docs/friends.md +86 -11
- package/docs/getting-started.md +10 -6
- package/docs/manifest.json +21 -16
- package/docs/saving-player-progress.md +41 -0
- package/docs/server-bundles.md +2 -2
- package/docs/serverless-functions-js.md +3 -2
- package/docs/serverless-functions.md +53 -7
- package/docs/vibration.md +141 -0
- package/docs/what-a-game-must-do.md +12 -0
- package/package.json +1 -1
- package/schema/bridge.v1.json +60 -0
- package/schema/capabilities.public.json +9 -0
package/docs/friends.md
CHANGED
|
@@ -11,9 +11,10 @@ for (const friend of page.friends) {
|
|
|
11
11
|
}
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
That read,
|
|
15
|
-
confirms with the player and sends itself —
|
|
16
|
-
|
|
14
|
+
That read, one ask — [sending a friend a challenge](#friend-challenges), which the Arcade
|
|
15
|
+
confirms with the player and sends itself — and [reading the challenges the player
|
|
16
|
+
received](#reading-the-challenges-a-player-received) in your game are the whole surface. The
|
|
17
|
+
boundary around them is the thing to understand before you design against it.
|
|
17
18
|
|
|
18
19
|
## What the read is, and is not
|
|
19
20
|
|
|
@@ -31,7 +32,8 @@ Everything else about the friend graph stays on the Arcade, by design:
|
|
|
31
32
|
platform screens with the player's own account credential — a game frame holds a play token,
|
|
32
33
|
and a play token cannot touch the graph. Sending a [friend challenge](#friend-challenges) does
|
|
33
34
|
not touch it either: your game asks, and the Arcade sends only after the player confirms in its
|
|
34
|
-
own sheet.
|
|
35
|
+
own sheet. The one write a play token has is marking one of the player's own friend challenges
|
|
36
|
+
answered (`gem.friends.markAnswered`), which records that they played it and tells nobody.
|
|
35
37
|
- **No states.** Pending requests, blocks and relationship history do not appear. An accepted
|
|
36
38
|
friend appears; everyone else does not exist on this surface.
|
|
37
39
|
- **No presence, no invites.** Nothing here says who is online or delivers an invite — an invite
|
|
@@ -174,15 +176,88 @@ existing `instanceof GemApiError` handling keeps working:
|
|
|
174
176
|
|
|
175
177
|
### When a challenge launches your game
|
|
176
178
|
|
|
177
|
-
The friend's tap opens your game with the challenge's params in its launch query,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
The friend's tap opens your game with the challenge's params in its launch query, and with one key
|
|
180
|
+
of the platform's own, `gemChallenge`. **Do not read either from the URL to find the challenge.**
|
|
181
|
+
Ask the SDK:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
const challenge = await gem.friends.launchedChallenge();
|
|
185
|
+
if (challenge?.template === 'beat_code_jar') {
|
|
186
|
+
// Check every param before it does anything, as you would a launch key: the template
|
|
187
|
+
// constrained it when it was sent, but your config may have changed since.
|
|
188
|
+
const jar = typeof challenge.params.jar === 'string' && /^[A-Z0-9]{7}$/.test(challenge.params.jar)
|
|
189
|
+
? challenge.params.jar
|
|
190
|
+
: null;
|
|
191
|
+
if (jar) {
|
|
192
|
+
startJar(jar, {
|
|
193
|
+
from: challenge.from, // who sent it, from the platform's records
|
|
194
|
+
replay: challenge.answeredAt !== undefined, // already played once
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`launchedChallenge()` reads `gemChallenge` once, when `createGem` runs — so cleaning your URL
|
|
201
|
+
with `history.replaceState` afterwards changes nothing — and looks the challenge up on the
|
|
202
|
+
platform. The order matters the other way too: call `createGem` before you clean the URL, or
|
|
203
|
+
leave `gemChallenge` in place, because a key removed before `createGem` runs is never seen and
|
|
204
|
+
`launchedChallenge()` resolves `null` with no error. You get the sender as a [friend row](#what-the-read-is-and-is-not), the template key and
|
|
205
|
+
the params exactly as they were sent.
|
|
206
|
+
|
|
207
|
+
It **resolves `null`**, never rejects, for every launch that is not this player's challenge in
|
|
208
|
+
this game: no `gemChallenge` key, a value that is not a challenge id, a link naming someone
|
|
209
|
+
else's challenge, one from another game or channel, and every guest launch. Treat `null` as an
|
|
210
|
+
ordinary launch. Only a real failure — the platform unreachable, a context with no player —
|
|
211
|
+
rejects.
|
|
212
|
+
|
|
213
|
+
**Never put a name in a param, and never read one out.** A param key like `by=`, `from=` or
|
|
214
|
+
`name=` is forbidden: the sender is `challenge.from`, resolved by the platform, and a name in a
|
|
215
|
+
param is one nobody checked — your game would render whatever the sending client put there.
|
|
182
216
|
|
|
183
217
|
**A relaunch replays the same challenge.** When the player presses Play again in the Arcade, it
|
|
184
|
-
reuses the launch query, so the same
|
|
185
|
-
|
|
218
|
+
reuses the launch query, so the same challenge comes back. Tell a replay from a first attempt by
|
|
219
|
+
`answeredAt`, and make answering idempotent.
|
|
220
|
+
|
|
221
|
+
### Marking a challenge answered
|
|
222
|
+
|
|
223
|
+
When the attempt the challenge asked for is over — won or lost — mark it:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
await gem.friends.markAnswered(challenge.id);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
It is idempotent: a challenge keeps the first time it was answered, and a second call changes
|
|
230
|
+
nothing. It tells the sender nothing — there are no read receipts — so it is your game's own
|
|
231
|
+
record, read back as `answeredAt` on every later read. It rejects `not_found` for an id that is
|
|
232
|
+
not one of this player's challenges in this game and channel (one answer for unknown and
|
|
233
|
+
someone else's), and `invalid`, before anything is sent, for a value that cannot be a challenge
|
|
234
|
+
id at all.
|
|
235
|
+
|
|
236
|
+
### Reading the challenges a player received
|
|
237
|
+
|
|
238
|
+
`gem.friends.inbox()` lists the friend challenges this player holds **in this game, on this
|
|
239
|
+
channel**, in every state:
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
let page = await gem.friends.inbox();
|
|
243
|
+
renderChallenges(page.challenges);
|
|
244
|
+
while (page.nextCursor) {
|
|
245
|
+
page = await gem.friends.inbox({ cursor: page.nextCursor });
|
|
246
|
+
renderChallenges(page.challenges);
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- **Answered challenges are included, and sort last**; within each part, newest first. Filter on
|
|
251
|
+
`answeredAt` if you only want the open ones.
|
|
252
|
+
- **A guest's page is empty**, not a refusal — a guest is never sent a challenge. Build the screen
|
|
253
|
+
for the empty state first.
|
|
254
|
+
- **Paged like the friends list**: default 50, at most 200, an opaque cursor passed back verbatim.
|
|
255
|
+
A player holds at most 200 friend challenges across every game, so one max-size page is all of
|
|
256
|
+
them.
|
|
257
|
+
- `openedAt` is when the player pressed Play on it in the Arcade. There is no "seen" time: that is
|
|
258
|
+
the Arcade's, not your game's.
|
|
259
|
+
|
|
260
|
+
For the challenge this launch carried, use `launchedChallenge()` rather than searching the inbox.
|
|
186
261
|
|
|
187
262
|
## Inspecting a player: game profiles
|
|
188
263
|
|
package/docs/getting-started.md
CHANGED
|
@@ -50,7 +50,7 @@ either way.
|
|
|
50
50
|
```jsonc
|
|
51
51
|
// package.json
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@indiegems/gem-web-sdk": "
|
|
53
|
+
"@indiegems/gem-web-sdk": "8.0.0"
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -342,8 +342,11 @@ That is the whole contract for your own keys. The bridge carries no launch messa
|
|
|
342
342
|
has no API for them: the arcade does the forwarding, so a build you published on any SDK version
|
|
343
343
|
gets it without a code change. The one key that is not yours is the platform's `gemChallenge`
|
|
344
344
|
(below), which belongs to [friend challenges](friends.md#friend-challenges): never define it, and
|
|
345
|
-
never parse it yourself. The SDK
|
|
346
|
-
|
|
345
|
+
never parse it yourself. The SDK reads it for you — `gem.friends.launchedChallenge()` answers the
|
|
346
|
+
challenge it names, or `null` — and that is the only launch key it will ever read. It is read once,
|
|
347
|
+
when `createGem` runs, so cleaning your URL afterwards does not lose it. Call `createGem` before
|
|
348
|
+
you clean the URL, or leave `gemChallenge` in place: a key removed before `createGem` runs is never
|
|
349
|
+
seen, and `launchedChallenge()` then resolves `null` with no error.
|
|
347
350
|
|
|
348
351
|
What reaches the frame:
|
|
349
352
|
|
|
@@ -365,9 +368,10 @@ What reaches the frame:
|
|
|
365
368
|
letter, so the arcade keeps that space for itself.
|
|
366
369
|
|
|
367
370
|
- **One platform key.** `gemChallenge` does reach the frame — the arcade adds it when a player
|
|
368
|
-
opens a friend challenge — but it is the platform's, not yours
|
|
369
|
-
|
|
370
|
-
a
|
|
371
|
+
opens a friend challenge — but it is the platform's, not yours: read the challenge with
|
|
372
|
+
`gem.friends.launchedChallenge()`, never from the URL. Do not define a key of that name, and
|
|
373
|
+
treat every key beginning with `gem` as reserved: a friend-challenge template refuses a param
|
|
374
|
+
named that way.
|
|
371
375
|
- **Bounded.** At most 16 parameters. A key is a letter followed by up to 63 letters, digits,
|
|
372
376
|
`_` or `-`. A value is at most 512 characters with no control or format characters. An entry
|
|
373
377
|
that breaks a rule is dropped on its own; the rest of the link still arrives.
|
package/docs/manifest.json
CHANGED
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"file": "changelog.md",
|
|
36
|
-
"sha256": "
|
|
37
|
-
"updated": "2026-10-
|
|
36
|
+
"sha256": "37bae4170e89587ee8c498891fa2dac008f09fb01fcfbcf87b1310340d2cfc62",
|
|
37
|
+
"updated": "2026-10-06T19:43:21Z"
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
"file": "cross-promotion.md",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
"file": "friends.md",
|
|
61
|
-
"sha256": "
|
|
62
|
-
"updated": "2026-10-
|
|
61
|
+
"sha256": "002e6f61050cd188baa0b9b056c9ceaccd96e653361e0c28646baa5632e525df",
|
|
62
|
+
"updated": "2026-10-06T16:37:15Z"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"file": "game-economy-tutorial.md",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
"file": "getting-started.md",
|
|
71
|
-
"sha256": "
|
|
72
|
-
"updated": "2026-10-
|
|
71
|
+
"sha256": "77ce7b2223d18267ac3ca2f49e09481752539f2674910e2cb12832825c945000",
|
|
72
|
+
"updated": "2026-10-06T19:43:21Z"
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"file": "inventory-and-catalogs.md",
|
|
@@ -113,33 +113,38 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"file": "saving-player-progress.md",
|
|
116
|
-
"sha256": "
|
|
117
|
-
"updated": "2026-10-
|
|
116
|
+
"sha256": "08778cccc7d7a9a26a005a070bb2cc98784752ebec9dfce9873ef3d4e674e1e0",
|
|
117
|
+
"updated": "2026-10-05T16:53:25Z"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
"file": "server-bundles.md",
|
|
121
|
-
"sha256": "
|
|
122
|
-
"updated": "2026-10-
|
|
121
|
+
"sha256": "49f51b37e969b6120d2df6cf6d2f33d2856c1602fd7504a7b308a9ea21c9b502",
|
|
122
|
+
"updated": "2026-10-06T19:43:21Z"
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"file": "serverless-functions-js.md",
|
|
126
|
-
"sha256": "
|
|
127
|
-
"updated": "2026-10-
|
|
126
|
+
"sha256": "8d5bda66dfebd93e80fe97b2f09c0651c26dc83b16827bdae7aa25f198f89e0b",
|
|
127
|
+
"updated": "2026-10-03T00:26:43Z"
|
|
128
128
|
},
|
|
129
129
|
{
|
|
130
130
|
"file": "serverless-functions.md",
|
|
131
|
-
"sha256": "
|
|
132
|
-
"updated": "2026-10-
|
|
131
|
+
"sha256": "1edaa60a8aaf20499f24d661f169790cacfdb11c26529735a93e5b7b996e5432",
|
|
132
|
+
"updated": "2026-10-03T00:26:43Z"
|
|
133
133
|
},
|
|
134
134
|
{
|
|
135
135
|
"file": "trading-and-gifting.md",
|
|
136
136
|
"sha256": "d7ee3c85540ab8aae29988c16a76bee3ba03cc66b10d2faaf5cd7210a80a0544",
|
|
137
137
|
"updated": "2026-08-26T03:27:49Z"
|
|
138
138
|
},
|
|
139
|
+
{
|
|
140
|
+
"file": "vibration.md",
|
|
141
|
+
"sha256": "3608da64abcef207cf0875f9d32f3aed88d4fb23512bc70d0a9684a570263a44",
|
|
142
|
+
"updated": "2026-10-06T17:36:25Z"
|
|
143
|
+
},
|
|
139
144
|
{
|
|
140
145
|
"file": "what-a-game-must-do.md",
|
|
141
|
-
"sha256": "
|
|
142
|
-
"updated": "2026-10-
|
|
146
|
+
"sha256": "436a8b61c46a3de6c7d5db65293bdcd17426412742dffd21f976bf87d1418d88",
|
|
147
|
+
"updated": "2026-10-06T17:36:25Z"
|
|
143
148
|
}
|
|
144
149
|
]
|
|
145
150
|
}
|
|
@@ -436,6 +436,47 @@ game, and a key you experiment with on `dev` cannot break a shipped build.
|
|
|
436
436
|
| a difficulty, a control scheme, a level counter | `gem.me.settings` — a string per key | yes |
|
|
437
437
|
| a volume, a quality preset, a dismissed tip | `gem.deviceSettings` — one object per device | **no** |
|
|
438
438
|
|
|
439
|
+
## Starting over as a new player while you test
|
|
440
|
+
|
|
441
|
+
Testing a first launch, a tutorial or a new player's economy means getting your own account
|
|
442
|
+
back to nothing on your game. Do not write code for it. A throwaway serverless function, or a
|
|
443
|
+
reset button you mean to remove later, is the workaround this replaces. gemctl does it, on
|
|
444
|
+
`dev` or `test`, for the account you are signed in as:
|
|
445
|
+
|
|
446
|
+
```bash
|
|
447
|
+
gemctl support reset --player me --channel dev --reason "fresh tutorial run"
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`--channel test` works the same way. `--reason` is required and is kept on the audit record, so
|
|
451
|
+
say why in a few words, with no personal data or tokens. Run it from your project's directory,
|
|
452
|
+
or add `--game <game-id>`.
|
|
453
|
+
|
|
454
|
+
The order matters, and two of the steps are the ones people skip:
|
|
455
|
+
|
|
456
|
+
1. **Close the game first.** A running game still holds the old state, and its next autosave
|
|
457
|
+
writes it straight back over the reset.
|
|
458
|
+
2. **Run the reset.**
|
|
459
|
+
3. **Clear the site data of the page the game runs in, with the game still closed.** That is
|
|
460
|
+
the Arcade, or the page `gemctl serve` opened, not the game's own origin. `gem.deviceSettings`
|
|
461
|
+
lives in that page's storage on this device, and no reset reaches it. Skip this and the old
|
|
462
|
+
volume, quality and dismissed tips come back. Clearing the Arcade's site data also signs you
|
|
463
|
+
out of it and clears the device settings of every game you have played there on this device;
|
|
464
|
+
the page `gemctl serve` opened holds only the game you are serving.
|
|
465
|
+
4. **Reopen the game.** It should see what a new player sees: `null` from
|
|
466
|
+
`gem.me.saves.get`, an empty `gem.me.settings.list()`, and `null` device settings.
|
|
467
|
+
|
|
468
|
+
The reset clears this player's state **in this game, on that one channel**: saves,
|
|
469
|
+
`gem.me.settings`, inventory and progressions, and the rest of what the game keeps for them.
|
|
470
|
+
It does not touch your other channels, your other games, achievements, or your Gems and
|
|
471
|
+
Tickets. Your purchases and what you paid stay on record, but an item a purchase delivered on
|
|
472
|
+
that channel is cleared with the rest, and the answer lists those purchases.
|
|
473
|
+
`gemctl support reset --help` has the full list, and the answer names whatever it left alone.
|
|
474
|
+
|
|
475
|
+
**It never runs on `release`.** gemctl refuses before sending anything, and so does the
|
|
476
|
+
platform. It is a tool for testing your own game, not something a game ships. A "New game" or
|
|
477
|
+
"Reset progress" button for players is game code: it overwrites or clears the player's slots
|
|
478
|
+
with `gem.me.saves` and their keys with `gem.me.settings`, as described above.
|
|
479
|
+
|
|
439
480
|
## What the platform already does with these, so you do not
|
|
440
481
|
|
|
441
482
|
**A player's erasure covers their saves.** When an account is erased the stored payloads go with
|
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/
|
|
84
|
-
curl -fsSL https://downloads.gemarcade.com/sdk/
|
|
83
|
+
curl -fsSL -O https://downloads.gemarcade.com/sdk/v8.0.0/gem-web-sdk.tgz
|
|
84
|
+
curl -fsSL https://downloads.gemarcade.com/sdk/v8.0.0/gem-web-sdk.tgz.sri
|
|
85
85
|
openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
|
|
86
86
|
```
|
|
87
87
|
|
|
@@ -33,8 +33,9 @@ message and the stack go to the platform's log for the invocation, never to the
|
|
|
33
33
|
`handleJson` whose input is not JSON, or whose return value JSON cannot carry, fails the same way.
|
|
34
34
|
|
|
35
35
|
**`console.*` reaches the invocation's log** at the matching level (`debug`, `info`, `warn`,
|
|
36
|
-
`error`); `console.log` and every other method log at `info`. As with a component's `log`,
|
|
37
|
-
reads it back
|
|
36
|
+
`error`); `console.log` and every other method log at `info`. As with a component's `log`,
|
|
37
|
+
`gemctl function logs` reads it back for a call on `dev`, and a call on `test` or `release` keeps
|
|
38
|
+
no line.
|
|
38
39
|
|
|
39
40
|
## 2. Writing one
|
|
40
41
|
|
|
@@ -81,7 +81,7 @@ everything a function can reach:
|
|
|
81
81
|
| `call-api` | One request to the platform API, made **as your game's server**: a method, a `/v1/` path and a JSON body, and nothing else |
|
|
82
82
|
| `download-link` | A save's contents, fetched from the download link the save's record carries |
|
|
83
83
|
| `upload-link` | A save's new contents, sent to the upload link the platform issued for them |
|
|
84
|
-
| `log` | A line kept
|
|
84
|
+
| `log` | A line kept 24 hours under this call's invocation id, on `dev` only, cut at 1 KiB |
|
|
85
85
|
| `random-bytes` | Randomness. The function has no other source |
|
|
86
86
|
| `now-ms` | Unix time in milliseconds. The function has no other clock |
|
|
87
87
|
|
|
@@ -120,9 +120,9 @@ Anything that changes several players at once — settling a match, trading betw
|
|
|
120
120
|
granting a whole room its rewards — is your **game server's** job, or the job of a function your
|
|
121
121
|
server calls, which is not bound to a player.
|
|
122
122
|
|
|
123
|
-
**`
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
**`gemctl function logs` reads back what `log` wrote** (section 7), with a line saying how each
|
|
124
|
+
call ended — but only for a call on `dev`, and only for 24 hours. A call on `test` or `release`
|
|
125
|
+
keeps no line, so to see what a tester or a player hit, make the same call on `dev`.
|
|
126
126
|
|
|
127
127
|
**A function cannot call a function.** `call-api` to `/v1/functions/invoke` is refused with
|
|
128
128
|
`function_cannot_invoke`.
|
|
@@ -732,8 +732,10 @@ It reports:
|
|
|
732
732
|
- `function_message`: your `Err` text, on a `function_error`;
|
|
733
733
|
- `reason`: on a `function_failed`, which limit or fault ended the call;
|
|
734
734
|
- `round_trip_ms`: measured by gemctl, from sending the call to the last byte back;
|
|
735
|
-
- `invocation_id`: what the platform files the call under. **Keep it.** It is
|
|
736
|
-
|
|
735
|
+
- `invocation_id`: what the platform files the call under. **Keep it.** It is what you pass to
|
|
736
|
+
`--invocation-id` to read that call's lines, and what you quote when you report the call;
|
|
737
|
+
- `logs`: on a `test` or `release` call that reached your function, that it left no log lines. A
|
|
738
|
+
`dev` call that reached it prints the `gemctl function logs` command that reads its lines instead.
|
|
737
739
|
|
|
738
740
|
Anything but `returned` exits 1.
|
|
739
741
|
|
|
@@ -744,6 +746,48 @@ If the call is refused before anything is invoked, the problem is your sign-in:
|
|
|
744
746
|
be a developer on the game, `test` needs a grant on it, and `release` needs a game your account may
|
|
745
747
|
play.
|
|
746
748
|
|
|
749
|
+
### Reading what it logged
|
|
750
|
+
|
|
751
|
+
`gemctl function logs` reads what your function wrote with `log`, newest first, with one closing
|
|
752
|
+
line per call saying how it ended.
|
|
753
|
+
|
|
754
|
+
```bash
|
|
755
|
+
gemctl function logs --invocation-id <invocation-id> # one call: what it logged, how it ended
|
|
756
|
+
gemctl function logs --outcome error # every call whose handle returned Err
|
|
757
|
+
gemctl function logs --level warn # what your function warned about
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
⚠️ **Only a call on `dev` leaves lines.** A call on `test` or `release` leaves none, so there is
|
|
761
|
+
no channel to choose and nothing here shows what a tester or a player hit. Make the same call on
|
|
762
|
+
`dev`, with the input that failed, and read that.
|
|
763
|
+
|
|
764
|
+
Each row is one of two kinds, and both carry the call's `invocation_id` and its `caller`, `player`
|
|
765
|
+
or `game_server`:
|
|
766
|
+
|
|
767
|
+
- **A line your function logged**: its `level` and its `message`.
|
|
768
|
+
- **The closing line**: `outcome` is `ok`, `error`, `timeout`, `cpu_exceeded` or `failed`, with the
|
|
769
|
+
call's `wall_ms` and `cpu_ms`. On `error`, `message` is your `Err` text. On `failed`, `reason`
|
|
770
|
+
says what ended the call, and when your own code trapped, `message` is the trap's message.
|
|
771
|
+
|
|
772
|
+
Newest first means a call's closing line comes before what it logged. **A call's logging is
|
|
773
|
+
capped**: once it reaches its per-call log limit, that `log` call and every later one go
|
|
774
|
+
unrecorded, and the closing line's `dropped_log_lines` says how many. A log that stops part-way
|
|
775
|
+
with a nonzero count means your function logged too much, not that it went quiet.
|
|
776
|
+
|
|
777
|
+
`--invocation-id`, `--level` (`debug`, `info`, `warn` or `error`) and `--outcome` narrow the page.
|
|
778
|
+
`--level` picks what your function logged and `--outcome` picks closing lines, so the two cannot be
|
|
779
|
+
combined: to see everything a failed call logged, pass its invocation id. The window is the last 24
|
|
780
|
+
hours unless `--start-ns` and `--end-ns` (Unix nanoseconds) narrow it, and a full page ends with a
|
|
781
|
+
hint carrying `--cursor` for the next, older one. There is no follow mode.
|
|
782
|
+
|
|
783
|
+
**An empty page does not prove your function was not called.** Lines take a few seconds to arrive
|
|
784
|
+
and are kept for 24 hours. A call the platform refused before your function ran leaves none — a bad
|
|
785
|
+
credential, a malformed request, a channel serving nothing, a full pool, a component that could not
|
|
786
|
+
be downloaded — and its `invoke` answer is its only record.
|
|
787
|
+
|
|
788
|
+
Every `message` was written by your function's code, and any member of your game's team can read
|
|
789
|
+
it. Log ids, not anything personal about a player.
|
|
790
|
+
|
|
747
791
|
## 8. When a call is refused
|
|
748
792
|
|
|
749
793
|
Every answer from a call that reached your function carries an invocation id: success,
|
|
@@ -766,7 +810,9 @@ the last column: it is `true` exactly where this table says to retry.
|
|
|
766
810
|
| `403 forbidden` | The call carried an account sign-in rather than a game-scoped or game-server token | Call from your game or its server, or through `gemctl function invoke` |
|
|
767
811
|
|
|
768
812
|
A `trap` is a panic, an `unreachable`, an out-of-bounds access or a stack overflow, and no stack
|
|
769
|
-
trace comes back with it.
|
|
813
|
+
trace comes back with it. On `dev`, the call's closing line in `gemctl function logs` carries the
|
|
814
|
+
trap's message when your own code trapped (section 7). The other three reasons are the limits in
|
|
815
|
+
section 2.
|
|
770
816
|
|
|
771
817
|
Publishing has its own refusals, and nothing is stored when one happens:
|
|
772
818
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Vibration
|
|
2
|
+
|
|
3
|
+
`navigator.vibrate` does nothing on any iPhone. It does not exist in Safari, in any other iOS
|
|
4
|
+
browser, or in the Gem Arcade iPhone app, and Firefox removed it on every platform. A game that
|
|
5
|
+
calls it vibrates on Android and nowhere else.
|
|
6
|
+
|
|
7
|
+
`gem.vibration` vibrates wherever the player's device can. It takes the same pattern as
|
|
8
|
+
`navigator.vibrate` and returns the same boolean, so switching is a rename:
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
navigator.vibrate(30); // before: Android only
|
|
12
|
+
gem.vibration.vibrate(30); // after: Android, and the Gem Arcade iPhone app
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Two rules come before everything else on this page. Both are easy to get wrong.
|
|
16
|
+
|
|
17
|
+
1. **Read `gem.vibration.available` live, after `awaitBridge()`, every time you need it.** On an
|
|
18
|
+
iPhone it is `false` until the bridge to the Arcade connects. If you build your settings screen
|
|
19
|
+
at boot and hide the vibration toggle then, it stays hidden on exactly the device this exists
|
|
20
|
+
for.
|
|
21
|
+
2. **Never call `navigator.vibrate`**, with or without a guard, and never write a fallback to it.
|
|
22
|
+
`gem.vibration.vibrate` already calls `navigator.vibrate` itself wherever that works.
|
|
23
|
+
|
|
24
|
+
## The whole integration
|
|
25
|
+
|
|
26
|
+
Your game owns its vibration toggle; the Arcade has none. Keep the toggle in
|
|
27
|
+
`gem.deviceSettings`, never in `localStorage`, which every publish of your game wipes.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
await gem.awaitBridge();
|
|
31
|
+
|
|
32
|
+
// Your settings, kept by the Arcade so they survive your next publish.
|
|
33
|
+
let settings = (await gem.deviceSettings.wait(1_500)) ?? {};
|
|
34
|
+
|
|
35
|
+
// Build the row when the settings screen OPENS, never once at boot.
|
|
36
|
+
function openSettings() {
|
|
37
|
+
vibrationRow.hidden = !gem.vibration.available;
|
|
38
|
+
vibrationToggle.checked = settings.vibration !== false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
vibrationToggle.onchange = () => {
|
|
42
|
+
settings = { ...settings, v: 1, vibration: vibrationToggle.checked };
|
|
43
|
+
void gem.deviceSettings.put(settings);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Check your own toggle before every call. Nothing else: no fallback.
|
|
47
|
+
function onHit() {
|
|
48
|
+
if (settings.vibration !== false) gem.vibration.vibrate(30);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
There is no `available` check before `vibrate`: where vibration is not available, `vibrate` does
|
|
53
|
+
nothing and returns `false`. `available` exists to decide whether to show the toggle.
|
|
54
|
+
|
|
55
|
+
## What a pattern is
|
|
56
|
+
|
|
57
|
+
Exactly what `navigator.vibrate` takes. A number is one vibration of that many milliseconds; an
|
|
58
|
+
array alternates vibration and pause, starting with a vibration.
|
|
59
|
+
|
|
60
|
+
- **`0` or `[]` stops** a pattern that is still playing.
|
|
61
|
+
- **A new call replaces** whatever is still playing.
|
|
62
|
+
- **The pattern is capped**: at most 99 entries, each at most 10 000 ms, and **at most 10 seconds
|
|
63
|
+
in all**, vibrations and pauses together. A longer pattern is cut short, not refused.
|
|
64
|
+
- **A negative, `NaN` or infinite entry refuses the whole pattern**: `vibrate` returns `false`.
|
|
65
|
+
|
|
66
|
+
`vibrate` returns `true` when the pattern was accepted, never that a motor moved: the player may
|
|
67
|
+
have vibration switched off in their phone's settings, which your game cannot see.
|
|
68
|
+
|
|
69
|
+
## Nothing vibrates before the first tap
|
|
70
|
+
|
|
71
|
+
Browsers allow vibration only after the player has tapped or pressed a key in your game. Before
|
|
72
|
+
that, `vibrate` returns `false` and vibrates nothing, on Android and iPhone alike. So a buzz on
|
|
73
|
+
load, or on a title screen nobody has touched, never plays.
|
|
74
|
+
|
|
75
|
+
## A cookbook, by feel
|
|
76
|
+
|
|
77
|
+
On an iPhone, a vibration of 40 ms or less plays as a crisp tap, stronger the longer it is; a
|
|
78
|
+
longer one plays as a continuous buzz. On Android every entry is a buzz of that length.
|
|
79
|
+
|
|
80
|
+
| Feel | Pattern | Use it for |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| tick | `10` | a menu cursor moving, a dial clicking |
|
|
83
|
+
| tap | `20` | a button press, a piece placed |
|
|
84
|
+
| hit | `30` to `50` | taking damage, a landing, a collision |
|
|
85
|
+
| heavy | `100` | an explosion, a boss hit; use it rarely |
|
|
86
|
+
| alert | `[200, 100, 200]` | a warning, a timer running out |
|
|
87
|
+
| cancel | `0` | stopping whatever is playing |
|
|
88
|
+
|
|
89
|
+
Keep hit feedback at about 100 ms or less. Anything longer stops feeling like feedback and starts
|
|
90
|
+
feeling like a phone call.
|
|
91
|
+
|
|
92
|
+
## Stop on pause
|
|
93
|
+
|
|
94
|
+
When your game pauses, stop anything still playing:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
onPause: (paused) => {
|
|
98
|
+
if (paused) gem.vibration.vibrate(0);
|
|
99
|
+
},
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The Arcade also stops a running pattern itself when it pauses your game or the player leaves it,
|
|
103
|
+
but a pause your game makes on its own is yours to stop.
|
|
104
|
+
|
|
105
|
+
## Where it vibrates
|
|
106
|
+
|
|
107
|
+
| Where the player is | Vibrates? |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| An Android phone, in the Gem Arcade app or in Chrome | yes |
|
|
110
|
+
| An iPhone, in the Gem Arcade app | yes |
|
|
111
|
+
| An iPhone or iPad, in Safari or any other browser | no: `available` is `false` |
|
|
112
|
+
| An iPad, in the Gem Arcade app | no: an iPad has no vibration motor |
|
|
113
|
+
| Firefox, anywhere | no: `available` is `false` |
|
|
114
|
+
| A desktop or laptop with a mouse | no: `available` is `false` |
|
|
115
|
+
|
|
116
|
+
A laptop with a touch screen may report `available` as `true` and then vibrate nothing. That is
|
|
117
|
+
harmless; it is why the toggle is your player's to switch off.
|
|
118
|
+
|
|
119
|
+
## Gamepad rumble is separate
|
|
120
|
+
|
|
121
|
+
A controller's rumble is `GamepadHapticActuator` (`gamepad.vibrationActuator`), not
|
|
122
|
+
`navigator.vibrate`. It is unaffected by any of this, and `gem.vibration` does not drive it.
|
|
123
|
+
|
|
124
|
+
## What not to do
|
|
125
|
+
|
|
126
|
+
- **`navigator.vibrate?.(x)`.** It is still a direct call: it vibrates on Android and silently
|
|
127
|
+
nowhere else.
|
|
128
|
+
- **`if ('vibrate' in navigator)` to decide whether to show the toggle.** It hides the toggle on
|
|
129
|
+
every iPhone, where `gem.vibration` works. Use `gem.vibration.available`.
|
|
130
|
+
- **A wrapper that falls back to `navigator.vibrate`.** `gem.vibration.vibrate` already does that
|
|
131
|
+
where it works, and nowhere else does a fallback do anything.
|
|
132
|
+
- **A user-agent check** to hide the toggle on iOS. Read `available`; it already knows.
|
|
133
|
+
- **Reading `available` once at boot.** It is `false` on an iPhone until the bridge connects.
|
|
134
|
+
- **Vibrating on load or every frame.** Nothing plays before the first tap, and a call every frame
|
|
135
|
+
is past the limit of 600 a minute: past it, `vibrate` returns `false`.
|
|
136
|
+
- **Hit feedback longer than about 100 ms.**
|
|
137
|
+
- **Keeping the toggle in `localStorage`.** It is gone after your next publish; use
|
|
138
|
+
`gem.deviceSettings`.
|
|
139
|
+
|
|
140
|
+
When `vibrate` refuses a call for a reason you can fix (no tap yet, a bad pattern, the limit), the
|
|
141
|
+
SDK says so once a session through your `log` option, which is `console.warn` unless you set it.
|
|
@@ -1183,6 +1183,18 @@ or after an `await`; and **never gate a reward on it**, because your game is tol
|
|
|
1183
1183
|
not even whether the player shared.
|
|
1184
1184
|
[Linking to your game](getting-started.md#linking-to-your-game) is the section.
|
|
1185
1185
|
|
|
1186
|
+
## If your game vibrates
|
|
1187
|
+
|
|
1188
|
+
Not on the list above either. `navigator.vibrate` does nothing on any iPhone, so call
|
|
1189
|
+
`gem.vibration.vibrate` instead: it takes the same pattern, returns the same boolean, and vibrates
|
|
1190
|
+
wherever the player's device can, the Gem Arcade iPhone app included.
|
|
1191
|
+
|
|
1192
|
+
Two things before you reach for it: **read `gem.vibration.available` live, after `awaitBridge()`**,
|
|
1193
|
+
to decide whether to show your vibration toggle, because on an iPhone it is `false` until the
|
|
1194
|
+
bridge connects; and **never call `navigator.vibrate`**, guarded or not, because the SDK already
|
|
1195
|
+
does wherever that works. Your game owns the toggle and keeps it in `gem.deviceSettings`.
|
|
1196
|
+
[`vibration.md`](vibration.md) is the page.
|
|
1197
|
+
|
|
1186
1198
|
## A decision the player must not make, if your game has one
|
|
1187
1199
|
|
|
1188
1200
|
Not on the list above either: most games trust the browser with everything they do and lose nothing
|
package/package.json
CHANGED
package/schema/bridge.v1.json
CHANGED
|
@@ -2604,6 +2604,66 @@
|
|
|
2604
2604
|
}
|
|
2605
2605
|
},
|
|
2606
2606
|
|
|
2607
|
+
{
|
|
2608
|
+
"type": "gem.vibrate",
|
|
2609
|
+
"direction": "frameToParent",
|
|
2610
|
+
"transport": "port",
|
|
2611
|
+
"$comment": [
|
|
2612
|
+
"Ask the parent to play a vibration pattern on the player's device, where",
|
|
2613
|
+
"the frame cannot: `navigator.vibrate` is undefined in every WebKit (so in",
|
|
2614
|
+
"every iOS browser and the iOS app's WKWebView) and in Firefox 129 and",
|
|
2615
|
+
"later. Gated by the `vibration` capability: sent only to a parent that",
|
|
2616
|
+
"advertised it.",
|
|
2617
|
+
"",
|
|
2618
|
+
"THE SAME VALUE `navigator.vibrate` WOULD HAVE BEEN GIVEN. `pattern` is the",
|
|
2619
|
+
"Vibration API's pattern, already validated and normalized by the SDK:",
|
|
2620
|
+
"alternating on and off durations in milliseconds, starting with on, with",
|
|
2621
|
+
"no trailing pause. An empty array CANCELS a running pattern. A new",
|
|
2622
|
+
"message replaces whatever is still playing.",
|
|
2623
|
+
"",
|
|
2624
|
+
"ONE WAY, AND NOTHING ANSWERS IT. No requestId, and no message travels",
|
|
2625
|
+
"back: the game already has its answer (`gem.vibration.vibrate` returns",
|
|
2626
|
+
"whether the message left the frame), and a reply would tell a game the",
|
|
2627
|
+
"state of the parent's own gates.",
|
|
2628
|
+
"",
|
|
2629
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce:",
|
|
2630
|
+
"",
|
|
2631
|
+
" REFUSE A TOTAL OVER 10 000 ms. The sum of every entry, on and off, is",
|
|
2632
|
+
" at most 10 000. The SDK truncates to that before sending; a message",
|
|
2633
|
+
" over it was written by hand, and is refused whole, never clamped.",
|
|
2634
|
+
"",
|
|
2635
|
+
" GATE ON THE FRAME'S OWN ACTIVATION, NOT THE PAGE'S. The player's Play",
|
|
2636
|
+
" click has already activated the parent document before any game runs,",
|
|
2637
|
+
" so the parent's sticky activation proves nothing about the game. Drop",
|
|
2638
|
+
" the message until a tap inside this frame document has activated it.",
|
|
2639
|
+
"",
|
|
2640
|
+
" DROP IT WHILE THE GAME CANNOT BE SEEN: the page hidden, or the game",
|
|
2641
|
+
" paused behind the parent's own menu. Cancel a running pattern on pause",
|
|
2642
|
+
" or hide.",
|
|
2643
|
+
"",
|
|
2644
|
+
" SEND NOTHING BACK. No outcome, no acknowledgement, and no error for a",
|
|
2645
|
+
" refusal - not for an unadvertised type, a malformed or oversized",
|
|
2646
|
+
" pattern, or the rate limit - so a game cannot read the parent's gates",
|
|
2647
|
+
" or its limiter.",
|
|
2648
|
+
"",
|
|
2649
|
+
"Not an Arcade surface: the parent draws nothing and the player sees",
|
|
2650
|
+
"nothing of the platform's. It is a device effect the game would have had",
|
|
2651
|
+
"from `navigator.vibrate` on a Chromium browser."
|
|
2652
|
+
],
|
|
2653
|
+
"fields": {
|
|
2654
|
+
"pattern": {
|
|
2655
|
+
"kind": "array",
|
|
2656
|
+
"maxItems": 99,
|
|
2657
|
+
"$comment": "At least 0 entries (an empty array cancels) and at most 99, Chromium's bound. The total is at most 10 000 ms, which the parent checks: a sum has no field shape.",
|
|
2658
|
+
"items": { "kind": "int", "min": 0, "max": 10000 }
|
|
2659
|
+
}
|
|
2660
|
+
},
|
|
2661
|
+
"rateLimit": {
|
|
2662
|
+
"perMinute": 600,
|
|
2663
|
+
"$comment": "Bounds a BUGGY game - one that vibrates every frame - at ten a second. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK. What bounds the motor is the parent's native throttle and its duty-cycle budget."
|
|
2664
|
+
}
|
|
2665
|
+
},
|
|
2666
|
+
|
|
2607
2667
|
{
|
|
2608
2668
|
"type": "gem.frameStats",
|
|
2609
2669
|
"direction": "frameToParent",
|