@indiegems/gem-web-sdk 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,14 @@
1
- # Room shapes — the four things a game room is for
1
+ # Room shapes — the three things a game room is for
2
2
 
3
- A game room is one mechanism with four useful shapes. The same browser surface (`gem.rooms` to get
4
- in, `gem.match.connect()` to reach the authority) and the same server entry point (`defineServer`)
5
- serve all four; what differs is the room template and what the hosting server chooses to do with
6
- its time.
3
+ A game room is one mechanism with three useful shapes. The same browser surface (`gem.rooms` to
4
+ get in, `gem.match.connect()` to reach the authority) and the same server entry point
5
+ (`defineServer`) serve all three; what differs is the room template and what the hosting server
6
+ chooses to do with its time.
7
7
 
8
- Only three of the four need a second player. The fourth is for a game that has no multiplayer at
9
- all and still needs something a browser cannot be trusted to do.
8
+ A room is for players who share something live. A game with no real-time match that still needs
9
+ something a browser cannot be trusted to do — a run verified, a score checked before it counts, a
10
+ reward granted — does not need a room or a game server. That is a serverless function:
11
+ [serverless-functions.md](serverless-functions.md).
10
12
 
11
13
  This page is the decision. [rooms-and-matches.md](rooms-and-matches.md) is the mechanism behind
12
14
  it — the room state machine, the event stream, room args, reconnection — and is what to read once
@@ -16,14 +18,12 @@ you know which shape you want.
16
18
 
17
19
  Answer in this order. The first yes wins.
18
20
 
19
- 1. **Does the server need to run a match at all**, or only answer requests from clients who are
20
- not playing each other? Only answer requests → **authoritative worker**.
21
- 2. **Does one player decide when a round starts, and with what settings?** → **custom**.
22
- 3. **Do players arrive and leave continuously, with the world outliving any one of them?** →
21
+ 1. **Does one player decide when a round starts, and with what settings?** → **custom**.
22
+ 2. **Do players arrive and leave continuously, with the world outliving any one of them?** →
23
23
  **hub**.
24
- 4. Otherwise a lobby fills and plays one match → **match**.
24
+ 3. Otherwise a lobby fills and plays one match → **match**.
25
25
 
26
- ## The four
26
+ ## The three
27
27
 
28
28
  ### Match
29
29
 
@@ -53,31 +53,6 @@ A hub is **served** or **unserved**, and `match_type` decides which:
53
53
  Reach for `none` when nothing needs simulating. A lobby that needs a member list and a settings
54
54
  object does not need a server, and a served hub costs one.
55
55
 
56
- ### Authoritative worker
57
-
58
- A room whose server **runs no match**. It accepts no player input, replicates no world state,
59
- simulates nothing. It answers requests from members who are not playing each other, and each
60
- member's session is unrelated to every other member's.
61
-
62
- This is how a browser game reaches an execution context a modified client cannot rewrite. Use it
63
- when something must be decided on your behalf rather than the player's, and the platform has no
64
- built-in rule for it:
65
-
66
- - Confirming an offline, single-player run really was finished, and to the score claimed.
67
- - Granting an award whose condition is game state — level reached, boss defeated, run completed
68
- under a time — that only your game code can evaluate.
69
- - Checking a score before it counts.
70
-
71
- **A game with no multiplayer can need this shape.** The room is not a lobby; it is a way to get
72
- your own code running somewhere the player does not control.
73
-
74
- If a check out of the player's reach is all you want the room for, a serverless function is the
75
- smaller tool: no room, no seat and no connection, one call at a time. See
76
- [serverless-functions.md](serverless-functions.md).
77
-
78
- Two things decide whether you need it at all. See
79
- [what a worker owes you, and what it does not](#what-a-worker-owes-you-and-what-it-does-not).
80
-
81
56
  ### Custom
82
57
 
83
58
  A room one player owns, where a group gathers and plays round after round. The owner picks the
@@ -91,16 +66,16 @@ from a list hard-coded in your client — then the config can add a mode without
91
66
 
92
67
  ## The settings that produce each
93
68
 
94
- | | Match | Hub | Worker | Custom |
95
- | ------------------------ | ----------------- | --------------------- | ----------- | --------------------- |
96
- | `match_type` | `dedicated`/`p2p` | `dedicated` or `none` | `dedicated` | `dedicated`/`p2p` |
97
- | `auto_start_host` | `true` | `true` | `true` | **`false`** |
98
- | `min_players` | the match size | `1` | `1` | `1` |
99
- | `allow_join_in_progress` | for backfill | **`true`** | **`true`** | `false` |
100
- | `show_in_browser` | `false` | `true` | `false` | when public |
101
- | `transports` | the ladder | the ladder | the ladder | the ladder |
102
- | How a player gets in | `quickJoin` | `quickJoin` | `quickJoin` | `create`/`joinByCode` |
103
- | What starts the match | `min_players` | `min_players` | first join | the owner |
69
+ | | Match | Hub | Custom |
70
+ | ------------------------ | ----------------- | --------------------- | --------------------- |
71
+ | `match_type` | `dedicated`/`p2p` | `dedicated` or `none` | `dedicated`/`p2p` |
72
+ | `auto_start_host` | `true` | `true` | **`false`** |
73
+ | `min_players` | the match size | `1` | `1` |
74
+ | `allow_join_in_progress` | for backfill | **`true`** | `false` |
75
+ | `show_in_browser` | `false` | `true` | when public |
76
+ | `transports` | the ladder | the ladder | the ladder |
77
+ | How a player gets in | `quickJoin` | `quickJoin` | `create`/`joinByCode` |
78
+ | What starts the match | `min_players` | `min_players` | the owner |
104
79
 
105
80
  Templates live in your game config under `game_room_templates`, and are uploaded and promoted per
106
81
  channel like the rest of it. `transports` is the same on every shape because it is orthogonal to
@@ -119,8 +94,7 @@ quietly becomes one world per player.
119
94
 
120
95
  ## What the platform decides for you
121
96
 
122
- Four limits bind every shape, and three of the four shapes exist partly to work around one of
123
- them.
97
+ Four limits bind every shape.
124
98
 
125
99
  **Seats belong to the template, not to the room.** A create request carrying its own
126
100
  `max_players` is refused rather than having the field ignored. The template's count is not
@@ -130,7 +104,7 @@ from what one process can carry.
130
104
 
131
105
  **A match has a maximum duration.** `max_match_duration_seconds` defaults to 21600 — six hours —
132
106
  after which the platform ends the match the way any end happens: members get the ordinary
133
- `match_ended` push and the server is released. **Nothing here runs forever.** A hub or a worker is
107
+ `match_ended` push and the server is released. **Nothing here runs forever.** A hub is
134
108
  recycled, not permanent, and has to be written that way: durable state belongs in player save
135
109
  data or your own store, written as it changes rather than at shutdown, and the client re-enters
136
110
  on the room's `matchEnded` event (reason `expired`, `roomReopened: true`) rather than treating it
@@ -150,37 +124,39 @@ server per player.
150
124
  behind a shared fence, so an `auto_start_host` template and a client that also calls start cannot
151
125
  race into two matches.
152
126
 
153
- ## What a worker owes you, and what it does not
154
-
155
- The worker shape is the one most easily got wrong, in both directions.
156
-
157
- **It is not the answer to economy arithmetic.** Spending a currency and granting an item is a
158
- **recipe**, and a recipe is already evaluated on the platform's side where a modified client
159
- cannot change the arithmetic — no server of yours, no allocation, no seat. See
160
- [inventory-and-catalogs.md](inventory-and-catalogs.md). A worker room is for validating game state
161
- the platform knows nothing about. A worker that grants _after_ validating is doing both: decide
162
- here, then let the grant itself be a recipe or an order.
127
+ ## What a server in a room owes you
163
128
 
164
129
  **Its server must check its own roster.** A server bundle's credential is scoped to your game and
165
130
  channel, not to its match, and the room roster is not a boundary the platform enforces on it. A
166
131
  bundle can therefore act on any player of your game, including one who never joined its room.
167
- That is needed by an ordinary match server writing results — and it means a worker that skips its
168
- own membership check is an open grant endpoint wearing a room as decoration. Read the roster and
169
- check the requesting player against it before acting; not against an id the client sent.
170
-
171
- **A result against a daily's board is checked against the platform's copy, never the client's.**
172
- When your studio stores each period's content with the challenge, the worker reads it with
173
- `gem.challenges.list({ playerId })` — for the player who submitted, whose period is their own, for
174
- the current period and, with `periodsBack: 1`, the one before, so a run finished just before the
175
- rollover still verifies — and validates against the entry whose `periodLocalDate` (a cron
176
- challenge's `seed`) and `dataHash` match what the client submitted, never its `periodStartAt`. No
177
- match is a rejection. [daily-challenges.md](daily-challenges.md#verifying-a-result-against-the-content) has
132
+ That is needed by an ordinary match server writing results — and it means a server that acts
133
+ because a client asked it to must read the roster and check the requesting player against it
134
+ first; not against an id the client sent.
135
+
136
+ **It should hold no player state between joins.** Each join is a session, not an identity. A
137
+ returning player may be in a completely different state than when they left, and the process may
138
+ have served hundreds of others in between.
139
+
140
+ ## When you do not need a room
141
+
142
+ A room, and the server behind it, is for a real-time match: the server is the authority, corrects
143
+ what players do, and replicates the state of the game to everyone in it. Such a server can also
144
+ verify results and grant progression and rewards for the match it ran.
145
+
146
+ Everything else that must happen out of the player's reach is a **serverless function**:
147
+ confirming a single-player run really was finished and to the score claimed, checking a score
148
+ before it reaches a leaderboard, anti-cheat, updating progression, granting a reward whose
149
+ condition only your game code can evaluate. A function needs no room, no seat and no connection,
150
+ costs less and scales further. See [serverless-functions.md](serverless-functions.md).
151
+
152
+ Two things are not a function's job either. Spending a currency and granting an item is a
153
+ **recipe**, already evaluated on the platform's side where a modified client cannot change the
154
+ arithmetic — see [inventory-and-catalogs.md](inventory-and-catalogs.md); a function that has
155
+ decided a player earned something still makes the grant itself a recipe or an order. And a result
156
+ against a daily's board is checked against the platform's copy of that period's content, never the
157
+ client's — [daily-challenges.md](daily-challenges.md#verifying-a-result-against-the-content) has
178
158
  the recipe.
179
159
 
180
- **It should hold nothing between joins.** Each join is an unrelated session. A returning player
181
- may be in a completely different state than when they left, and the process may have served
182
- hundreds of others in between.
183
-
184
160
  ## Where to go next
185
161
 
186
162
  - [rooms-and-matches.md](rooms-and-matches.md) — the browser half in full: the room state
@@ -193,3 +169,5 @@ hundreds of others in between.
193
169
  - [p2p-and-parties.md](p2p-and-parties.md) — the shapes that run without a server of ours.
194
170
  - [inventory-and-catalogs.md](inventory-and-catalogs.md) — what a server calls once it has
195
171
  authority.
172
+ - [serverless-functions.md](serverless-functions.md) — your own check out of the player's reach,
173
+ with no room and no game server.
@@ -6,9 +6,8 @@ carrying the engine's model should transfer with no friction, and where the web
6
6
  engine this document says why.
7
7
 
8
8
  > **Which SHAPE of room you want is decided first, on
9
- > [`room-shapes.md`](room-shapes.md)** — a match, a hub players drift through, a custom lobby one
10
- > player owns, or an authoritative worker whose server runs no match at all. This page is the
11
- > mechanism underneath all four.
9
+ > [`room-shapes.md`](room-shapes.md)** — a match, a hub players drift through, or a custom lobby one
10
+ > player owns. This page is the mechanism underneath all three.
12
11
 
13
12
  > **This page is the SDK's model. The platform's own rules are
14
13
  > [`platform-rules.md`](platform-rules.md)** — what gem-api enforces underneath, none of which is
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v8.0.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v8.0.0/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v9.0.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v9.0.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -35,10 +35,11 @@ Two cases look like a function and are not:
35
35
  a dedicated server, and [server-bundles.md](server-bundles.md) is how you write one. A function
36
36
  holds nothing between calls.
37
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.
38
+ A function is the answer whenever your own check has to run out of the player's reach and no
39
+ real-time match is running: verifying a run or a score, anti-cheat, updating progression, granting
40
+ a reward. It needs no room, no seat and no connection to open, and each call is its own run. A game
41
+ with no multiplayer does not need a game server for any of this.
42
+ [room-shapes.md](room-shapes.md#when-you-do-not-need-a-room) draws the line from the other side.
42
43
 
43
44
  ⚠️ **A verdict you return to the browser is advice to the client you do not trust.** If your
44
45
  function decides a player earned something and returns `{ "ok": true }`, a modified client can skip
@@ -440,18 +441,22 @@ on every request your function makes.
440
441
 
441
442
  ### The limits on one call
442
443
 
443
- These are the platform's limits, the same for every game, and a game cannot raise its own.
444
-
445
- | Limit | Value |
446
- | --------------------------------------------------- | -------------------------------------- |
447
- | Wall clock | 5 seconds |
448
- | CPU | 1 second of that 5 |
449
- | Memory | 64 MiB |
450
- | `call-api`, `download-link` and `upload-link` calls | 32, all three together |
451
- | Input | 256 KiB |
452
- | Output | 1 MiB |
453
- | Component size | 32 MiB |
454
- | Calls, per caller per minute | 30 for a player, 120 for a game server |
444
+ These are the platform's limits, the same for every game, and a game cannot raise its own. If your
445
+ game needs more memory, CPU, time or calls than they allow, ask for the limit to be raised: the
446
+ platform raises a function's limits so the game can stay on a function. Reaching one is never a
447
+ reason to move the work to a game server.
448
+
449
+ | Limit | Value |
450
+ | ------------------------------------------------------------------------------ | -------------------------------------- |
451
+ | Wall clock | 5 seconds |
452
+ | CPU | 1 second of that 5 |
453
+ | Memory | 64 MiB |
454
+ | `call-api`, `download-link` and `upload-link` calls | 32, all three together |
455
+ | A value handed to one `log`, `call-api`, `download-link` or `upload-link` call | 16 MiB |
456
+ | Input | 256 KiB |
457
+ | Output | 1 MiB |
458
+ | Component size | 32 MiB |
459
+ | Calls, per caller per minute | 30 for a player, 120 for a game server |
455
460
 
456
461
  **The per-minute budget belongs to the caller, not to your game.** A player who spends theirs is
457
462
  refused and nobody else is affected. Thirty a minute is also why a function call belongs at a
@@ -767,7 +772,9 @@ or `game_server`:
767
772
  - **A line your function logged**: its `level` and its `message`.
768
773
  - **The closing line**: `outcome` is `ok`, `error`, `timeout`, `cpu_exceeded` or `failed`, with the
769
774
  call's `wall_ms` and `cpu_ms`. On `error`, `message` is your `Err` text. On `failed`, `reason`
770
- says what ended the call, and when your own code trapped, `message` is the trap's message.
775
+ says what ended the call, and when your own code trapped, `message` is the trap's message. When
776
+ `reason` is `output_too_large` or `argument_too_large`, `detail` names the limit the call
777
+ exceeded. The platform writes it and never copies your function's data into it.
771
778
 
772
779
  Newest first means a call's closing line comes before what it logged. **A call's logging is
773
780
  capped**: once it reaches its per-call log limit, that `log` call and every later one go
@@ -795,24 +802,27 @@ Every answer from a call that reached your function carries an invocation id: su
795
802
  refused before that point never became an invocation and carries none. `err.retryable` agrees with
796
803
  the last column: it is `true` exactly where this table says to retry.
797
804
 
798
- | What you get | What happened | What to do |
799
- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
800
- | `422 function_error` | Your `handle` returned `Err`. The text is `err.functionMessage` | Fix your code or the input. Return verdicts in `Ok` |
801
- | `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 |
802
- | `504 function_timeout` | It ran past the wall clock | Do not retry: the same input takes as long again |
803
- | `404 function_not_published` | Nothing is promoted on the caller's channel | `gemctl function promote <version> --channel <channel>` |
804
- | `413 function_input_too_large` | The input is over 256 KiB | Send a request, not a file |
805
- | `429 rate_limited` | This caller's per-minute budget is spent | Wait `err.retryAfterSeconds` |
806
- | `503 function_capacity_unavailable` | The platform was too busy to run it. Your function is not known to be broken | Retry after `err.retryAfterSeconds` |
807
- | `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 |
808
- | `503 service_unavailable` | The platform could not check the caller's budget, so it refused the call. The function did not run | Retry shortly |
809
- | `503 functions_not_configured` | Functions are switched off on the backend you called | Do not retry. Nothing about your function or your game |
810
- | `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` |
805
+ | What you get | What happened | What to do |
806
+ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
807
+ | `422 function_error` | Your `handle` returned `Err`. The text is `err.functionMessage` | Fix your code or the input. Return verdicts in `Ok` |
808
+ | `422 function_failed` | It did not finish. `err.failure` is `trap`, `memory_exceeded`, `cpu_budget_exceeded`, `host_call_limit`, `output_too_large` or `argument_too_large` | A bug or a limit in the function. Reproduce with the same input |
809
+ | `504 function_timeout` | It ran past the wall clock | Do not retry: the same input takes as long again |
810
+ | `404 function_not_published` | Nothing is promoted on the caller's channel | `gemctl function promote <version> --channel <channel>` |
811
+ | `413 function_input_too_large` | The input is over 256 KiB | Send a request, not a file |
812
+ | `429 rate_limited` | This caller's per-minute budget is spent | Wait `err.retryAfterSeconds` |
813
+ | `503 function_capacity_unavailable` | The platform was too busy to run it. Your function is not known to be broken | Retry after `err.retryAfterSeconds` |
814
+ | `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 |
815
+ | `503 service_unavailable` | The platform could not check the caller's budget, so it refused the call. The function did not run | Retry shortly |
816
+ | `503 functions_not_configured` | Functions are switched off on the backend you called | Do not retry. Nothing about your function or your game |
817
+ | `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` |
811
818
 
812
819
  A `trap` is a panic, an `unreachable`, an out-of-bounds access or a stack overflow, and no stack
813
820
  trace comes back with it. On `dev`, the call's closing line in `gemctl function logs` carries the
814
- trap's message when your own code trapped (section 7). The other three reasons are the limits in
815
- section 2.
821
+ trap's message when your own code trapped (section 7). The other five reasons are the limits in
822
+ section 2. Two of them are about size: `output_too_large` is a result over the 1 MiB output cap,
823
+ and `argument_too_large` is a value your function handed to `log`, `call-api`, `download-link` or
824
+ `upload-link` that was over the 16 MiB one call accepts. On `dev`, the closing line of either
825
+ carries `detail`, naming the limit the call exceeded.
816
826
 
817
827
  Publishing has its own refusals, and nothing is stored when one happens:
818
828
 
@@ -53,8 +53,9 @@ No `baseUrl`. The Arcade names the backend on every token and the SDK routes the
53
53
  tokens only if its origin is allowed, and the pages that matter are always allowed without a word
54
54
  from you:
55
55
 
56
- - **the Gem Arcade website and app** — the SDK carries their origins as `ARCADE_EMBEDDERS`, on every
57
- platform the app runs on;
56
+ - **the platform's websites (Gem Arcade and Meme Games) and the Gem Arcade app** — the SDK carries
57
+ their origins as `ARCADE_EMBEDDERS`, on every platform the app runs on. Your game is listed on
58
+ one of the websites, but it accepts every origin in that list;
58
59
  - **a dev host on your own machine** — `gemctl serve` and the Gem Editor frame a locally-served game
59
60
  from a loopback origin whose port changes every run, and the SDK admits that shape by rule, only
60
61
  while your game itself is served from loopback. Once published, nothing about the rule applies.
@@ -353,6 +354,117 @@ one refused orientation, in fullscreen, once they are already the right way roun
353
354
  That is best effort (iOS does not implement it, and the native app's webview declines
354
355
  it), which is why the panel exists and why it always offers a way past.
355
356
 
357
+ **The same declaration decides how a computer or a television shows your game.** Neither is held
358
+ two ways round, so the Arcade reads your phone and tablet rows instead and, for a portrait-only
359
+ game, draws a centred portrait column.
360
+ [Art beside a portrait game](#art-beside-a-portrait-game-on-a-wide-screen-portrait-borders) below
361
+ has the exact rule, and the art you can put beside that column.
362
+
363
+ ## Art beside a portrait game on a wide screen: portrait borders
364
+
365
+ **You need this only if your game is portrait-only and you want your own art beside it on a
366
+ computer or a television.** On a desktop browser, the Arcade's desktop app or a TV, the Arcade
367
+ draws a portrait-only game as a centred 9:16 column and fills the space either side with its own
368
+ bars. Portrait borders put your art there instead: you publish a named set of images with your
369
+ store page, and your game says which one goes with what is on screen.
370
+
371
+ ### When the Arcade letterboxes a game
372
+
373
+ **Your store page's `orientations` decide it, and nothing else does.** Declaring borders never
374
+ turns the letterbox on, and there is no desktop orientation to declare. The rule:
375
+
376
+ 1. Take `tablet` if you declared both its `portrait` and its `landscape`; otherwise `phone` if you
377
+ declared both of its; otherwise stop, and the game is not letterboxed.
378
+ 2. The game is letterboxed to portrait when, on that device, `portrait` is `full` or `partial` and
379
+ `landscape` is `none`.
380
+
381
+ | `orientations`, each pair portrait / landscape | Letterboxed on a computer or TV |
382
+ | ---------------------------------------------- | ------------------------------- |
383
+ | nothing declared | no |
384
+ | tablet `full` / `none` | yes |
385
+ | phone `full` / `none`, no tablet | yes |
386
+ | phone `partial` / `none`, no tablet | yes |
387
+ | phone `full` / `none`, tablet `full` / `full` | no — the tablet decides |
388
+ | phone `full` / `none`, tablet `none` / `full` | no — the tablet decides |
389
+ | tablet `none` / `full` | no |
390
+ | tablet `partial` / `partial` | no |
391
+ | tablet `full` / `partial` | no |
392
+
393
+ A phone or a tablet is never letterboxed: it gets the rotate panel from the section above. On a
394
+ computer or TV, a game the rule does not letterbox fills the play area and its borders never show.
395
+ **A store page with borders and orientations that never letterbox uploads with a
396
+ `portrait_borders_unused` warning** — the page is accepted, and the art will not appear anywhere.
397
+
398
+ ### Declaring the borders
399
+
400
+ Add them to your store page's `media`, one entry per image:
401
+
402
+ ```yaml
403
+ media:
404
+ portrait_borders:
405
+ - name: meadow # the first entry is the default
406
+ src: borders/meadow.jpg
407
+ - name: alpine
408
+ src: borders/alpine.jpg
409
+ ```
410
+
411
+ - **Up to 16 entries**, each with a `name` and a `src`. A name is 1 to 40 characters of `a-z`,
412
+ `0-9`, `_` and `-`, starts with a letter or digit, and is unique on the page.
413
+ - **The first entry is the default.** The Arcade shows it the moment play starts, before your game
414
+ has said anything, so put first the one most players should see.
415
+ - **16:9, at least 1920×1080; 2560×1440 is ideal.** PNG, JPEG or WebP. An AVIF, GIF or SVG uploads
416
+ without complaint and then fails the page's processing.
417
+ - **Opaque.** A transparent area shows the Arcade's own bars through it.
418
+ - **Keep the interesting part away from the middle.** The image is scaled to cover the whole play
419
+ area and centred, and your game covers the centre. How much shows either side changes with the
420
+ shape of the player's screen — in a window narrower than the column, the art shows above and
421
+ below instead — so nothing in the art can line up with your game's edge.
422
+ - **Upload with a current `gemctl`.** An older one refuses `portrait_borders` as an unknown key.
423
+
424
+ ### Choosing one from your game
425
+
426
+ Call `gem.showPortraitBorders` at start-up and on every theme change, with the name of the border
427
+ that goes with what is on screen, and `null` for a theme that has no art:
428
+
429
+ ```js
430
+ const BORDERS = new Set(['meadow', 'alpine']); // the names your store page declares
431
+
432
+ function applyTheme(id) {
433
+ document.body.dataset.theme = id;
434
+ gem.showPortraitBorders(BORDERS.has(id) ? id : null); // null: the Arcade's own bars show again
435
+ }
436
+ ```
437
+
438
+ - **Call it unconditionally.** On a phone, or on a screen where your game is not letterboxed, the
439
+ Arcade keeps your choice and shows nothing, and nothing tells your game which case it is in — do
440
+ not look for a signal. `gem.frame.parentSupports('portraitBorders')` says whether this Arcade can
441
+ paint borders at all, never whether it is painting them now.
442
+ - **It is safe at any time.** Before the connection exists your choice is held and sent when the
443
+ Arcade connects. A burst of calls — a theme picker previewing on hover — reaches the Arcade as
444
+ its final value, at most one a second, so it never trips a rate limit. An embedder that paints
445
+ no borders is not told, and nothing is raised.
446
+ - **The Arcade owns the change.** When and how the image swaps is the Arcade's; you get no
447
+ acknowledgement and no say in the timing. A game that restores a saved theme just after it
448
+ starts shows the default for a moment, then its own.
449
+ - **It shows only while your game is playing** — not before play starts, and not while your key
450
+ art covers the play area.
451
+ - **A string that is not a valid name is refused on your side**, with a console warning, and never
452
+ sent. The SDK does not trim it, lower-case it or shorten it.
453
+ - **A valid name your page does not declare is ignored**, and the Arcade keeps what it is showing.
454
+ On the dev and test channels the Arcade's own page logs a console warning naming the value, the
455
+ channel whose page it looked in, and the names that page declares; on release it says nothing.
456
+
457
+ ### Publishing order and experiments
458
+
459
+ - **Publish the store page before, or together with, the build that uses a new name.** A build
460
+ asking for a name its page does not carry yet keeps the current image until the page arrives.
461
+ Rolling a build back is harmless; rolling the page back can remove a name, and the Arcade then
462
+ keeps showing what it has.
463
+ - **Every arm of a store-page experiment must carry every name your build uses.** The set comes
464
+ from the page the player opened, so an arm without a name falls under the rule above.
465
+ - **The borders come from the store page on screen.** To see your dev-channel borders, open your
466
+ game's store page on the dev channel. `gemctl serve` does not letterbox and shows no borders.
467
+
356
468
  ## An achievement unlock — celebrate it yourself, or declare the Arcade's
357
469
 
358
470
  By default the Arcade draws no card when a player unlocks one of your game's
@@ -429,8 +541,10 @@ credit for it until you say so.
429
541
  entry fields few and ordinary — and never gate first play behind one. A player made to type on a
430
542
  remote before they may play mostly does not.
431
543
  - **A TV performance budget.** TV-browser SoCs are weak next to any desktop: ship a player-settable
432
- render-scale option (render at DPR 1 and upscale), bound memory, assume a fixed 16:9 frame — there
433
- is no `orientationchange` rescue coming — and keep your heights `dvh`-safe.
544
+ render-scale option (render at DPR 1 and upscale), bound memory, assume a fixed frame — 16:9, or
545
+ a centred 9:16 column when your store page makes you
546
+ [a letterboxed portrait game](#art-beside-a-portrait-game-on-a-wide-screen-portrait-borders) —
547
+ with no `orientationchange` rescue coming, and keep your heights `dvh`-safe.
434
548
  - **Reduced motion.** TV browsers rarely expose `prefers-reduced-motion`, so the pushed
435
549
  `gem.prefs.reducedMotion` is the only signal a TV player has; honor it. It arrives with
436
550
  [the prefs the parent pushes](#what-the-parent-says-about-the-session-prefs-pause-and-terminate)
@@ -1016,7 +1130,15 @@ renderer serves all of them.
1016
1130
  | `displayTag` | The precomposed `Name#0001` | `PlayerText` |
1017
1131
  | `avatarIconId` | The portrait item's id | `string` |
1018
1132
  | `avatarIconUrl` | The portrait's thumbnail | `string` |
1019
- | `badges` | Equipped badges in display order, each `{ itemId, iconUrl }` — always a list, empty for now | array |
1133
+ | `badges` | Equipped badges, at most two, in the player's order, each `{ itemId, iconUrl }` | array |
1134
+
1135
+ **A player chooses what they wear**: a portrait and up to two badges, each an icon they own — one
1136
+ an achievement in your game or any other may have awarded them ([achievements.md](achievements.md)
1137
+ §1). The portrait arrives as `avatarIconId` and `avatarIconUrl`, and the badges as `badges`, in the
1138
+ order the player put them in. `badges` is always a list — empty for a player who wears none, and on
1139
+ a row whose identity is withheld — so draw what is there, never pad it to two, and key on `itemId`,
1140
+ never on a position. A player can also equip a title, and colours and animations for their name and
1141
+ title. The SDK's identity does not carry those, so draw the name in your own style.
1020
1142
 
1021
1143
  **The name and the tag are player-authored, so both are `PlayerText`** on every surface, not only
1022
1144
  some. Convert with `text.toPlain()` where you render, never before.
@@ -1160,6 +1282,11 @@ rather than a failure; nothing rejects, so there is no error branch to write; an
1160
1282
  nothing, so a reward must never be handed out on the strength of it.
1161
1283
  [`cross-promotion.md`](cross-promotion.md) is the page.
1162
1284
 
1285
+ A button the player presses for a reward — a revive, an extra attempt — is the same call with
1286
+ `format: 'rewarded'`. Your game config declares what each such button grants, the platform writes
1287
+ the reward into your game's inventory, and your game reads it there rather than from the answer.
1288
+ [`rewarded-ads.md`](rewarded-ads.md) is that page.
1289
+
1163
1290
  ## An account upsell, if your game has something to offer one
1164
1291
 
1165
1292
  Not on the list above either. When a player reaches for something only an account gives them,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",