@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,748 @@
1
+ # Serverless functions
2
+
3
+ A **function** is a small piece of your own code that the platform runs when your game calls it.
4
+ Your game sends some bytes, the platform hands them to your function, and your function's answer
5
+ comes back. It runs where the player cannot reach it, and nothing of yours has to be running for
6
+ it to answer.
7
+
8
+ Most games need none. A function earns its place when your game needs a decision that a player's
9
+ browser cannot be trusted to make, and nothing the platform already does makes it for you. Read
10
+ section 1 before you write one: two of the commonest reasons to reach for a function are better
11
+ served by something else.
12
+
13
+ ## 1. Do you need one
14
+
15
+ Everything in a player's browser belongs to the player. They can read your code, change it, and
16
+ send the platform whatever they like. A function is somewhere your code runs that they cannot
17
+ change: checking that a finished run was plausible, or granting a reward whose condition is game
18
+ state only your code can judge.
19
+
20
+ It is also how a game **with no dedicated server** pays a platform currency — Gems (`GEMS`) or
21
+ Tickets (`ARCADE_TICKETS`) — or unlocks an achievement. A browser write
22
+ never does either. A function a player calls may, for **that player only**, on a progression you
23
+ opt in. Section 2's
24
+ [Paying the player who called](#paying-the-player-who-called) is the whole of it. Opting in makes
25
+ your function the part of your game that decides what the player earned, with nothing else
26
+ checking it.
27
+
28
+ Two cases look like a function and are not:
29
+
30
+ - **Spending currency, granting an item, opening a loot box.** That arithmetic is a **recipe**, and
31
+ the platform already runs recipes where a modified client cannot change them. See
32
+ [inventory-and-catalogs.md](inventory-and-catalogs.md). A function that has decided a player
33
+ earned something still makes the grant itself an order or a recipe.
34
+ - **A match with live state.** Players moving in a shared world, a simulation that ticks: that is
35
+ a dedicated server, and [server-bundles.md](server-bundles.md) is how you write one. A function
36
+ holds nothing between calls.
37
+
38
+ A function is also the smaller answer to a need [room-shapes.md](room-shapes.md#authoritative-worker)
39
+ meets with an **authoritative worker** room. If you were going to allocate a worker room only to
40
+ run your own check out of the player's reach, a function does the same job with no room, no seat
41
+ and no connection to open, and each call is its own run.
42
+
43
+ ⚠️ **A verdict you return to the browser is advice to the client you do not trust.** If your
44
+ function decides a player earned something and returns `{ "ok": true }`, a modified client can skip
45
+ the call and behave as though it got that answer. Whatever the function decides has to happen
46
+ _inside_ the function: it writes the result through the platform API itself (`call-api`, section
47
+ 2), and the browser only learns what was done.
48
+
49
+ ⚠️ **The input is whatever the caller sent.** The platform vouches for one thing about a call: who
50
+ made it. A score in the input is the player's claim. A function that reads a score from its input
51
+ and writes it back has checked nothing. Check the claim against what the player cannot forge,
52
+ such as the platform's own records (which `call-api` reads) and what your game's rules make
53
+ possible.
54
+
55
+ ## 2. What a function is
56
+
57
+ A function is one WebAssembly **component** per game per channel, built against the platform's
58
+ `gem:functions` world. It exports exactly one function:
59
+
60
+ ```wit
61
+ export handle: func(input: list<u8>) -> result<list<u8>, string>;
62
+ ```
63
+
64
+ - `input` is the request body, byte for byte. What the bytes mean is up to your game and your
65
+ function. JSON is the usual choice, and section 6 explains why it is the easy one.
66
+ - `Ok` bytes are the response body, byte for byte.
67
+ - `Err` text is your own error message. The call fails, and the game reads the text as
68
+ `err.functionMessage` (section 6). Keep `Err` for input your function could not handle at all: a
69
+ verdict your game acts on, such as "too fast", is an answer, so it belongs in `Ok`.
70
+
71
+ **Nothing survives between calls.** Each call gets a fresh instance, and the instance is thrown
72
+ away when `handle` returns. A global you set is gone by the next call. State lives behind the
73
+ platform API.
74
+
75
+ The component imports one interface, `gem:functions/host`, and nothing else. That interface is
76
+ everything a function can reach:
77
+
78
+ | Host call | What it gives you |
79
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------- |
80
+ | `get-context` | The game id, the channel, this call's invocation id, and **who called**: a player by id, or a game server by its server id |
81
+ | `call-api` | One request to the platform API, made **as your game's server**: a method, a `/v1/` path and a JSON body, and nothing else |
82
+ | `download-link` | A save's contents, fetched from the download link the save's record carries |
83
+ | `upload-link` | A save's new contents, sent to the upload link the platform issued for them |
84
+ | `log` | A line kept on the platform under this call's invocation id, cut at 1 KiB |
85
+ | `random-bytes` | Randomness. The function has no other source |
86
+ | `now-ms` | Unix time in milliseconds. The function has no other clock |
87
+
88
+ **`call-api` acts for your game, not for the player who called.** It carries your game's server
89
+ authority whoever made the call. Take the player id from `get-context`, never from the input. The
90
+ input is the caller's claim about who they are; `get-context` is the platform's answer. A route
91
+ that refuses a game-server token refuses `call-api` too. When a **player** made the call, the
92
+ platform also holds every write to that player: the next subsection.
93
+
94
+ ### What a function a player called may change
95
+
96
+ **Only the calling player's own state.** A function a player called may change:
97
+
98
+ - that player's inventory, currencies, items and progressions — dailies and challenges included;
99
+ - that player's **own characters**;
100
+ - that player's saves, settings and showcase values, their async-opponent records, the
101
+ leaderboard runs of that player and their characters, and the analytics events attributed to
102
+ them;
103
+ - transfers **out of** that player's own inventory, to anyone the item's transfer policy allows.
104
+
105
+ **Any write that targets another player, or another player's character, is refused with
106
+ `403 function_foreign_entity_write`, and nothing is written.** It does not matter what the write
107
+ is: an order, a progression `add`, `max`, `reset` or `claim`, a recipe, a save, a setting, a
108
+ leaderboard run, a transfer out of someone else's inventory, a room kick or host transfer naming
109
+ someone else. A batch of runs that names anyone else is refused whole, but an analytics batch
110
+ rejects each event attributed to someone else with the same code and keeps the rest. Reads are not
111
+ writes: the function may still read another player's state where your game server could, and
112
+ simulating an order for them changes nothing.
113
+
114
+ A transfer your function sends out of the caller's inventory is delivered like one the player sent
115
+ themselves, except for one case: if receiving it would pay the recipient platform currency or an
116
+ achievement (a pass item that opens a battle-pass track they are already past, say), it is not
117
+ delivered by the function. It stays `pending` for the recipient to accept or reject.
118
+
119
+ Anything that changes several players at once — settling a match, trading between two players,
120
+ granting a whole room its rewards — is your **game server's** job, or the job of a function your
121
+ server calls, which is not bound to a player.
122
+
123
+ **`log` is not a debugger.** What it writes stays on the platform under the call's invocation id,
124
+ and no command reads it back to you. The invocation id is what you quote when you ask the platform
125
+ team about one call.
126
+
127
+ **A function cannot call a function.** `call-api` to `/v1/functions/invoke` is refused with
128
+ `function_cannot_invoke`.
129
+
130
+ ### Paying the player who called
131
+
132
+ A progression in your game config can pay a platform currency (a `platform_grants` mapping) or
133
+ carry an achievement: [inventory-and-catalogs.md](inventory-and-catalogs.md) §13 and
134
+ [achievements.md](achievements.md). Your dedicated server's write fires them. A function's write
135
+ fires them only when you opt in:
136
+
137
+ - **Opt in per progression, with `function_may_grant: true`.** It is off by default. Without it, a
138
+ function's `progression_add` or `progression_max` still moves the value, but every grant and
139
+ achievement it would have fired reads `suppressed` with `functions_not_opted_in`. Turn it on only
140
+ for a progression whose function checks what it pays for, because turning it on makes that
141
+ check yours (see the ⚠️ below).
142
+ - **A function a player called pays that player.** Its `call-api` writes the opted-in progression
143
+ for the player id `get-context` gave it, and the platform grants and unlocks as it does for your
144
+ server's write: currency on every channel, achievements on `release` only.
145
+ - **It pays nobody else.** It cannot write another player at all
146
+ (`403 function_foreign_entity_write`, the previous subsection), so it cannot pay one, directly or
147
+ through anything your config writes on the order's behalf. A character never earns a platform
148
+ currency or an achievement, whoever writes it — your function may write the caller's own
149
+ characters, and their receipts read `suppressed` with `entity=character`. A transfer is the one
150
+ write that reaches someone else, and it grants them nothing either: one whose delivery would
151
+ grant the recipient platform currency or an achievement is neither delivered nor refused. The
152
+ request answers `200`, and the transfer stays `pending` for the recipient to accept or reject
153
+ (the previous subsection).
154
+ - **Any currency the platform lists.** A function may pay every currency `gemctl platform config`
155
+ lists under `platform_grants` — Gems (`GEMS`) and Tickets (`ARCADE_TICKETS`) alike — so a daily
156
+ that pays Gems needs no dedicated server. (`function_gems_not_enabled` on an older receipt is from
157
+ before functions could pay Gems.)
158
+ - **No guests.** A guest account's call grants and unlocks nothing (`function_invoker_is_guest`).
159
+ - **An achievement unlocks once per player, ever**, whoever writes it.
160
+ - **The receipt names the function.** Every `platform_grant` and `achievement_unlocked` entry your
161
+ function's orders produce carries `principal: "function"`, whatever its outcome.
162
+ - **The platform's limits are the same as for a server.** What one firing may pay, what your game
163
+ may pay a player per window and what all games may pay a player per day all apply, and your game
164
+ declares none of them.
165
+ - **The platform can switch a function's payouts off**, for every game or only yours, and leave
166
+ your dedicated server's payouts running. A grant then reads `suppressed` with the operator's
167
+ reason, and an unlock reads `suppressed` with `functions_disabled`. Read the receipt before
168
+ suspecting your config.
169
+ - **A call from your dedicated server** is not bound to a player. The function acts for your game,
170
+ like the server that called it, and may write and pay whichever player it names, on a
171
+ progression that opts in.
172
+
173
+ ⚠️ **Opting in makes your function the only check on what the player earned.** A player can call
174
+ it as often as their per-minute budget allows, with any input they like, and **every new call may
175
+ grant again**. The platform's limits cap the damage per player per day. They
176
+ do not decide whether a reward was earned. So:
177
+
178
+ - treat the input as a claim, and check it against what the player cannot forge: the platform's
179
+ own records (read through `call-api`), elapsed time (`now-ms`) and what your game's rules make
180
+ possible;
181
+ - let the progression make a reward grant once. An `on_complete` mapping on a progression with a
182
+ `daily` reset grants once per completion per day, the player's own unless its `boundary` fixes a
183
+ zone, and on one with no reset it grants once, ever.
184
+ `progression_max` grants only an improvement on a best. The platform keeps that state, and the
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;
191
+ - never pass the caller's request straight through to a progression write.
192
+
193
+ ### What `call-api` can reach
194
+
195
+ **A `call-api` request is a method, a `/v1/` path and a JSON body, and nothing else.** It reaches
196
+ the platform API carrying your game's server credential. No header of yours goes with it, and
197
+ nothing goes to any other address. So a route that needs a header from you is out of a function's
198
+ reach, even where the route itself admits your game's server. A save's contents are the one thing
199
+ a function sends or fetches anywhere else, and they have two host calls of their own:
200
+ `download-link` and `upload-link`.
201
+
202
+ That decides the three things a function is most often asked to do.
203
+
204
+ **Saves: the record through `call-api`, the contents through the save links.** A save slot's
205
+ `access_game_server_read` and `access_game_server_write`, both `true` by default, admit a function
206
+ as they admit your dedicated server, with one limit on writes. A function a player called writes
207
+ only that player's saves: starting, finishing or deleting anyone else's is refused with
208
+ `403 function_foreign_entity_write`
209
+ ([What a function a player called may change](#what-a-function-a-player-called-may-change)). A
210
+ function your server called keeps the slot's game-server access for every player of your game.
211
+
212
+ The contents never travel on a `call-api` request. They move between your function and the
213
+ platform's save storage over the same short-lived links a browser game's save uses, and
214
+ `download-link` and `upload-link` are the calls that follow those links:
215
+
216
+ | Step | Request | What it does |
217
+ | ----------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
218
+ | Read the record | `GET /v1/saves/players/{player_id}/{key}` | The save's `size_bytes`, its version (`etag`), its timestamps and `metadata`, and a `download_url` |
219
+ | Read the contents | `download-link` with that `download_url` | The save's bytes, as the answer's `body` |
220
+ | Start a write | `POST /v1/saves/players/{player_id}/{key}/upload-url` | Takes `size_bytes`, `content_type` and, if you like, `metadata`. Answers a `session_id`, an `upload_url` and the `upload_headers` |
221
+ | Send the contents | `upload-link` with that `upload_url` | Sends the bytes, with the same content type and exactly the `upload_headers` it was given |
222
+ | Store them | `POST /v1/saves/players/{player_id}/{key}/finalize` | Takes `{ "session_id": ... }`, stores the save and answers its new record |
223
+ | List | `GET /v1/saves/players/{player_id}` | The player's saves your server may read: their records, with no download links |
224
+ | Delete | `DELETE /v1/saves/players/{player_id}/{key}` | Deletes the save where the slot lets your server write |
225
+
226
+ - **Finish a write in the call that started it.** An upload a function starts lapses within a
227
+ minute, and until it does it counts as one of the five saves a player may have in flight.
228
+ `size_bytes` has to be exactly the number of bytes you send: `finalize` refuses any other with
229
+ `400 validation_error` and throws the bytes away.
230
+ - **At most 8 MiB in one link call**, either way, so a larger save is out of a function's reach
231
+ whatever its slot allows. A link call also counts toward the call's 32 requests and its 5
232
+ seconds, as a `call-api` request does ([The limits on one call](#the-limits-on-one-call)).
233
+ - **Only the platform's own save links.** A link call to any other address, or an upload to a
234
+ download link, is refused before anything is sent, and returns `Err`. A redirect is returned as
235
+ its status and never followed.
236
+ - **What save storage answered is not an `Err`.** Every status comes back as an answer, with
237
+ `status` and `body`. A download's `body` is the save only when `status` is `200`, and an upload
238
+ succeeded when `status` is in the `2xx` range. Only `finalize` makes the save exist.
239
+
240
+ A function writes a save like this. `contents` holds the bytes to store; the request bodies are
241
+ built with `serde_json`, which you add to the crate:
242
+
243
+ ```rust
244
+ use gem::functions::host::{self, ApiRequest, Caller, UploadRequest};
245
+
246
+ let Caller::Player(player_id) = host::get_context().caller else {
247
+ return Err("expected a player's call".into());
248
+ };
249
+ let save = format!("/v1/saves/players/{player_id}/campaign");
250
+ let json = |v: serde_json::Value| v.to_string().into_bytes();
251
+
252
+ let started = host::call_api(&ApiRequest {
253
+ method: "POST".into(),
254
+ path: format!("{save}/upload-url"),
255
+ body: json(serde_json::json!({
256
+ "size_bytes": contents.len(),
257
+ "content_type": "application/octet-stream",
258
+ "metadata": { "level": 7 },
259
+ })),
260
+ })?;
261
+ if started.status != 200 {
262
+ return Err(format!("upload-url answered {}", started.status));
263
+ }
264
+ let grant: serde_json::Value = serde_json::from_slice(&started.body).map_err(|e| e.to_string())?;
265
+ let headers = grant["upload_headers"]
266
+ .as_object()
267
+ .into_iter()
268
+ .flatten()
269
+ .map(|(name, value)| (name.clone(), value.as_str().unwrap_or_default().to_string()))
270
+ .collect();
271
+
272
+ let sent = host::upload_link(&UploadRequest {
273
+ url: grant["upload_url"].as_str().unwrap_or_default().to_string(),
274
+ content_type: "application/octet-stream".into(),
275
+ headers,
276
+ body: contents,
277
+ })?;
278
+ if !(200..300).contains(&sent.status) {
279
+ return Err(format!("the upload answered {}", sent.status));
280
+ }
281
+
282
+ let stored = host::call_api(&ApiRequest {
283
+ method: "POST".into(),
284
+ path: format!("{save}/finalize"),
285
+ body: json(serde_json::json!({ "session_id": grant["session_id"] })),
286
+ })?;
287
+ if stored.status != 200 {
288
+ return Err(format!("finalize answered {}", stored.status));
289
+ }
290
+ ```
291
+
292
+ Reading one back is the record's `GET`, then `host::download_link(url)` with its `download_url`.
293
+
294
+ The metadata is what makes the record worth reading on its own. Your game attaches it when it
295
+ saves, such as `{ "level": 7 }`, and so can your function, so a function can check a claim in its
296
+ input against it with one request and no download.
297
+
298
+ ⚠️ **A save is only as honest as whoever wrote it.** A player writes their own save unless the slot
299
+ declares `access_self_write: false`, so on an ordinary slot its contents and its metadata are the
300
+ player's word too, given earlier rather than in this call. A slot that declares it is one only
301
+ your server and your function write, and the showcase example below declares one that way.
302
+
303
+ **Showcase values: read and write.** A showcase slot, one declared with `visibility: friends` or
304
+ `public`, holds a JSON value of at most 4096 bytes that travels on the request itself. That makes it
305
+ the per-player value a function can both read and write: `GET`, `PUT` and `DELETE` on
306
+ `/v1/showcase/players/{player_id}/{key}`, admitted by the slot's `access_game_server_read` and
307
+ `access_game_server_write`. The same limit holds: a function a player called reads any player's
308
+ value your server may read, but its `PUT` or `DELETE` for any other player is refused with
309
+ `403 function_foreign_entity_write`. A function your server called may write any player's. It is
310
+ not a private save. The slot's visibility shows the value to the player's friends or to every
311
+ player of your game, and the last write wins, whoever made it.
312
+ [Showcase slots](saving-player-progress.md#showcase-slots-a-little-progress-your-friends-can-read)
313
+ covers declaring and reading one.
314
+
315
+ **A value players cannot forge, written by a function.** A best time that friends can see and a
316
+ player cannot write for themselves is a showcase slot only your game's server may write:
317
+
318
+ ```yaml
319
+ save_schemas:
320
+ - key: best_time
321
+ visibility: friends
322
+ access_self_write: false
323
+ access_game_server_write: true
324
+ ```
325
+
326
+ `access_game_server_write` is `true` already; writing it out says what the slot is for. The
327
+ function checks the run, then writes the time for the player who called. The body is the showcase
328
+ write's `{ "value": ... }`:
329
+
330
+ ```rust
331
+ use gem::functions::host::{self, ApiRequest, Caller};
332
+
333
+ // In `handle`, once the run has passed your checks. `finish_ms` is the time to show.
334
+ let Caller::Player(player_id) = host::get_context().caller else {
335
+ return Err("expected a player's call".into());
336
+ };
337
+ let written = host::call_api(&ApiRequest {
338
+ method: "PUT".into(),
339
+ path: format!("/v1/showcase/players/{player_id}/best_time"),
340
+ body: serde_json::json!({ "value": { "ms": finish_ms } }).to_string().into_bytes(),
341
+ })?;
342
+ if written.status != 200 {
343
+ return Err(format!("best_time was not written: {}", written.status));
344
+ }
345
+ ```
346
+
347
+ **Leaderboard scores: `POST /v1/games/{game_id}/runs`.** This is how a function records a
348
+ leaderboard result. It needs no match and no header. The runs go on the boards of the channel the
349
+ function was called on, and no match is recorded against them. A function cannot use the route a
350
+ dedicated server reports its match's runs on, because that route needs the match's allocation
351
+ proof in a request header. This one takes the same body, checks each run the same way and refuses
352
+ with the same codes. It accepts only a function's `call-api`: a dedicated server's own credential
353
+ is refused there with `403 forbidden`.
354
+
355
+ - **Whose runs.** A function a player called records runs only for that player and that player's
356
+ own characters. A batch that names anyone else is refused with
357
+ `403 function_foreign_entity_write`, and nothing in it is written. A function your server called
358
+ may record runs for any player or character of your game.
359
+ - **How many.** Up to 500 runs in one request. Requests to this route share a budget of 60 a
360
+ minute across all the function calls one player makes, and another 60 across all the function
361
+ calls one game server makes.
362
+ - **All or nothing.** If any run in the batch fails its checks, nothing is written, and the answer
363
+ names the first run that failed. A run is stored once per player or character and `run_id`, so a
364
+ batch sent again with the same ids records nothing twice, and the answer counts them in
365
+ `duplicates`.
366
+
367
+ The body is a batch of runs. For the `race` run type that
368
+ [leaderboards.md](leaderboards.md#1-declare-the-board-in-your-game-config) declares, whose
369
+ `int_value_1_name` is `score`:
370
+
371
+ ```json
372
+ {
373
+ "runs": [
374
+ {
375
+ "run_id": "5b0d3c1e-8f7a-4c2e-9d61-0a4b7e2f9c13",
376
+ "entity_type": "player",
377
+ "entity_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
378
+ "run_type": "race",
379
+ "int_value_1": 3100
380
+ }
381
+ ]
382
+ }
383
+ ```
384
+
385
+ The path's `{game_id}` is the game id from `get-context`, `entity_id` is the player id it gives
386
+ you, and `run_id` is a UUID you choose. A run may also carry `occurred_at`, in Unix seconds, which
387
+ defaults to now. A stored batch answers `200` with `{ "inserted": 1, "duplicates": 0 }`.
388
+
389
+ ⚠️ **A function that records the score its caller sent, unchecked, makes your board forgeable.**
390
+ The platform trusts a function's runs as it trusts your dedicated server's, and it cannot tell a
391
+ checked score from a forwarded one. Check the run against what the player cannot forge (section 1)
392
+ before you record it, or any player can put any score they like on your board.
393
+
394
+ | What you get | What happened | What to do |
395
+ | ----------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------- |
396
+ | `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
+ | `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
+ | `400 too_many_runs` | More than 500 runs in one request | Split the batch |
399
+ | `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
+ | `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
+ | `503 service_unavailable` | The platform could not check the budget, so it refused the request. Nothing was written | Retry shortly |
402
+
403
+ [Where the rows come from](leaderboards.md#where-the-rows-come-from) is how runs reach a board
404
+ from every side, and the rest of that page is how your game reads them back.
405
+
406
+ ⚠️ **A refused request is not an `Err`.** `call-api` returns `Err` only when the request was never
407
+ answered: a request it will not send, such as a path outside `/v1/`, or no answer from the platform.
408
+ A refusal comes back as an answer, with its status and the platform's error body, so check `status`
409
+ on every request your function makes.
410
+
411
+ ### The limits on one call
412
+
413
+ These are the platform's limits, the same for every game, and a game cannot raise its own.
414
+
415
+ | Limit | Value |
416
+ | --------------------------------------------------- | -------------------------------------- |
417
+ | Wall clock | 5 seconds |
418
+ | CPU | 1 second of that 5 |
419
+ | Memory | 64 MiB |
420
+ | `call-api`, `download-link` and `upload-link` calls | 32, all three together |
421
+ | Input | 256 KiB |
422
+ | Output | 1 MiB |
423
+ | Component size | 32 MiB |
424
+ | Calls, per caller per minute | 30 for a player, 120 for a game server |
425
+
426
+ **The per-minute budget belongs to the caller, not to your game.** A player who spends theirs is
427
+ refused and nobody else is affected. Thirty a minute is also why a function call belongs at a
428
+ decision point, such as the end of a run or a claim, and never on a timer or once per frame.
429
+
430
+ ## 3. Writing one
431
+
432
+ The reference toolchain is Rust. gemctl writes the starter crate and wraps what your build
433
+ produces, and the build in between is yours:
434
+
435
+ ```bash
436
+ gemctl function init functions/score-check
437
+ rustup target add wasm32-unknown-unknown # once per machine
438
+ cargo build --release --target wasm32-unknown-unknown --manifest-path functions/score-check/Cargo.toml
439
+ gemctl function package functions/score-check/target/wasm32-unknown-unknown/release/score_check.wasm
440
+ ```
441
+
442
+ **`init`** writes three files into the directory: a `Cargo.toml`, a `lib.rs` whose `handle` echoes
443
+ its input, and `wit/world.wit`, which is the platform's world and the one file you cannot write
444
+ yourself. The package name comes from the directory unless you pass `--name`. It refuses to
445
+ overwrite a file that is already there, naming every one, unless you pass `--force`. Run it inside
446
+ your game's repository and it also adds the crate's `target/` to that repository's root ignore
447
+ file (`.gitignore` for git).
448
+
449
+ **gemctl never runs your build.** `cargo build` is yours, and `init` prints it for you rather than
450
+ running it.
451
+
452
+ **`package`** wraps the **core module** cargo wrote into a **component**, on your machine, with no
453
+ second tool. It writes `score_check.component.wasm` beside the module (or wherever `--out` says),
454
+ then runs the same check as `gemctl function check`, below. A refusal exits 1, and the component is
455
+ written anyway so you can inspect it. It needs no sign-in and no network.
456
+
457
+ What `init` writes for `handle`:
458
+
459
+ ```rust
460
+ wit_bindgen::generate!({
461
+ path: "wit",
462
+ world: "function",
463
+ });
464
+
465
+ use gem::functions::host::{self, Caller, LogLevel};
466
+
467
+ struct Function;
468
+
469
+ impl Guest for Function {
470
+ fn handle(input: Vec<u8>) -> Result<Vec<u8>, String> {
471
+ let ctx = host::get_context();
472
+ let caller = match ctx.caller {
473
+ Caller::Player(id) => format!("player {id}"),
474
+ Caller::GameServer(Some(id)) => format!("game server {id}"),
475
+ Caller::GameServer(None) => "a game server".to_string(),
476
+ };
477
+ host::log(
478
+ LogLevel::Info,
479
+ &format!("invoked by {caller} with {} bytes", input.len()),
480
+ );
481
+
482
+ let mut output = format!("hello {caller}, from {} on {}: ", ctx.game_id, ctx.channel)
483
+ .into_bytes();
484
+ output.extend_from_slice(&input);
485
+ Ok(output)
486
+ }
487
+ }
488
+
489
+ export!(Function);
490
+ ```
491
+
492
+ If you write the crate yourself, keep three settings the starter's `Cargo.toml` carries, because
493
+ each one fails somewhere unhelpful when it is missing: `crate-type = ["cdylib"]` (without it there
494
+ is no wasm module at all), an empty `[workspace]` table (without it a workspace the crate sits
495
+ inside tries to build it for your machine), and a size-tuned release profile (`opt-level = "s"`,
496
+ `lto`, `strip`).
497
+
498
+ ⚠️ **Do not build for `wasm32-wasip2`.** It is the target that makes a component in one step, so it
499
+ is the one a Rust developer reaches for, and the platform refuses what it produces as
500
+ `forbidden_import`: Rust's standard library links `wasi:*` imports into it whether or not your
501
+ function uses them. A WASI adapter (`wasm-tools component new --adapt`) adds the same imports.
502
+ Build for `wasm32-unknown-unknown` and wrap with no adapter, which is what `package` does.
503
+
504
+ **Another toolchain.** `gemctl function init --wit-only <dir>` writes only `wit/world.wit`, for
505
+ your toolchain's own bindings generator. Any language works if it produces a component that
506
+ imports only `gem:functions/host` and exports `handle` with the signature above. Check a finished
507
+ component with `gemctl function check`; wrap a core module that carries the world with
508
+ `gemctl function package`.
509
+
510
+ **What the world rules out.** A function has no filesystem, no network, no environment and no
511
+ clock or randomness of its own: `now-ms` and `random-bytes` are the clock and the entropy, and
512
+ `call-api` is the only way out, apart from `download-link` and `upload-link`, which move a save's
513
+ contents to and from the platform's save storage and nowhere else. A crate that assumes a WASI or JavaScript host (`getrandom` without
514
+ a custom backend, `std::time`, anything that reaches for files or sockets) either fails to build for
515
+ `wasm32-unknown-unknown` or links an import the platform refuses.
516
+
517
+ ### Check before you upload
518
+
519
+ ```bash
520
+ gemctl function check function.wasm
521
+ ```
522
+
523
+ `check` runs the platform's own publish-time validation on your machine: the same rules, in the
524
+ same order. It needs no sign-in, and nothing leaves the machine. A refusal names the reason the
525
+ upload would give, says what to change, and exits 1:
526
+
527
+ | Reason | What it usually is | What to change |
528
+ | ------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------ |
529
+ | `not_a_component` | The core module cargo wrote, not yet wrapped | Run `gemctl function package` on it |
530
+ | `forbidden_import` | A `wasm32-wasip2` build, or a WASI adapter. The import is named | Build for `wasm32-unknown-unknown`, no adapter |
531
+ | `missing_handle` | No `handle` export: the bindings were not generated from the world | Generate them from `wit/world.wit` |
532
+ | `wrong_handle_signature` | A `handle` written by hand | Use the exact signature in section 2 |
533
+ | `too_large` | Over the size limit | Release profile with `opt-level = "s"`, `lto`, `strip` |
534
+
535
+ The backend checks again when you upload, and its verdict is final.
536
+
537
+ ## 4. Publishing: upload, then promote
538
+
539
+ Publishing is two steps, and only the second one changes what anybody calls. The commands act on
540
+ the game your directory is bound to; [getting-started.md](getting-started.md) §1 and §2 cover
541
+ signing in and binding one.
542
+
543
+ ```bash
544
+ gemctl function upload --file function.wasm --version 1.0.0 --description "commit 3f2a9c1"
545
+ gemctl function promote 1.0.0 --channel dev
546
+ ```
547
+
548
+ **`upload` stores a version, and nobody calls it.** It runs `check` first and uploads nothing on a
549
+ refusal; `--dry-run` runs the check and stops. Passing `--channel` promotes the version in the same
550
+ step. Omit it while you iterate.
551
+
552
+ **A version string is used once per game.** It is 1 to 64 letters, digits, `.`, `_` and `-`. To
553
+ change what callers run, upload a new version and promote it. A deleted version keeps its name until
554
+ the backend purges it.
555
+
556
+ **`--description` is the only record of what a version was built from.** Nothing else on a version
557
+ says which source produced it, so put the commit in it (500 characters at most).
558
+
559
+ **`promote` points a channel at a version**, from the next call on. There is no cache to wait for.
560
+ Publishing a function takes the same package permissions on your game that publishing a build does.
561
+
562
+ ⚠️ **`release` is every player, immediately.** `upload --channel release`, `promote --channel
563
+ release` and `unassign --channel release` all refuse without `--yes`:
564
+
565
+ ```bash
566
+ gemctl function promote 1.1.0 --channel release --yes
567
+ ```
568
+
569
+ **Rolling back is promoting the previous version:**
570
+
571
+ ```bash
572
+ gemctl function list # what each channel serves, who promoted it, and when
573
+ gemctl function list --all # every upload, and which channel serves each
574
+ gemctl function promote 1.0.0 --channel dev
575
+ ```
576
+
577
+ `gemctl function view <version>` shows one version in detail, including its SHA-256, so you can
578
+ compare it with a local build. `list --all --include-deleted` adds deleted versions that have not
579
+ been purged yet.
580
+
581
+ `gemctl function unassign --channel dev` clears a channel, so every call to it is refused
582
+ `function_not_published`. **That is not a rollback**: to go back to an earlier version, promote it.
583
+ `gemctl function delete <version> --yes` is refused while any channel serves the version, and
584
+ gemctl cannot undo it.
585
+
586
+ ## 5. Channels
587
+
588
+ Each channel (`dev`, `test` and `release`) serves at most one function version. A call never names
589
+ its channel: the platform reads the channel, and the game, from the caller's token. Your game on
590
+ `dev` calls `dev`'s function, and your released game calls `release`'s.
591
+
592
+ | Channel | Whose calls run it |
593
+ | --------- | ------------------ |
594
+ | `dev` | your team |
595
+ | `test` | your testers |
596
+ | `release` | **every player** |
597
+
598
+ ⚠️ **A function version and a build version are promoted separately, and nothing ties one to the
599
+ other.** Promote a function that expects a new input shape before the build that sends it, and
600
+ every player on that channel sends the old shape to the new function. Promote the build first, and
601
+ the new build calls the old function. A player who loaded your game before a promote is still
602
+ running the build they loaded. So a function has to accept what the previous build sends for at
603
+ least one release: put a version field in the input and branch on it.
604
+
605
+ A dedicated server's token names the channel its match runs on, so your server calls the same
606
+ channel's function as the players it serves.
607
+
608
+ ## 6. Calling it from your game
609
+
610
+ `gem.functions.invoke(input)` calls the function promoted on your game's channel and resolves its
611
+ answer. The call names no game and no channel: both come from the token your game already holds.
612
+
613
+ ```js
614
+ import { GemFunctionError } from '@indiegems/gem-web-sdk/sdk';
615
+
616
+ // `gem` is what createGem returned.
617
+ const result = await gem.functions.invoke({ v: 2, runId, finishMs, moves });
618
+ const { accepted, why } = result.json();
619
+ ```
620
+
621
+ **What your function receives.** Pass a `Uint8Array`, an `ArrayBuffer` or another typed array and
622
+ your function's `input` is those bytes, as they are. Pass anything else and it is sent as its JSON
623
+ text, so parse `input` as JSON in `handle`. A string is a JSON value too: `'ping'` arrives as
624
+ `"ping"`, quotes included. To send text as it is, encode it with `new TextEncoder().encode(text)`.
625
+ A value JSON cannot carry, such as a `BigInt`, is refused before anything is sent.
626
+
627
+ **What comes back** is a result holding your function's `Ok` bytes:
628
+
629
+ - `result.json()` parses them as JSON, and throws if they are not JSON (an empty answer is not);
630
+ - `result.text()` reads them as UTF-8, and throws if they are not UTF-8 rather than changing them;
631
+ - `result.bytes` is the bytes themselves, for a function that answers in binary.
632
+
633
+ **It sends the call once.** The SDK never repeats a function call for you, because your function
634
+ may have granted or written something, and a retry you cannot see is how one grant becomes two.
635
+ Whether repeating it could help is on the error. A repeated call may grant again, and what stops
636
+ it is the progression's own state, or a key the platform decides (section 2).
637
+
638
+ **A refused call throws `GemFunctionError`.** It is a `GemApiError`, so `err.code` is the
639
+ platform's code from section 8, and the error adds what that code needs:
640
+
641
+ - `err.functionMessage` is the text your `handle` returned in `Err`, on `function_error`. Your
642
+ function wrote it and it may repeat what the caller sent, so log it and never render it as markup;
643
+ - `err.failure` says which limit or fault ended the call, on `function_failed`;
644
+ - `err.retryable` is decided by the code. It is `false` for `function_timeout`, because the same
645
+ input runs out of time again, and for `functions_not_configured`, because functions are switched
646
+ off where you called. It is `true` for the refusals that mean the platform could not run the
647
+ call (too busy, unreachable, or rate-limiting the caller), and then `err.retryAfterSeconds` is the
648
+ wait the platform named, when it named one.
649
+
650
+ ```js
651
+ try {
652
+ show((await gem.functions.invoke(claim)).json());
653
+ } catch (err) {
654
+ if (!(err instanceof GemFunctionError)) throw err;
655
+ if (err.code === 'function_error') {
656
+ reportBug(err.functionMessage, err.invocationId); // your handle returned Err
657
+ } else if (err.retryable) {
658
+ retryLater(err.retryAfterSeconds ?? 5); // the function did not run
659
+ } else {
660
+ reportBug(err.code, err.invocationId); // repeating the same call gets the same answer
661
+ }
662
+ }
663
+ ```
664
+
665
+ ⚠️ **A lost connection is `retryable` and tells you nothing about whether your function ran.** The
666
+ call left, and no answer came back. If your function grants or writes, make it safe to call twice
667
+ with the same input, for instance by keying what it writes on an id the caller sends.
668
+
669
+ **The invocation id** is what the platform files one call under: `result.invocationId`, or
670
+ `err.invocationId` on a call that reached your function. A server bundle always reads it. A game
671
+ page cannot: the platform does not expose that response header to pages, so on a page both are
672
+ `undefined`. To trace one call from a page, reproduce it with `gemctl function invoke` (section 7).
673
+
674
+ **From your server bundle.** A dedicated server makes the same call, `gem.functions.invoke`, with
675
+ its game-server token, so your function's `get-context` reports a game server rather than a player.
676
+ A server gets 120 calls a minute because it calls for everyone in its match.
677
+ [server-bundles.md](server-bundles.md) covers how a bundle calls the backend. A bundle that a
678
+ player's browser is hosting holds that player's token instead, so its calls arrive as that player.
679
+
680
+ ## 7. Calling it from the terminal
681
+
682
+ `gemctl function invoke` calls what a channel serves and reports what came back. It is how you
683
+ test a function without a game, and how you see one call from outside it.
684
+
685
+ ```bash
686
+ gemctl function invoke --data '{"v":2,"runId":"a1","finishMs":61234}' # dev's function, as you
687
+ gemctl function invoke --channel test --input request.json # a file, byte for byte
688
+ printf 'ping' | gemctl function invoke --input - # stdin
689
+ gemctl function invoke --caller game-server --server-label probe # as a game server would
690
+ ```
691
+
692
+ It calls `dev` unless you pass `--channel`, and it calls **as you**, through a token scoped to this
693
+ game and channel. `--caller game-server` calls as a game server instead, with the server id
694
+ `--server-label` gives (`gemctl` by default). That token is issued only for `dev` and `test`, and
695
+ only to an Owner, Admin or Contributor on the game. **A function that trusts a server more than a
696
+ player has to be tested both ways.**
697
+
698
+ It reports:
699
+
700
+ - `result`: `returned`, `function_error`, `function_failed`, or the platform's code from section 8;
701
+ - `output`: the function's bytes as text, or `output_base64` when they are not UTF-8;
702
+ - `function_message`: your `Err` text, on a `function_error`;
703
+ - `reason`: on a `function_failed`, which limit or fault ended the call;
704
+ - `round_trip_ms`: measured by gemctl, from sending the call to the last byte back;
705
+ - `invocation_id`: what the platform files the call under. **Keep it.** It is the only handle on
706
+ the platform's side of one call, including whatever the function passed to `log`.
707
+
708
+ Anything but `returned` exits 1.
709
+
710
+ ⚠️ **`invoke` runs the function for real.** Whatever the function does through `call-api`, it
711
+ does, on the channel you called. Stay on `dev` while you develop.
712
+
713
+ If the call is refused before anything is invoked, the problem is your sign-in: `dev` needs you to
714
+ be a developer on the game, `test` needs a grant on it, and `release` needs a game your account may
715
+ play.
716
+
717
+ ## 8. When a call is refused
718
+
719
+ Every answer from a call that reached your function carries an invocation id: success,
720
+ `function_error`, `function_failed`, `function_timeout`, and the three `function_*` `503`s. A call
721
+ refused before that point never became an invocation and carries none. `err.retryable` agrees with
722
+ the last column: it is `true` exactly where this table says to retry.
723
+
724
+ | What you get | What happened | What to do |
725
+ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
726
+ | `422 function_error` | Your `handle` returned `Err`. The text is `err.functionMessage` | Fix your code or the input. Return verdicts in `Ok` |
727
+ | `422 function_failed` | It did not finish. `err.failure` is `trap`, `memory_exceeded`, `cpu_budget_exceeded` or `host_call_limit` | A bug or a limit in the function. Reproduce with the same input |
728
+ | `504 function_timeout` | It ran past the wall clock | Do not retry: the same input takes as long again |
729
+ | `404 function_not_published` | Nothing is promoted on the caller's channel | `gemctl function promote <version> --channel <channel>` |
730
+ | `413 function_input_too_large` | The input is over 256 KiB | Send a request, not a file |
731
+ | `429 rate_limited` | This caller's per-minute budget is spent | Wait `err.retryAfterSeconds` |
732
+ | `503 function_capacity_unavailable` | The platform was too busy to run it. Your function is not known to be broken | Retry after `err.retryAfterSeconds` |
733
+ | `503 function_host_unreachable`, `503 function_host_error` | The platform could not run it. Nothing about your function | Retry shortly. Quote the invocation id if it persists |
734
+ | `503 service_unavailable` | The platform could not check the caller's budget, so it refused the call. The function did not run | Retry shortly |
735
+ | `503 functions_not_configured` | Functions are switched off on the backend you called | Do not retry. Nothing about your function or your game |
736
+ | `403 forbidden` | The call carried an account sign-in rather than a game-scoped or game-server token | Call from your game or its server, or through `gemctl function invoke` |
737
+
738
+ A `trap` is a panic, an `unreachable`, an out-of-bounds access or a stack overflow, and no stack
739
+ trace comes back with it. The other three reasons are the limits in section 2.
740
+
741
+ Publishing has its own refusals, and nothing is stored when one happens:
742
+
743
+ | What you get | What happened | What to do |
744
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
745
+ | `400 component_invalid` | The backend's check refused the component. `details.reason` is one of the five in section 3, or `sha256_mismatch`, `size_mismatch` or `not_uploaded` | The five: fix and rebuild. The other three: upload again |
746
+ | `409 function_version_exists` | That version string is taken, perhaps by a deleted version not yet purged | Choose another version |
747
+ | `409 function_version_in_use` | You tried to delete a version a channel still serves | Promote another version there, or unassign it first |
748
+ | `404 function_version_not_found` | No such version, or it has been deleted | `gemctl function list --all --include-deleted` |