@indiegems/gem-web-sdk 7.3.1 → 7.4.1

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.
Files changed (42) hide show
  1. package/README.md +54 -0
  2. package/dist/game.d.ts +156 -22
  3. package/dist/gem-web-sdk.bridge.js +1 -1
  4. package/dist/gem-web-sdk.esm.js +2 -2
  5. package/dist/gem-web-sdk.game.js +2 -2
  6. package/dist/gem-web-sdk.min.js +2 -2
  7. package/dist/gem-web-sdk.sdk.js +2 -2
  8. package/dist/gem-web-sdk.server.js +2 -2
  9. package/dist/index.d.ts +89 -6
  10. package/dist/sdk.d.ts +156 -22
  11. package/docs/achievements.md +232 -0
  12. package/docs/analytics.md +271 -0
  13. package/docs/api/api-reference-game.md +15308 -0
  14. package/docs/api/api-reference-server.md +4397 -0
  15. package/docs/api/reference.json +24130 -0
  16. package/docs/arcade-economy.md +263 -0
  17. package/docs/async-opponents.md +396 -0
  18. package/docs/brand-art.md +334 -0
  19. package/docs/cross-promotion.md +137 -0
  20. package/docs/daily-challenges.md +530 -0
  21. package/docs/dedicated-server-flow.md +879 -0
  22. package/docs/deprecation-policy.md +124 -0
  23. package/docs/friends.md +233 -0
  24. package/docs/game-economy-tutorial.md +326 -0
  25. package/docs/getting-started.md +552 -0
  26. package/docs/inventory-and-catalogs.md +1035 -0
  27. package/docs/leaderboards.md +277 -0
  28. package/docs/manifest.json +135 -0
  29. package/docs/match-session.md +288 -0
  30. package/docs/p2p-and-parties.md +783 -0
  31. package/docs/platform-rules.md +372 -0
  32. package/docs/player-sign-in.md +89 -0
  33. package/docs/room-shapes.md +195 -0
  34. package/docs/rooms-and-matches.md +779 -0
  35. package/docs/saving-player-progress.md +457 -0
  36. package/docs/server-bundles.md +1041 -0
  37. package/docs/serverless-functions.md +748 -0
  38. package/docs/trading-and-gifting.md +98 -0
  39. package/docs/what-a-game-must-do.md +1231 -0
  40. package/package.json +7 -2
  41. package/schema/bridge.v1.json +440 -7
  42. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,277 @@
1
+ # Leaderboards
2
+
3
+ Games do not have to rank anything. This is a capability, not an obligation — but if your game
4
+ has a score, a fastest time, or a longest streak worth comparing between players, the platform
5
+ can hold the standings and your game can read them with the token your frame already holds.
6
+
7
+ A leaderboard here is **read-only from the browser, by design**. Your game reads standings and
8
+ renders them; it cannot write a score, because a browser credential is public and a
9
+ client-asserted score would be forgeable. Where the rows actually come from is covered
10
+ [below](#where-the-rows-come-from) — read that section before you design around this feature,
11
+ because it decides whether your game can use it at all today.
12
+
13
+ ## Which read do you want
14
+
15
+ One declaration, four reads. Picking the wrong one is the commonest mistake here, and the
16
+ tempting shortcut — read the global board and filter it client-side for the player — is the wrong
17
+ answer twice over: `maxSize` caps the board, so a player outside the cut simply is not in the
18
+ response, and their rank among everyone is not the rank most screens want anyway.
19
+
20
+ | Read | Ranks | `rank` means | Freshness |
21
+ | --------------------------------------------- | ------------------------------ | ---------------------------------- | ---------------------------------------- |
22
+ | `gem.leaderboards.get(id)` | everyone, capped at `maxSize` | position on the whole board | cached; poll at `updateFrequencySeconds` |
23
+ | `gem.leaderboards.friends(id)` | the viewer and their friends | position within that circle | cached, per viewer, same cadence |
24
+ | `gem.leaderboards.mine(id)` | the player, against themselves | **your own** best-to-worst | exact when read — do not poll |
25
+ | `gem.leaderboards.character(id, characterId)` | one character, against itself | **that character's** best-to-worst | exact when read — do not poll |
26
+
27
+ `mine()` is the one to reach for when the question is _"is this a personal best?"_ or _"show my
28
+ last runs"_. `get()` is for a standings screen. `friends()` is for a standings screen among people
29
+ the player knows.
30
+
31
+ `character()` is `mine()` for a character. **Read the leg your server WRITES.** Runs carry the
32
+ entity that owns them, so if your server reports character-owned runs, `mine()` returns nothing and
33
+ `character()` is the read you want — and vice versa. That mismatch is the commonest reason one of
34
+ these boards is unexpectedly empty, and it is silent: both legs answer `200` with `entries: []`.
35
+
36
+ ## Two roles, and they are not the same person
37
+
38
+ The same split as save slots: one half is something you publish, the other is code.
39
+
40
+ | | Who | When |
41
+ | --------------------- | ------------------------ | ----------------------------------- |
42
+ | **Declaring a board** | you, the developer | once, in your game config |
43
+ | **Reading a board** | your game's running code | whenever a standings screen renders |
44
+
45
+ A board your config does not declare does not exist: reading it is refused with `not_found`,
46
+ at the first call and at every retry after it.
47
+
48
+ ## 1. Declare the board, in your game config
49
+
50
+ Two sections of the game config work together. A **run type** names the values a single play
51
+ produces — up to two integers and two floats, each with a friendly name — and a **leaderboard**
52
+ says how to rank runs of that type:
53
+
54
+ ```yaml
55
+ run_types:
56
+ - id: race
57
+ int_value_1_name: score
58
+
59
+ leaderboards:
60
+ - id: top-score
61
+ run_type: race
62
+ order_by:
63
+ - column: int_value_1
64
+ direction: desc
65
+ max_size: 100
66
+ update_frequency_seconds: 600
67
+ ```
68
+
69
+ The friendly name is what your game reads back: a row on this board carries `stats.score`,
70
+ not `stats.int_value_1`. `max_size` caps the board at up to 1000 rows, and
71
+ `update_frequency_seconds` is how often the served board refreshes — more on that below.
72
+
73
+ Three flags decide what a row shows, all declared on the board rather than chosen by the
74
+ reader: `expose_player` (identity — on by default), `expose_stats` (the run's values — on by
75
+ default), and `expose_properties` (the run's freeform properties — off by default).
76
+
77
+ ## 2. Read it, whole
78
+
79
+ ```js
80
+ const board = await gem.leaderboards.get('top-score');
81
+ console.log(`generated ${board.generatedAt}, refreshes every ${board.updateFrequencySeconds}s`);
82
+ for (const row of board.entries) {
83
+ const name = row.displayName ? text.toPlain(row.displayName) : 'Anonymous player';
84
+ render(row.rank, name, row.discriminator, row.stats?.score);
85
+ }
86
+ ```
87
+
88
+ A row carries the identity fields every surface that shows a player carries — the name and tag as
89
+ `PlayerText`, the discriminator, the portrait and badges — and a row with no `playerId` is an
90
+ anonymous player who keeps their rank. [Showing a player](what-a-game-must-do.md#showing-a-player-one-identity-on-every-surface) is how to render them.
91
+
92
+ The whole board arrives in one response, already sorted, at most `max_size` rows. There is no
93
+ paging and nothing further to assemble.
94
+
95
+ **On the CACHED reads — `get` and `friends` — poll at the cadence the response states, not
96
+ faster.** The platform serves a cached rendering and regenerates it at most once per
97
+ `updateFrequencySeconds`; polling faster re-downloads the same rendering. `generatedAt` says when
98
+ the rendering you hold was made.
99
+
100
+ That rule is about these two reads only. The entity reads —
101
+ [`mine()`](#one-players-own-runs-mine) and `character()` — carry no cadence, because they are not
102
+ refreshed on one: they are exact when read, and are called on an event rather than on a timer.
103
+
104
+ Two facts about rows worth designing for:
105
+
106
+ - **A player can hold several rows.** Ranks are per run, arcade-style — the same player's
107
+ second-best run is still on the board if it ranks.
108
+ - **An anonymous row is still a row.** A deleted player's entry keeps its rank — removing it
109
+ would shift everyone below it — and arrives with an `entityId` but no display fields. Render
110
+ it as "anonymous" rather than skipping it, or every rank under it will look wrong.
111
+
112
+ ## Buckets — one declaration, many boards
113
+
114
+ A board that declares `bucket_by` is partitioned: each distinct bucket value is its own board
115
+ with its own ranks. Declare the bucket column on the run type, list its allowed values, and
116
+ name the bucket when you read:
117
+
118
+ ```yaml
119
+ run_types:
120
+ - id: race
121
+ int_value_1_name: score
122
+ bucket_1_name: region
123
+ bucket_1_allowed_values: [na, eu]
124
+
125
+ leaderboards:
126
+ - id: regional
127
+ run_type: race
128
+ order_by:
129
+ - column: int_value_1
130
+ direction: desc
131
+ bucket_by: [bucket_1]
132
+ ```
133
+
134
+ ```js
135
+ const board = await gem.leaderboards.get('regional', { bucket1: 'na' });
136
+ ```
137
+
138
+ A bucketed board owes exactly its declared buckets — omitting one, or supplying one the board
139
+ does not declare, is refused with `invalid`.
140
+
141
+ ## Friends — the same board, restricted to people the player knows
142
+
143
+ Every declared board also answers a **friend view**: `GET /v1/leaderboards/{id}/friends` is the
144
+ same board — same run type, ordering, buckets, expose flags, `max_size` cap and freshness window —
145
+ ranking only the runs owned by the viewer and their accepted friends. The friend set is resolved
146
+ on the backend at generation time, and the re-ranking is the platform's work: your game is handed
147
+ finished rows and joins nothing. Your game _can_ also read the friend list itself now —
148
+ `gem.friends`, covered in [`friends.md`](friends.md) — but that does not replace this view. A
149
+ friend standings screen wants the ranked subset, not a list to filter the global board by, and
150
+ the global board's `max_size` cap means filtering it client-side would silently drop any friend
151
+ ranked below the cutoff.
152
+
153
+ ```js
154
+ const board = await gem.leaderboards.friends('top-score');
155
+ for (const row of board.entries) {
156
+ const name = row.displayName ? text.toPlain(row.displayName) : 'Anonymous player';
157
+ render(row.rank, row.isViewer ? 'you' : name, row.stats?.score);
158
+ }
159
+ ```
160
+
161
+ Four ways a friend view differs from the board it restricts:
162
+
163
+ - **Ranks are positions within the view,** not the run's global rank — a friend circle has its
164
+ own first place.
165
+ - **The viewer's own rows say so.** They carry `isViewer: true` — present even on a board that
166
+ hides identity fields, because the viewer is being told about their own row, nobody else's. On
167
+ `gem.leaderboards.get` the field is **absent**, not `false`: a global board does not answer that
168
+ question at all, and the difference matters if you write `row.isViewer ? 'you' : name`.
169
+ - **It is a player surface only.** A game server token is refused — a server has no friend
170
+ list — and character-owned runs are not ranked here, because friendship is a player-to-player
171
+ relation.
172
+ - **The cache is per viewer,** on the same `update_frequency_seconds` cadence — a new friendship,
173
+ or a friend's new run, appears within one window.
174
+
175
+ A bucketed board buckets its friend view the same way: name the buckets in the query string
176
+ (`/v1/leaderboards/regional/friends?bucket_1=na`), owing exactly the buckets the board declares.
177
+
178
+ ## One player's own runs — `mine()`
179
+
180
+ The two reads above rank a _field_. This one ranks **one entity against itself**: your own runs on
181
+ a board, best first, so `rank: 1` is your personal best. It is the read behind "New personal
182
+ best!", and behind a run-history panel.
183
+
184
+ ⚠️ **Before you write it, read [Where the rows come from](#where-the-rows-come-from) below.** A
185
+ browser cannot report a run, so a game where neither a dedicated server nor a serverless function
186
+ reports runs will get an empty board here forever — and empty is also the correct answer for every
187
+ player's first session, so the two are indistinguishable from the response alone. That is the
188
+ single most likely reason this returns nothing.
189
+
190
+ ```js
191
+ const mine = await gem.leaderboards.mine('time-trial');
192
+ if (mine.personalBest) showBest(mine.personalBest.stats?.finish_ms);
193
+ for (const row of mine.entries) render(row.rank, row.stats?.finish_ms);
194
+ ```
195
+
196
+ **When to call it.** Not on a timer — there is deliberately no `updateFrequencySeconds` on the
197
+ result, because the read is exact when made rather than refreshed on a cadence. Call it once a run
198
+ has been reported. ⚠️ Reporting is your _server's_ job, or your function's, and your client has no
199
+ happens-after edge with a server: a read fired the instant a match ends can legitimately miss the
200
+ run just played. Read it when your server has told your client the run landed, or once the function
201
+ that recorded it has answered.
202
+
203
+ Five things that differ from the reads above:
204
+
205
+ - **`rank` is a position among this entity's own runs**, not a global rank. It is the number most
206
+ games actually want, and the one the global board cannot give you unless the player happens to be
207
+ on it.
208
+ - **`personalBest` is returned outright**, and is the same row as `entries[0]`. Render one or the
209
+ other, not both. It is `null` when the entity has no eligible run.
210
+ - **`totalRuns` counts your eligible runs before `limit`** and after any bucket filter, and is
211
+ capped — past the cap `totalRunsExact` goes `false` and the honest rendering is `` `${totalRuns}+` ``.
212
+ A run missing a value the board sorts on is not on the board and is not counted.
213
+ - **`limit` (1–100, default 25) slices; it does not select.** A read at `limit=5` and a read at the
214
+ default return the same list, cut differently. There is no cursor: history deeper than the cap is
215
+ not reachable here.
216
+ - **`expose_stats: false` means something different here.** On the reads above it hides stats
217
+ entirely; here it narrows them to the columns the board _sorts on_ — the numbers being ranked.
218
+ A run type may name a column no board orders by, and that is where server-authored values live,
219
+ so those stay hidden.
220
+
221
+ **Characters: read the leg your server writes.** Runs carry the entity that owns them, so a game
222
+ whose server reports character-owned runs gets nothing from `mine()`, and vice versa. That mismatch
223
+ is the second most likely reason a board looks empty.
224
+
225
+ ```js
226
+ const theirs = await gem.leaderboards.character('time-trial', characterId);
227
+ ```
228
+
229
+ ## Where the rows come from
230
+
231
+ Runs are reported **from the server side** — by a dedicated server during its match, or by your
232
+ game's serverless function — never from the browser. That is the integrity model: the standings
233
+ are only worth rendering because nothing a player controls can write to them.
234
+
235
+ Two consequences to plan around:
236
+
237
+ - A game whose matches run entirely in the browser (single-player, or peer-to-peer multiplayer)
238
+ reaches a board through a serverless function, which checks a finished run and records it with
239
+ no match and no server of yours:
240
+ [What `call-api` can reach](serverless-functions.md#what-call-api-can-reach). ⚠️ A function
241
+ that records the score its client sent, unchecked, hands players the write this model keeps
242
+ from them. Otherwise its scoring matches need to be dedicated-server matches —
243
+ [`dedicated-server-flow.md`](dedicated-server-flow.md). If what you
244
+ wanted was competition rather than a ranking, [`async-opponents.md`](async-opponents.md) is the
245
+ mode that needs no server of yours: the platform holds a snapshot of one player's run for another
246
+ player's game to play against later.
247
+ - The server-bundle call that reports runs is **not available yet**. The read surface this page
248
+ documents works today, and a board with no runs yet is an ordinary empty board — `entries` is
249
+ `[]`, which your standings screen must render as a designed state, because every new game's
250
+ board starts that way.
251
+
252
+ ## When a read is refused
253
+
254
+ **Branch on `err.code` when the server sent one, and on `err.reason` otherwise.** They are
255
+ different fields with different vocabularies, and mixing them is the easy mistake: `bad_request` is
256
+ a `code`, and testing `err.reason === 'bad_request'` never matches, because a 400 arrives as
257
+ `reason: 'invalid'`.
258
+
259
+ | What you get | What happened | What to do |
260
+ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
261
+ | `reason: 'not_found'`, `code: 'leaderboard_not_found'` | no board with this id in the channel's active config | check the id against your config, and that the config is assigned to the channel you are on |
262
+ | `reason: 'invalid'`, `code: 'invalid_bucket'` | bucket values do not match the board's `bucket_by` | supply exactly the buckets the board declares |
263
+ | `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
+ | `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 |
265
+ | `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
+ | `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
+
268
+ The _target-dependent_ refusals on the entity read are deliberately uninformative and that is the
269
+ feature: "no such board", "no such entity" and "not yours" are one answer. Do not build retry or
270
+ fallback logic that tries to tell those apart — there is nothing there to read. `forbidden` is not
271
+ an exception to that set so much as outside it: it is about the caller rather than the target, so
272
+ it stays distinguishable and is worth its own branch.
273
+
274
+ **A session that cannot be refreshed is not in this table.** The SDK replays a `401` against a
275
+ fresh token and, if that fails too, raises `AuthError` rather than `GemApiError` — a different
276
+ class, and one that carries no `reason` at all. A `catch` written from this table alone falls
277
+ through it.
@@ -0,0 +1,135 @@
1
+ {
2
+ "$comment": [
3
+ "When each published guide in this folder last changed. Written by",
4
+ "`node scripts/docs-manifest.mjs`; `--check` fails when a guide changed and its",
5
+ "entry did not. `updated` is when the change was recorded, not when it shipped."
6
+ ],
7
+ "schema_version": 1,
8
+ "documents": [
9
+ {
10
+ "file": "achievements.md",
11
+ "sha256": "2bb4790db96a48171826a9cb3ea8e517d9128f07e55b1fd71b072a491021a15b",
12
+ "updated": "2026-09-28T01:10:05Z"
13
+ },
14
+ {
15
+ "file": "analytics.md",
16
+ "sha256": "3ca38659d3c7fd9087d0f17d6c0a31659246724c227cf63701e1bd9a058f0df2",
17
+ "updated": "2026-09-16T22:32:35Z"
18
+ },
19
+ {
20
+ "file": "arcade-economy.md",
21
+ "sha256": "7840941e761c1a460b6dc83ed4cec1ae8838161e0b37ec16cb184c66ccee1385",
22
+ "updated": "2026-10-01T19:42:50Z"
23
+ },
24
+ {
25
+ "file": "async-opponents.md",
26
+ "sha256": "9bf14e66a03c4cbdc21208456eabae13fa419ebdfa09db6cac708f21610014d7",
27
+ "updated": "2026-09-28T19:56:41Z"
28
+ },
29
+ {
30
+ "file": "brand-art.md",
31
+ "sha256": "cf54939a0fcee1f5a6e11b9de52b5ed498ec8c373683e92aa5bc6654b12eb0ab",
32
+ "updated": "2026-10-01T03:49:20Z"
33
+ },
34
+ {
35
+ "file": "cross-promotion.md",
36
+ "sha256": "3a8121a9c973e4a1bfed076a3226d139f8b4e38a173a06ea9f08000c72bca4b4",
37
+ "updated": "2026-09-29T17:32:40Z"
38
+ },
39
+ {
40
+ "file": "daily-challenges.md",
41
+ "sha256": "6b3fac0f91442561e45af35ee1213685b007f27782fd40434eb1bf2423de797e",
42
+ "updated": "2026-10-01T16:38:53Z"
43
+ },
44
+ {
45
+ "file": "dedicated-server-flow.md",
46
+ "sha256": "06dfa9341e924628f67d33e8751f523ea27a3bc91eb07f1f52aadc5e2aa62de7",
47
+ "updated": "2026-09-24T19:20:12Z"
48
+ },
49
+ {
50
+ "file": "deprecation-policy.md",
51
+ "sha256": "2923c4af22f266bb545e0218efb3ffb3c7cc7184ea538e431b6b87352c6b9e51",
52
+ "updated": "2026-09-24T16:41:50Z"
53
+ },
54
+ {
55
+ "file": "friends.md",
56
+ "sha256": "1c0a427a4e86d4c0e48faad9a80bdef9d528a60485b5aadee18d7759d9051e63",
57
+ "updated": "2026-10-01T16:38:53Z"
58
+ },
59
+ {
60
+ "file": "game-economy-tutorial.md",
61
+ "sha256": "7c94ec70c9e2490bdef23b92743b48409a3c6e22d9f26aff8c09fd86af84eae1",
62
+ "updated": "2026-10-01T19:42:50Z"
63
+ },
64
+ {
65
+ "file": "getting-started.md",
66
+ "sha256": "00ca91b36945d37bade4954a204b3bcb9af39c142011647220b3c7a4c587d44f",
67
+ "updated": "2026-10-01T19:58:52Z"
68
+ },
69
+ {
70
+ "file": "inventory-and-catalogs.md",
71
+ "sha256": "23de4799808ec3745664eb8c8fd948891921d5077535e27155c7e4ed4b584fd8",
72
+ "updated": "2026-10-01T19:42:50Z"
73
+ },
74
+ {
75
+ "file": "leaderboards.md",
76
+ "sha256": "940be101a9a06a6ddad85a1afe18a23e9acca43772d158ff7be70ed0f2f6b28d",
77
+ "updated": "2026-09-28T19:46:26Z"
78
+ },
79
+ {
80
+ "file": "match-session.md",
81
+ "sha256": "738c54766f5489ba7509f5f31ef1387a2a80ad89f03eaccd52113a18f2765b0d",
82
+ "updated": "2026-09-25T21:03:24Z"
83
+ },
84
+ {
85
+ "file": "p2p-and-parties.md",
86
+ "sha256": "3887bc706d098014cefab84e9b8bd5b964841888c242a7ccb1f799a9b9723fc0",
87
+ "updated": "2026-09-24T01:00:22Z"
88
+ },
89
+ {
90
+ "file": "platform-rules.md",
91
+ "sha256": "269f2d75a717de583af5ca7e0b91655a2d1edcedb5745c6f2d6f8f0be4dcfbdc",
92
+ "updated": "2026-09-27T19:51:18Z"
93
+ },
94
+ {
95
+ "file": "player-sign-in.md",
96
+ "sha256": "df1e39628ccba04b6f7bcd0b90381dfa34a4480330a6af041d9b71bcaefddb5f",
97
+ "updated": "2026-09-17T20:51:57Z"
98
+ },
99
+ {
100
+ "file": "room-shapes.md",
101
+ "sha256": "5aab02d65a7573ac690649b70e5db4fed4f779c3102c35e64a8ced02218410a9",
102
+ "updated": "2026-09-29T23:14:43Z"
103
+ },
104
+ {
105
+ "file": "rooms-and-matches.md",
106
+ "sha256": "d8c397bc3657d7f46dba7ca0af81dcf181148df798dbaced27dfd294889a1da6",
107
+ "updated": "2026-09-25T02:14:45Z"
108
+ },
109
+ {
110
+ "file": "saving-player-progress.md",
111
+ "sha256": "e556b9ef033b0ae611a7d136da3422c8145367939848eda193ab2674087c884f",
112
+ "updated": "2026-10-01T18:37:21Z"
113
+ },
114
+ {
115
+ "file": "server-bundles.md",
116
+ "sha256": "d9a5cac21415179fceb8b5d584e7563820634558f01ebeb4c2aa9ce56dad0139",
117
+ "updated": "2026-10-01T19:58:52Z"
118
+ },
119
+ {
120
+ "file": "serverless-functions.md",
121
+ "sha256": "3b61ee955d3bc439c39a7cad9339f8ffe8c0fb584f0a25621b877e185160a45a",
122
+ "updated": "2026-10-01T18:13:51Z"
123
+ },
124
+ {
125
+ "file": "trading-and-gifting.md",
126
+ "sha256": "d7ee3c85540ab8aae29988c16a76bee3ba03cc66b10d2faaf5cd7210a80a0544",
127
+ "updated": "2026-08-26T03:27:49Z"
128
+ },
129
+ {
130
+ "file": "what-a-game-must-do.md",
131
+ "sha256": "f34156c8e369197a1b7ce010ccd837e5ee38cd20150c3836e9da8f38811b06e6",
132
+ "updated": "2026-10-01T19:03:17Z"
133
+ }
134
+ ]
135
+ }