@indiegems/gem-web-sdk 7.9.1 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/changelog.md CHANGED
@@ -16,6 +16,51 @@ Its entries are grouped under up to five headings:
16
16
  - **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
17
17
  - **New capability**: something a game can now do. Nothing changes unless you use it.
18
18
 
19
+ ## 8.1.0
20
+
21
+ Upgrading from 8.0.0. Impact: required.
22
+
23
+ ### Rebuild
24
+
25
+ - The list of messages a frame sends to its parent, the FrameMessage type and the GemCapability type each gained a portrait borders member. Recompiling is enough for most games. If your code handles every FrameMessage or GemCapability member exhaustively, add a case for the new one so it keeps compiling.
26
+
27
+ ### New capability
28
+
29
+ - A game can now ask whether a rewarded placement could pay right now, before it draws its reward button, by calling AdsApi.rewardedStatus with the placement. The answer is a RewardedStatus saying whether the placement is declared, whether it is grantable now, how many grants remain today, when it is next eligible, whether the held limit is reached, and when the allowance resets. Times are in epoch milliseconds. The call never rejects. A failed read, a missing play token or a bad placement resolves as not declared and not grantable, and the read gives up after ten seconds. A placement only counts as grantable when the Arcade you run in supports ads. Failures are reported once per kind through your error handler.
30
+ - A game can now choose which border image from its store page the Arcade paints beside it in portrait by calling Gem.showPortraitBorders with the image's name. The name is a plain string of at most 40 characters, and one that fails the store page's naming rule is refused whole with a console warning. The choice is held until the bridge connects, sent at most once a second with the latest choice winning, and not resent if it matches what was last posted. If the Arcade does not advertise the portraitBorders capability, nothing is sent and nothing is raised.
31
+ - If you embed a game in your own frame, GemFrame.showPortraitBorders lets you send the same border choice, or clear it. It sends only while connected and only to a parent that advertised the portraitBorders capability, and it applies the same name rule as the game side.
32
+ - If you host games, you can now pass onPortraitBorders in your host options to receive the border name a game chose. The new GemPortraitBorders message carries it, and validateGemPortraitBorders checks a message you receive. The host re-checks the name on receipt and drops one that no store page could declare before your callback sees it.
33
+
34
+ ## 8.0.0
35
+
36
+ *Written from the API facts alone; no summary was generated for this release.*
37
+
38
+ Upgrading from 7.9.1. Impact: required.
39
+
40
+ ### Must change
41
+
42
+ - BucketConfig changed its signature.
43
+ - FRAME_TO_PARENT changed its signature.
44
+ - FrameMessage changed its signature.
45
+ - GemCapability changed its signature.
46
+
47
+ ### New capability
48
+
49
+ - FriendChallenge was added.
50
+ - FriendChallengesPage was added.
51
+ - FriendsApi.inbox was added.
52
+ - FriendsApi.launchedChallenge was added.
53
+ - FriendsApi.markAnswered was added.
54
+ - FriendsApiDependencies.launchedChallengeId was added.
55
+ - ListFriendChallengesOptions was added.
56
+ - Gem.vibration was added.
57
+ - GemFrame.requestVibrate was added.
58
+ - VibrationApi was added.
59
+ - GemVibrate was added.
60
+ - HostCallbacks.onVibrate was added.
61
+ - HostOptions.onVibrate was added.
62
+ - validateGemVibrate was added.
63
+
19
64
  ## 7.9.1
20
65
 
21
66
  Upgrading from 7.9.0. Impact: none.
@@ -13,8 +13,8 @@ const { outcome } = await gem.ads.requestAd({
13
13
  startNextLevel();
14
14
  ```
15
15
 
16
- That is the whole surface. The rest of this page is the four things a game gets wrong when nobody
17
- tells it.
16
+ That is the whole surface, apart from one read that tells a rewarded button whether it could pay
17
+ (§3). The rest of this page is the four things a game gets wrong when nobody tells it.
18
18
 
19
19
  ## 1. The answer is usually no, and no is ordinary
20
20
 
@@ -80,6 +80,36 @@ your player is already sitting in, and it is the whole of what changes on the da
80
80
  rebuild. [`inventory-and-catalogs.md` §6](inventory-and-catalogs.md#6-when-to-re-read) is the
81
81
  general form of this.
82
82
 
83
+ **Whether a rewarded button could pay right now.** A rewarded placement is a button the player
84
+ presses, so you need to know whether to draw it. `gem.ads.rewardedStatus(placement)` reads one
85
+ placement's state from **your own** `rewarded_placements` config — its daily cap, cooldown and
86
+ `max_held` — applied to this player's use of it:
87
+
88
+ ```js
89
+ const status = await gem.ads.rewardedStatus('run-banked');
90
+ bankButton.disabled = !status.grantable;
91
+ ```
92
+
93
+ | field | what it says |
94
+ |---|---|
95
+ | `declared` | the config promoted to the channel being played names this placement |
96
+ | `grantable` | pressing the button could pay right now — **not** that an ad will play |
97
+ | `remainingToday` | grants left today under the daily cap; absent when the placement has none |
98
+ | `nextEligibleAt` | when a running cooldown ends, in epoch milliseconds; absent when none runs |
99
+ | `heldLimitReached` | the player already holds the placement's `max_held` — spend one first |
100
+ | `resetsAt` | when today's count resets, the next 00:00 UTC, in epoch milliseconds |
101
+
102
+ **`grantable` is not a promise.** A player can press a `grantable` button and get `no_ad`, for
103
+ reasons your game is not told. Read that `no_ad` like any other (§1) — never guess a reason — and
104
+ re-read your inventory whatever the ask settled as. The status grants nothing and reserves
105
+ nothing.
106
+
107
+ It never rejects, like the rest of `gem.ads`. Without a play token, when the read fails or takes
108
+ longer than ten seconds, or for a placement outside the grammar below, it answers not declared and
109
+ not grantable with no `resetsAt`, and the reason goes to your `onError` handler. It is a read of
110
+ the platform, so call it when you draw the button — on a results or death screen — and not every
111
+ frame, and draw the rest of the screen first rather than holding it on the answer.
112
+
83
113
  ## 4. The Arcade pauses your game for you
84
114
 
85
115
  While a panel is up, your game is paused the same way it is paused when a player switches tabs —
@@ -130,6 +160,12 @@ schedule that does not depend on which branch produced it, so there is nothing i
130
160
  read either. (`shown` is not held back — how long that one takes is how long your player spent
131
161
  looking at a panel.)
132
162
 
163
+ The one thing you can read is your own economy, and never from the answer:
164
+ `gem.ads.rewardedStatus` reports **your own declared** rewarded caps as the platform holds them —
165
+ the platform can hold a placement's daily cap below what you declared, never above — so you can
166
+ tell when a rewarded `no_ad` was your own cap. It never reports anything about the player, the
167
+ platform's spacing between offers, or any limit that spans games.
168
+
133
169
  ## If you do nothing
134
170
 
135
171
  Nothing happens. A game that never calls `gem.ads.requestAd` is never interrupted, and the call is
package/docs/friends.md CHANGED
@@ -11,9 +11,10 @@ for (const friend of page.friends) {
11
11
  }
12
12
  ```
13
13
 
14
- That read, and one ask — [sending a friend a challenge](#friend-challenges), which the Arcade
15
- confirms with the player and sends itself — are the whole surface. The boundary around them is the
16
- thing to understand before you design against it.
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, read exactly as
178
- [your own launch keys](getting-started.md#linking-to-your-game) are: the param keys are
179
- launch-query keys, and every value arrived in a link, so validate it the same way. The Arcade adds
180
- one key of its own, `gemChallenge`, which is the platform's, not yours: never parse it. The SDK
181
- method that reads it ships in a later release.
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 params arrive again. Make answering a challenge idempotent:
185
- a second run of the same challenge is a replay, not a new one.
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
 
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "7.9.1"
53
+ "@indiegems/gem-web-sdk": "8.1.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 will read it for you, and that is the only launch key it will
346
- ever read: the method ships in a later release, and until then a game has nothing to do with it.
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
 
@@ -352,7 +355,8 @@ What reaches the frame:
352
355
  only keys the arcade keeps for itself; it never interprets yours:
353
356
  - Store and play-page switches: `play`, `channel`, `purchase`, and `build`, which pins one
354
357
  of your own builds when you play it from your Builds page.
355
- - Platform switches: `devtools`, `devLocale`.
358
+ - Platform switches: `devtools`, `devLocale`, and `gem_test_ads`, the mode of the arcade's
359
+ rewarded TEST ads.
356
360
  - The arcade's own sign-in return marker: `social`.
357
361
  - Attribution and campaign keys, which the arcade keeps out of every game: `_s`, `utm_*`
358
362
  (every key with that prefix, matched case-sensitively, so UTM_X and utmost are yours),
@@ -365,9 +369,10 @@ What reaches the frame:
365
369
  letter, so the arcade keeps that space for itself.
366
370
 
367
371
  - **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. Do not define a key of that
369
- name, and treat every key beginning with `gem` as reserved: a friend-challenge template refuses
370
- a param named that way.
372
+ opens a friend challenge — but it is the platform's, not yours: read the challenge with
373
+ `gem.friends.launchedChallenge()`, never from the URL. Do not define a key of that name, and
374
+ treat every key beginning with `gem` as reserved: a friend-challenge template refuses a param
375
+ named that way.
371
376
  - **Bounded.** At most 16 parameters. A key is a letter followed by up to 63 letters, digits,
372
377
  `_` or `-`. A value is at most 512 characters with no control or format characters. An entry
373
378
  that breaks a rule is dropped on its own; the rest of the link still arrives.
@@ -8,8 +8,8 @@
8
8
  "documents": [
9
9
  {
10
10
  "file": "achievements.md",
11
- "sha256": "e67bc5d0e6abd9ff842be46fa82bf5117fce74b2eb9d28a3954919ddc8a11c73",
12
- "updated": "2026-10-02T23:09:37Z"
11
+ "sha256": "adc8bfaa769fbb283a53de1190ed85cdf4c1c9753e8cf78335b4595e98f8fad4",
12
+ "updated": "2026-10-06T22:14:30Z"
13
13
  },
14
14
  {
15
15
  "file": "analytics.md",
@@ -33,13 +33,13 @@
33
33
  },
34
34
  {
35
35
  "file": "changelog.md",
36
- "sha256": "8a2e021addc2980433e722ba6ca2c7f5fa81cb8eac921e0bda28e44e3484d09d",
37
- "updated": "2026-10-05T17:22:40Z"
36
+ "sha256": "f13808374edf0988e0aef58b857c82298702a67d450962522a532b4827b74c91",
37
+ "updated": "2026-10-07T16:20:42Z"
38
38
  },
39
39
  {
40
40
  "file": "cross-promotion.md",
41
- "sha256": "3a8121a9c973e4a1bfed076a3226d139f8b4e38a173a06ea9f08000c72bca4b4",
42
- "updated": "2026-09-29T17:32:40Z"
41
+ "sha256": "289cb4826a56b4e7423160e4187af65f4167c80218c2792ca0c01fbd977ecfec",
42
+ "updated": "2026-10-06T18:49:36Z"
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
@@ -58,8 +58,8 @@
58
58
  },
59
59
  {
60
60
  "file": "friends.md",
61
- "sha256": "1c0a427a4e86d4c0e48faad9a80bdef9d528a60485b5aadee18d7759d9051e63",
62
- "updated": "2026-10-01T16:38:53Z"
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": "3c38e5b14c78b70b107d5e5b5499fa9340975d602642a8eb91f2dd969d1b605b",
72
- "updated": "2026-10-05T17:22:40Z"
71
+ "sha256": "222dcf81a2ed6d8fc8ffc37d5f7ef146dcf80c4b76f84fd9b8dd53ea1cbce23b",
72
+ "updated": "2026-10-07T16:20:42Z"
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": "e556b9ef033b0ae611a7d136da3422c8145367939848eda193ab2674087c884f",
117
- "updated": "2026-10-01T18:37:21Z"
116
+ "sha256": "08778cccc7d7a9a26a005a070bb2cc98784752ebec9dfce9873ef3d4e674e1e0",
117
+ "updated": "2026-10-05T16:53:25Z"
118
118
  },
119
119
  {
120
120
  "file": "server-bundles.md",
121
- "sha256": "13f5846ad8ee3d27e83916b8d95b4d3fdfcbeb85f94e095cc0c04e14dc2577b2",
122
- "updated": "2026-10-05T17:22:40Z"
121
+ "sha256": "ffc7ec338f3436f73b16f8ba92a54de93e55bd5e9adbe562647e27b4867e4619",
122
+ "updated": "2026-10-07T16:20:42Z"
123
123
  },
124
124
  {
125
125
  "file": "serverless-functions-js.md",
126
- "sha256": "86b0f9f0b742a662ebb78b1538cbbc399309725faf22f46f613b63b5e0864082",
127
- "updated": "2026-10-02T17:17:29Z"
126
+ "sha256": "8d5bda66dfebd93e80fe97b2f09c0651c26dc83b16827bdae7aa25f198f89e0b",
127
+ "updated": "2026-10-03T00:26:43Z"
128
128
  },
129
129
  {
130
130
  "file": "serverless-functions.md",
131
- "sha256": "c830daf736713744909700bf93ff5ba139b8b5d8ad3c2b4e057ca813fc6179f7",
132
- "updated": "2026-10-02T18:52:17Z"
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": "f44c1fd73678b1b569c59bc94bb55cfa2bdb957d798f5985ea5b3024865ed4a6",
142
- "updated": "2026-10-04T19:51:47Z"
146
+ "sha256": "3d3ed15c93368a03b11c0e4e1924797e2638aff5be264a799a259ee065d2dbfc",
147
+ "updated": "2026-10-06T23:08:59Z"
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
@@ -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/v7.9.1/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v7.9.1/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v8.1.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v8.1.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`, nothing
37
- reads it back to you: the invocation id is what you quote about one call.
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 on the platform under this call's invocation id, cut at 1 KiB |
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
- **`log` is not a debugger.** What it writes stays on the platform under the call's invocation id,
124
- and no command reads it back to you. The invocation id is what you quote when you ask the platform
125
- team about one call.
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 the only handle on
736
- the platform's side of one call, including whatever the function passed to `log`.
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. The other three reasons are the limits in section 2.
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