@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.
- package/README.md +54 -0
- package/dist/game.d.ts +156 -22
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +2 -2
- 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.js +2 -2
- package/dist/index.d.ts +89 -6
- package/dist/sdk.d.ts +156 -22
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +530 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +440 -7
- 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` |
|