@indiegems/gem-web-sdk 7.5.0 → 7.7.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.
@@ -64,8 +64,10 @@ reset_schedule: { type: daily, boundary: UTC, reset_time: '04:00' }
64
64
 
65
65
  - **`boundary`** — `local`, the default, is each player's own time zone. An IANA zone name such as
66
66
  `UTC` or `Asia/Tokyo` puts every player on that one calendar instead, so everybody's day turns
67
- over at the same instant: what a daily leaderboard, or a server that works out "today" for
68
- itself, needs.
67
+ over at the same instant: what a strictly fair daily leaderboard, or a server that works out
68
+ "today" for itself, needs. A daily leaderboard works on `local` too, with players on the same
69
+ date sharing a board — [section 10](#10-what-your-game-can-read-back) has the read, and
70
+ [leaderboards.md](leaderboards.md#one-day-many-time-zones) what the choice costs.
69
71
  - **`reset_time`** — the clock time, `HH:MM` in that zone, at which a period begins. The default is
70
72
  `00:00`; `'04:00'` lets a late-night player finish the day they started. Quote it: some YAML
71
73
  parsers read an unquoted `04:00` as a number.
@@ -563,6 +565,16 @@ server reads the same list for one player with `gem.challenges.list({ playerId }
563
565
  **How the player is doing in THIS period — yes**, through the ordinary progression read
564
566
  (section 3): `currentValue` and `isCompleted` are this period's, and reset with it.
565
567
 
568
+ **How everybody did in THIS period — yes, if you declare a board for it.** A leaderboard can be
569
+ scoped to this challenge's periods: today's standings, which start empty each day and close when
570
+ the day does. Name the challenge on the board's run type and set `per_period` on the board; the
571
+ board then takes this challenge's schedule, boundary and reset time, and
572
+ `gem.leaderboards.get('daily')` answers for the player's current period. It needs no
573
+ `platform_display` and no stored content. When your function or server records the run, send the
574
+ `periodLocalDate` you read here as the run's `period_id`, so a run finished just before midnight
575
+ counts for the day it was played. [A board for each day](leaderboards.md#a-board-for-each-day-period-boards)
576
+ is the page.
577
+
566
578
  **Their per-period history and their streak — no.** There is no history read and no streak read in
567
579
  the SDK. The arcade is what displays those — the marker on your game's card, and the player's own
568
580
  progress, history and streak on platform surfaces. Your game does not have to render any of it.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "7.5.0"
53
+ "@indiegems/gem-web-sdk": "7.7.0"
54
54
  }
55
55
  ```
56
56
 
@@ -157,11 +157,13 @@ your reports arrive with no picture. Two-dimensional canvas games need nothing h
157
157
  covers overriding the capture and attaching your own debug state, which is the part actually
158
158
  worth spending effort on.
159
159
 
160
- **There is no offline mode.** A game must be framed by an arcade page to reach the platform:
161
- unframed it still constructs and runs, but the bridge never connects and anything needing a
162
- token waits forever. To run framed without publishing, open your served build in the Gem
163
- Arcade app (step 6 below), or use the SDK repository's `examples/dev-harness/` — a local
164
- parent page that frames your dev server and completes a real handshake.
160
+ **There is no offline mode.** A game must be framed by a page that speaks the bridge: opened
161
+ at the top level, `createGem` throws `GemUnframedError`, which says how to run it. To run framed
162
+ without publishing, use the Gem dev host, signed in to `gemctl`: `npm run dev` in a project the
163
+ Gem tools scaffolded (`gemctl serve start . --dev`, your vite server with hot reload), or
164
+ `gemctl serve start .` for a build. The Gem Editor's Play frames it too, and the SDK
165
+ repository's `examples/dev-harness/` is a local parent page that frames your dev server. Never
166
+ open the dev server's own address.
165
167
 
166
168
  ## 5. Tell the arcade when you are ready
167
169
 
@@ -28,6 +28,10 @@ response, and their rank among everyone is not the rank most screens want anyway
28
28
  last runs"_. `get()` is for a standings screen. `friends()` is for a standings screen among people
29
29
  the player knows.
30
30
 
31
+ **Want today's standings rather than all-time ones?** That is the same four reads over a board
32
+ declared `per_period` — one board for each day of a daily challenge — and it has its own section:
33
+ [A board for each day](#a-board-for-each-day-period-boards).
34
+
31
35
  `character()` is `mine()` for a character. **Read the leg your server WRITES.** Runs carry the
32
36
  entity that owns them, so if your server reports character-owned runs, `mine()` returns nothing and
33
37
  `character()` is the read you want — and vice versa. That mismatch is the commonest reason one of
@@ -226,6 +230,139 @@ is the second most likely reason a board looks empty.
226
230
  const theirs = await gem.leaderboards.character('time-trial', characterId);
227
231
  ```
228
232
 
233
+ ## A board for each day — period boards
234
+
235
+ Everything above ranks **every run a board has ever been given**. A daily challenge wants something
236
+ else: _today's_ standings, which start empty each day and close when the day does. Declare the board
237
+ `per_period` and it exists once per period of a challenge — one board for each day of a daily
238
+ challenge, each week of a weekly one, each month of a monthly one.
239
+
240
+ You do not give the board a schedule. It takes the challenge's: the same days, the same reset
241
+ time, the same time-zone rule. A board cannot drift from the challenge it belongs to, because it
242
+ has no clock of its own.
243
+
244
+ ### Declare it
245
+
246
+ Three pieces, and the middle one is the link. The run type names the **challenge** its runs belong
247
+ to — the `id` of a progression in the same config with a `reset_schedule` — and the board asks to
248
+ be split by that challenge's periods:
249
+
250
+ ```yaml
251
+ progressions:
252
+ - id: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
253
+ slug: daily-puzzle
254
+ config:
255
+ completion_threshold: 1
256
+ reset_schedule: { type: daily }
257
+
258
+ run_types:
259
+ - id: daily-run
260
+ int_value_1_name: moves
261
+ challenge: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
262
+
263
+ leaderboards:
264
+ - id: daily
265
+ run_type: daily-run
266
+ order_by:
267
+ - column: int_value_1
268
+ direction: asc
269
+ per_period: true
270
+ ```
271
+
272
+ - **If you already have a daily challenge, use its id.** The progression in
273
+ [daily-challenges.md](daily-challenges.md) is the same kind of thing; nothing new is needed.
274
+ - **The challenge does not have to be shown on your game's card.** A progression with a
275
+ `reset_schedule` and nothing else is enough — no `platform_display`, no stored content.
276
+ - **`daily`, `weekly`, `monthly` and a cron expression all work.** A progression with no
277
+ `reset_schedule`, or one that resets on an event, has no periods and is refused at upload.
278
+ - **One set of runs can back two boards.** Leave `per_period` off a second board over the same run
279
+ type and it ranks every run: "today" and "all time" from the same reports.
280
+
281
+ ### Record a run
282
+
283
+ Nothing changes in how a run is reported — your [serverless function](serverless-functions.md) or
284
+ your dedicated server reports it, never the browser. What changes is that the platform files each
285
+ run under a period:
286
+
287
+ - **The platform decides the period, from when it receives the run and the player's own time
288
+ zone.** Not from the device clock, and not from the run's `occurred_at`.
289
+ - **Name the period the run was played for.** Send `period_id` on the run, set to the challenge's
290
+ `periodLocalDate` from `gem.challenges.list()` as the player's game had it. A run that finishes
291
+ seconds before midnight and is checked seconds after still counts for the day it was played,
292
+ for **10 minutes** after that day ends. Leave it out and the run goes to whatever period it
293
+ arrives in — tomorrow's board, for that late run.
294
+ - ⚠️ **A run for a period that is not open for the player is refused, and the whole batch with
295
+ it** — `409 run_period_closed`. That is a day that ended more than ten minutes ago, or one that
296
+ has not started for them. Do not retry it: tell the player the day has closed.
297
+ - ⚠️ **Every run is a row.** A period board ranks every run it is given, so a player who plays
298
+ five times today holds five rows. For one row per player, have your function record one run per
299
+ player per period — `gem.leaderboards.mine('daily')` tells it whether today already has one.
300
+
301
+ ### Read it
302
+
303
+ Every read answers for **one period**, and says which:
304
+
305
+ ```js
306
+ const today = await gem.leaderboards.get('daily');
307
+ const yesterday = await gem.leaderboards.get('daily', { periodsBack: 1 });
308
+
309
+ heading(today.period.localDate, today.period.final ? 'Final' : 'So far');
310
+ ```
311
+
312
+ | Option | Reads |
313
+ | ----------------- | --------------------------------------------------------------------------------- |
314
+ | _(none)_ | the player's current period |
315
+ | `periodsBack: n` | `n` periods before it — yesterday for a daily, last week for a weekly. 0 to 400 |
316
+ | `period: id` | exactly that period, from an earlier result's `period.id` — for a shared link or a history screen |
317
+
318
+ `friends()`, `mine()` and `character()` take the same options. On `mine()` the runs, the personal
319
+ best and the total are that one period's, so `personalBest` is "your best today" and an empty
320
+ result each morning is the ordinary state.
321
+
322
+ The result's `period` is absent on an ordinary board and carries, on a period board:
323
+
324
+ | Field | What it is |
325
+ | -------------------- | ------------------------------------------------------------------------------- |
326
+ | `id` | the board's identity. Pass it back as `period` to read this same board again |
327
+ | `localDate` | the date — the same value the challenge gives as `periodLocalDate`. Absent for a cron challenge |
328
+ | `startAt`, `endsAt` | the **player's own** day, in Unix seconds |
329
+ | `opensAt`, `closesAt` | when the board accepts runs from **anyone**, in Unix seconds |
330
+ | `final` | `true` once no run can still arrive |
331
+
332
+ ⚠️ **Ask the platform which day it is; never work it out.** The player's day is a date on their
333
+ own calendar, in the time zone the platform holds for them. A board id built from the device clock
334
+ — `new Date().toISOString().slice(0, 10)` — names the wrong board for every player whose day is
335
+ not the UTC day, and for the hours either side of midnight for everyone else. Read with no option
336
+ for today, and take `period.id` from the result when you need to name it.
337
+
338
+ ### One day, many time zones
339
+
340
+ On the default `local` boundary each player's day is their own: it begins at their midnight.
341
+ **Players on the same date share one board, whatever their time zone** — the same board the
342
+ challenge's seed gives them all. Three consequences follow, and each one surprises somebody:
343
+
344
+ - **A day's board stays open about 50 hours.** It opens when the date begins in the earliest time
345
+ zone and closes when it ends in the latest. So the standings can change after _your_ day has
346
+ ended, and "you finished 3rd today" shown at the player's midnight can be wrong an hour later.
347
+ Say "so far" until `period.final` is `true`. Two or three days' boards are open at any moment.
348
+ - **Players whose day starts later see the earlier results first.** The puzzle is the same for
349
+ everyone on the date, so a player in a later time zone can see scores — and may be handed
350
+ solutions — before they play. If your board has to be strictly fair, put the challenge on one
351
+ clock for everybody: `reset_schedule: { type: daily, boundary: UTC }`. Everyone's day then turns
352
+ over at the same instant, and the board is open for exactly that day.
353
+ - **Rows carry no time.** `occurredAt` is `0` on a period board that follows each player's own time
354
+ zone: beside the board's date, a run's time would show where its player is. Render nothing for
355
+ `0`. Ranks are unaffected.
356
+
357
+ **Changing time zone does not buy a longer day.** A player's first run on a date fixes when that
358
+ date ends for them. Runs after that moment are refused for the date, even if the player has since
359
+ moved to a time zone where it is still running.
360
+
361
+ **From a serverless function or a dedicated server, name the player.** Their credential carries no
362
+ player, so a period read is answered in UTC unless you pass
363
+ `{ playerId }` — which is not that player's day on a `local` challenge. In the browser the read is
364
+ always for the signed-in player and `playerId` is ignored.
365
+
229
366
  ## Where the rows come from
230
367
 
231
368
  Runs are reported **from the server side** — by a dedicated server during its match, or by your
@@ -262,6 +399,9 @@ a `code`, and testing `err.reason === 'bad_request'` never matches, because a 40
262
399
  | `reason: 'invalid'`, `code: 'invalid_bucket'` | bucket values do not match the board's `bucket_by` | supply exactly the buckets the board declares |
263
400
  | `reason: 'not_found'`, `code: 'leaderboard_not_found'`, on `mine()` / `character()` | **also** the entity is not one you may read — an unknown character, one you do not own, one in another game, or a malformed id | **the same code as the row above**, which is the point: the entity read answers ONE refusal for every reason that depends on the target, deliberately, because a distinguishable answer would tell a caller whether a given player plays this game. Check the entity id as well as the board id — nothing in the response separates them |
264
401
  | `reason: 'forbidden'`, on `mine()` / `character()` / `friends()` | the caller may not act for this entity at all, or (on `friends()`) carries no player identity at all — a game server token, say. Decided from the caller's own claims, not from what was asked for | this one is worth telling apart, and it is not about the target: it will not start working because the player played more. Treat it as a bug in how the call was made, not as an empty board. `get()` never answers this — the global board needs no viewer |
402
+ | `reason: 'invalid'`, `code: 'invalid_period'` | `periodsBack` or `period` asked of a board that is not `per_period`; or a `period` that is not one of the challenge's, or more than 400 periods back | a board that ranks every run has no periods to select: drop the option, or declare the board `per_period`. Otherwise take the id from an earlier result's `period.id` rather than building one |
403
+ | `reason: 'invalid'`, `code: 'period_not_begun'` | the `period` asked for has not begun for this player, even if it has for players in time zones ahead of theirs | read with no option for the player's own current period |
404
+ | `TypeError` (thrown, no request sent) | `periodsBack` out of 0–400, a `period` that is not a period id, or both passed at once | pass one of them, or neither |
265
405
  | `reason: 'invalid'`, `code: 'bad_request'` | `limit` is not an integer in 1–100 | the SDK refuses this locally with a `TypeError` before any request, so seeing it from the server means something else built the URL |
266
406
  | `TypeError` (thrown, no request sent) | `limit` out of range, passed to `mine()` / `character()` | pass an integer 1–100, or omit it for the default 25 |
267
407
 
@@ -13,8 +13,8 @@
13
13
  },
14
14
  {
15
15
  "file": "analytics.md",
16
- "sha256": "4d6b9e5c2cbc47c527c0437329c7b3d7ebc61136a8c384f4e0a640f9fd4ed226",
17
- "updated": "2026-10-02T01:09:38Z"
16
+ "sha256": "57fb07c3d649c2758a35660707d31b05164b363e9a425156978aed394d086339",
17
+ "updated": "2026-10-02T14:04:37Z"
18
18
  },
19
19
  {
20
20
  "file": "arcade-economy.md",
@@ -31,6 +31,11 @@
31
31
  "sha256": "cf54939a0fcee1f5a6e11b9de52b5ed498ec8c373683e92aa5bc6654b12eb0ab",
32
32
  "updated": "2026-10-01T03:49:20Z"
33
33
  },
34
+ {
35
+ "file": "changelog.md",
36
+ "sha256": "02083c9ed1d62baed5a297ca71b602608afa90320afb73a21ff03187da11346f",
37
+ "updated": "2026-10-02T04:41:46Z"
38
+ },
34
39
  {
35
40
  "file": "cross-promotion.md",
36
41
  "sha256": "3a8121a9c973e4a1bfed076a3226d139f8b4e38a173a06ea9f08000c72bca4b4",
@@ -38,8 +43,8 @@
38
43
  },
39
44
  {
40
45
  "file": "daily-challenges.md",
41
- "sha256": "4820868e911cc518a5c37cbd8c31b36a8a2374e280be14f3a6dfa45985685372",
42
- "updated": "2026-10-01T19:59:43Z"
46
+ "sha256": "7ba296ba3056407ca3c8c9757a3351f3a8062a943335ee52b4ee92e1b2721d3b",
47
+ "updated": "2026-10-02T18:52:17Z"
43
48
  },
44
49
  {
45
50
  "file": "dedicated-server-flow.md",
@@ -63,8 +68,8 @@
63
68
  },
64
69
  {
65
70
  "file": "getting-started.md",
66
- "sha256": "678670383df1f4df16253616869d9c2d1aceaf3e2464f7499c6ff50cee70a82b",
67
- "updated": "2026-10-02T01:21:17Z"
71
+ "sha256": "4a3c179022bbc7143b190a27a78c6fac8f7f92d8964b2dc6aec7342b052f40e1",
72
+ "updated": "2026-10-02T19:06:36Z"
68
73
  },
69
74
  {
70
75
  "file": "inventory-and-catalogs.md",
@@ -73,8 +78,8 @@
73
78
  },
74
79
  {
75
80
  "file": "leaderboards.md",
76
- "sha256": "940be101a9a06a6ddad85a1afe18a23e9acca43772d158ff7be70ed0f2f6b28d",
77
- "updated": "2026-09-28T19:46:26Z"
81
+ "sha256": "4f065b915604284ec810aa3c7bc046bfd99f343332a70b35921ecf061503f867",
82
+ "updated": "2026-10-02T18:52:17Z"
78
83
  },
79
84
  {
80
85
  "file": "match-session.md",
@@ -113,13 +118,18 @@
113
118
  },
114
119
  {
115
120
  "file": "server-bundles.md",
116
- "sha256": "abc87cf6bf86bd7c0538a983aa8681460f8c8d82fbee7bb78515ce5f97fb2e3f",
117
- "updated": "2026-10-02T01:21:17Z"
121
+ "sha256": "7d48bdbc026a03c78d26c24bf217fab1aa8df9d358fbe54c64fa53f18aa0d77b",
122
+ "updated": "2026-10-02T19:06:36Z"
123
+ },
124
+ {
125
+ "file": "serverless-functions-js.md",
126
+ "sha256": "86b0f9f0b742a662ebb78b1538cbbc399309725faf22f46f613b63b5e0864082",
127
+ "updated": "2026-10-02T17:17:29Z"
118
128
  },
119
129
  {
120
130
  "file": "serverless-functions.md",
121
- "sha256": "3b61ee955d3bc439c39a7cad9339f8ffe8c0fb584f0a25621b877e185160a45a",
122
- "updated": "2026-10-01T18:13:51Z"
131
+ "sha256": "c830daf736713744909700bf93ff5ba139b8b5d8ad3c2b4e057ca813fc6179f7",
132
+ "updated": "2026-10-02T18:52:17Z"
123
133
  },
124
134
  {
125
135
  "file": "trading-and-gifting.md",
@@ -128,8 +138,8 @@
128
138
  },
129
139
  {
130
140
  "file": "what-a-game-must-do.md",
131
- "sha256": "25ea532aa4f0a2b2f3139fc52516bdc869e3ada921935337462b6655de2b0938",
132
- "updated": "2026-10-02T01:09:38Z"
141
+ "sha256": "a0a2952a2b16b43b0af831ff58ad8fabe47b414f57fccd2cb2d866861a65465e",
142
+ "updated": "2026-10-02T14:04:37Z"
133
143
  }
134
144
  ]
135
145
  }
@@ -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.5.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v7.5.0/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.7.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v7.7.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -0,0 +1,139 @@
1
+ # Serverless functions in JavaScript
2
+
3
+ <!-- gem-web: unpublished — the platform does not run JavaScript functions yet -->
4
+
5
+ A serverless function can be written in JavaScript or TypeScript instead of Rust: the language your
6
+ game is already written in, so the verification code in your browser game moves into a function
7
+ with no change beyond bundling. You upload one bundled ES module, and the platform runs it on a
8
+ JavaScript engine of its own.
9
+
10
+ Everything [serverless-functions.md](serverless-functions.md) says about what a function is for,
11
+ what it may change, its limits, channels, and calling it from your game holds here unchanged. This
12
+ page covers only what is different: how you write one, how the SDK behaves inside it, and how
13
+ publishing checks it.
14
+
15
+ ## 1. The contract
16
+
17
+ The same as a component's: bytes in, bytes out, one fresh instance per call, nothing kept between
18
+ calls. Your module exports **exactly one** of two functions, and which one decides how the bytes
19
+ are read and written:
20
+
21
+ | Export | Input | Return |
22
+ | ------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
23
+ | `handle(input)` | `Uint8Array`: the request body, byte for byte | `Uint8Array`, `ArrayBuffer` or `string` (sent as UTF-8); anything else is an error |
24
+ | `handleJson(input)` | the body parsed as JSON; an empty body is `undefined` | any JSON-serializable value, sent as its JSON text; `undefined` is sent as `null`; a `BigInt` is an error |
25
+
26
+ Either may be `async`; the platform waits for the promise. Exporting neither, or both, is refused
27
+ when you upload. A caller cannot tell which kind of function answered: `gem.functions.invoke`, the
28
+ limits and the refusals in serverless-functions.md are identical for both.
29
+
30
+ **A thrown exception is `Err`.** The call fails with `function_error`, and `err.functionMessage` is
31
+ the exception's `name: message` (a thrown value that is not an `Error` is its `String()`). The
32
+ message and the stack go to the platform's log for the invocation, never to the caller. A
33
+ `handleJson` whose input is not JSON, or whose return value JSON cannot carry, fails the same way.
34
+
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.
38
+
39
+ ## 2. Writing one
40
+
41
+ ```bash
42
+ gemctl function init --lang js functions/score-check
43
+ cd functions/score-check && npm install && npm run build
44
+ gemctl function upload dist/function.js
45
+ ```
46
+
47
+ **`init --lang js`** writes a `package.json` whose `build` script type-checks and then bundles to
48
+ one ES module, a TypeScript starter handler and a `tsconfig.json`. gemctl runs neither step:
49
+ `npm run build` is yours, the way `cargo build` is for a component.
50
+
51
+ A handler looks like this:
52
+
53
+ ```ts
54
+ import { createGem } from '@indiegems/gem-web-sdk/function';
55
+
56
+ export async function handleJson(claim: { level: number }) {
57
+ const gem = createGem();
58
+ console.info(`level ${claim.level} claimed by ${gem.identity?.playerId ?? 'a game server'}`);
59
+ // Decide from what the player cannot forge, and make any write through `gem`
60
+ // here, before answering: the answer itself is only advice to the browser.
61
+ return { accepted: false };
62
+ }
63
+ ```
64
+
65
+ The package is the one your game already depends on, installed as
66
+ [getting-started.md](getting-started.md) shows. What makes a check worth running is
67
+ serverless-functions.md section 1, and it applies unchanged: a save or a setting the player's own
68
+ browser can write is the player's claim, not evidence.
69
+
70
+ **Imports.** The module may import the SDK and nothing else, under any of the three names a game
71
+ already uses: `@indiegems/gem-web-sdk/function`, `@indiegems/gem-web-sdk/sdk`, or
72
+ `@indiegems/gem-web-sdk/game`. Inside a function all three are the same module, the function
73
+ entry, so shared code keeps its import. A name that module does not export, such as
74
+ `defineServer`, fails every call with `function_error`. Everything else your code needs is bundled
75
+ in, which is what the starter's build does. An `import()` at runtime is refused at upload.
76
+ Top-level `await` is allowed and runs on every call.
77
+
78
+ **What the engine gives you.** `Date`, `performance`, `Math.random`, `crypto.getRandomValues`,
79
+ `crypto.randomUUID`, `TextEncoder`, `URL` and `fetch` all work. `fetch` reaches only the platform
80
+ API, through the SDK, and the save links the platform hands you; any other URL is refused. A timer
81
+ waits at most 10 ms: a function has no reason to sleep, and a long one would run out the wall
82
+ clock.
83
+
84
+ **The SDK version your function runs is the one the platform bundles**, not the one in your
85
+ `package.json`; the starter pins the matching version. A method added in a newer SDK reaches a
86
+ function when the platform's engine picks that version up.
87
+
88
+ ## 3. The SDK inside a function
89
+
90
+ `createGem()` from `@indiegems/gem-web-sdk/function` is synchronous, `awaitBridge()` and
91
+ `awaitIdentity()` resolve at once, and `gem.me` is the player who called. So a game's boot sequence
92
+ runs unchanged, and the same `gem.me.saves.get`, `gem.leaderboards.get` and `gem.profiles.of` calls
93
+ work, with the same types and the same errors. The function's game and channel come from the call;
94
+ nothing you write names them. `gem.context` is who called, for which game and channel, and
95
+ `gem.identity` is the same three fields a browser game reads from its own `gem.identity`.
96
+
97
+ What a browser game has that is backed by the page or the arcade (ads, purchases, rooms, device
98
+ settings, sharing) is not on `gem` inside a function. Four more differences, each following from
99
+ how a function reaches the platform:
100
+
101
+ - **`gem.me` is the invoker.** When a game server invoked the function there is no player, and
102
+ reading `gem.me` or `gem.transfers` throws saying so; `gem.identity` is `null`. Which writes a
103
+ player-invoked function may make is unchanged (serverless-functions.md section 2).
104
+ - **No conditional requests, and no retries.** The platform call a function makes carries no
105
+ headers, so an `ifMatch` or `ifNoneMatch` cannot travel. A call carrying one is refused with
106
+ `TransportRefusal` naming the option, rather than sent as an overwrite that checks nothing.
107
+ Nothing is retried either: a retry is another platform call and a wait is your function's wall
108
+ clock, so your code decides.
109
+ - **`gem.functions` is absent.** A function may not invoke a function.
110
+ - **`gem.gameConfig` is absent.** The config document is not reachable from a function, so
111
+ `gem.me.currencies.get()` takes a currency id rather than a slug.
112
+
113
+ `gem.friends.challenge()` answers `unavailable` without asking anything, because there is nobody in
114
+ front of a screen to confirm it, and `gem.platform.earnAllowance.on()` never fires.
115
+
116
+ ⚠️ **Flush analytics before you return.** A page flushes on a timer and as it closes; a function
117
+ ends the moment your handler settles, so `await gem.analytics.flush()` before you return, or the
118
+ events may be lost.
119
+
120
+ ## 4. Check before you upload
121
+
122
+ ```bash
123
+ gemctl function check dist/function.js
124
+ ```
125
+
126
+ `check` runs the platform's publish-time validation for JavaScript on your machine, and an upload
127
+ answers `400 source_invalid` with the same `details.reason`:
128
+
129
+ | Reason | What it usually is | What to change |
130
+ | ------------------ | ------------------------------------------------------------------------- | ------------------------------------------------- |
131
+ | `not_a_module` | A parse error, with its line and column, or a file that is not UTF-8 | Fix the syntax; bundle to ES2022 |
132
+ | `forbidden_import` | An `import` or `export … from` of anything but the SDK, or an `import()` | Bundle the dependency in; drop the dynamic import |
133
+ | `missing_handle` | Neither `handle` nor `handleJson` is exported (`export *` does not count) | Export one of them from the module itself |
134
+ | `ambiguous_handle` | Both are exported | Keep one |
135
+ | `too_large` | Over the size limit, the same one a component has | Bundle less; check what your build pulled in |
136
+
137
+ An upload also answers `source_invalid`, rather than `component_invalid`, for `not_uploaded`,
138
+ `size_mismatch` and `sha256_mismatch`: upload again. Promoting, channels and invoking are the same
139
+ as for a component (serverless-functions.md sections 4 to 7).
@@ -183,11 +183,15 @@ do not decide whether a reward was earned. So:
183
183
  zone, and on one with no reset it grants once, ever.
184
184
  `progression_max` grants only an improvement on a best. The platform keeps that state, and the
185
185
  input cannot reset it;
186
- - if you key a paying order with a `nonce` instead, build the key from something the platform
187
- decides: the UTC date from `now-ms` (`daily:2026-09-26`), or an id the platform issued, such as
188
- a match it ran. **Never use an id from the call's input.** A run id your client made up is a new
189
- id on every call, so it stops nothing. A nonce is also only remembered for 72 hours, so it
190
- guards a period, not a lifetime;
186
+ - if you key the order that grants with a `nonce` instead, build the key from something the
187
+ platform decides: the date of the player's daily, or an id the platform issued, such as a match
188
+ it ran. That date is `period_local_date` on your
189
+ [daily challenge](daily-challenges.md#the-one-call-that-answers-both), read for the caller with
190
+ `GET /v1/challenges?player_id=<their id>` (`daily:2026-09-26`), and it is the player's own day
191
+ unless the challenge's `boundary` fixes a zone. The UTC date from `now-ms` names a UTC day,
192
+ which for a player off UTC is not the day their daily runs on. **Never use an id from the call's
193
+ input.** A run id your client made up is a new id on every call, so it stops nothing. A nonce is
194
+ also only remembered for 72 hours, so it guards a day, not a week or a lifetime;
191
195
  - never pass the caller's request straight through to a progression write.
192
196
 
193
197
  ### What `call-api` can reach
@@ -386,6 +390,31 @@ The path's `{game_id}` is the game id from `get-context`, `entity_id` is the pla
386
390
  you, and `run_id` is a UUID you choose. A run may also carry `occurred_at`, in Unix seconds, which
387
391
  defaults to now. A stored batch answers `200` with `{ "inserted": 1, "duplicates": 0 }`.
388
392
 
393
+ **A run for a daily board names its day.** When the run type belongs to a challenge — its config
394
+ names one with `challenge`, which is what a
395
+ [`per_period` board](leaderboards.md#a-board-for-each-day-period-boards) ranks — the platform
396
+ files the run under one period of that challenge. It decides which from when the run arrives and
397
+ the player's own time zone, never from `occurred_at`. Add `"period_id"` to the run, set to the
398
+ `periodLocalDate` the player's game read from `gem.challenges.list()` and sent you with the result:
399
+
400
+ ```json
401
+ {
402
+ "run_id": "5b0d3c1e-8f7a-4c2e-9d61-0a4b7e2f9c13",
403
+ "entity_type": "player",
404
+ "entity_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
405
+ "run_type": "daily-run",
406
+ "int_value_1": 14,
407
+ "period_id": "2026-10-02"
408
+ }
409
+ ```
410
+
411
+ A run that names the day it was played for still counts for that day when it arrives up to 10
412
+ minutes after the day ended for the player. Without `period_id` the run goes to the period it
413
+ arrives in. ⚠️ **`period_id` is a claim the platform checks, not a choice it obeys**: a client
414
+ cannot use it to post to yesterday's board, or to a day that has not begun for the player. For a
415
+ cron challenge the id is the period's start, as `2026-10-02T06:00:00Z`. Send no `period_id` for a
416
+ run type that names no challenge; it is refused there.
417
+
389
418
  ⚠️ **A function that records the score its caller sent, unchecked, makes your board forgeable.**
390
419
  The platform trusts a function's runs as it trusts your dedicated server's, and it cannot tell a
391
420
  checked score from a forwarded one. Check the run against what the player cannot forge (section 1)
@@ -396,6 +425,7 @@ before you record it, or any player can put any score they like on your board.
396
425
  | `400 invalid_run` | A run failed its run type's checks. `details` names it by `index` and `run_id` | Fix that run and send the batch again |
397
426
  | `400 unknown_run_type` | The run type is not in the active config of the channel the function was called on | Publish the run type on that channel |
398
427
  | `400 too_many_runs` | More than 500 runs in one request | Split the batch |
428
+ | `409 run_period_closed` | A run's challenge period is not open for its player: the day ended more than 10 minutes ago, or has not begun for them. Nothing was written, and `details` names the run by `index` and `run_id` | Do not retry that run. Tell the player the day has closed, and send the rest of the batch without it |
399
429
  | `403 function_foreign_entity_write` | A function a player called named another player, or a character not theirs | Record runs only for the caller and their characters |
400
430
  | `429 rate_limited` | The budget of 60 requests a minute is spent. Nothing was written | Send more runs in each request, and fewer requests |
401
431
  | `503 service_unavailable` | The platform could not check the budget, so it refused the request. Nothing was written | Retry shortly |