@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.
- package/CHANGELOG.md +886 -0
- package/README.md +1 -0
- package/dist/function.d.ts +994 -0
- package/dist/game.d.ts +26 -5
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +1 -1
- package/dist/gem-web-sdk.function.js +3 -0
- package/dist/gem-web-sdk.game.js +2 -2
- package/dist/gem-web-sdk.min.js +2 -2
- package/dist/gem-web-sdk.sdk.js +2 -2
- package/dist/gem-web-sdk.server-api.js +1 -1
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/sdk.d.ts +62 -11
- package/dist/server-api.d.ts +33 -6
- package/dist/server.d.ts +36 -6
- package/docs/analytics.md +2 -3
- package/docs/api/api-reference-game.md +392 -41
- package/docs/api/api-reference-server.md +1 -1
- package/docs/api/reference.json +377 -20
- package/docs/changelog.md +886 -0
- package/docs/daily-challenges.md +14 -2
- package/docs/getting-started.md +8 -6
- package/docs/leaderboards.md +140 -0
- package/docs/manifest.json +24 -14
- package/docs/server-bundles.md +2 -2
- package/docs/serverless-functions-js.md +139 -0
- package/docs/serverless-functions.md +35 -5
- package/docs/what-a-game-must-do.md +39 -26
- package/package.json +7 -2
- package/schema/bridge.v1.json +1 -1
package/docs/daily-challenges.md
CHANGED
|
@@ -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
|
|
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.
|
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": "7.
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
package/docs/leaderboards.md
CHANGED
|
@@ -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
|
|
package/docs/manifest.json
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"file": "analytics.md",
|
|
16
|
-
"sha256": "
|
|
17
|
-
"updated": "2026-10-
|
|
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": "
|
|
42
|
-
"updated": "2026-10-
|
|
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": "
|
|
67
|
-
"updated": "2026-10-
|
|
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": "
|
|
77
|
-
"updated": "2026-
|
|
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": "
|
|
117
|
-
"updated": "2026-10-
|
|
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": "
|
|
122
|
-
"updated": "2026-10-
|
|
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": "
|
|
132
|
-
"updated": "2026-10-
|
|
141
|
+
"sha256": "a0a2952a2b16b43b0af831ff58ad8fabe47b414f57fccd2cb2d866861a65465e",
|
|
142
|
+
"updated": "2026-10-02T14:04:37Z"
|
|
133
143
|
}
|
|
134
144
|
]
|
|
135
145
|
}
|
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/v7.
|
|
84
|
-
curl -fsSL https://downloads.gemarcade.com/sdk/v7.
|
|
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
|
|
187
|
-
decides: the
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 |
|