@indiegems/gem-web-sdk 7.6.0 → 7.8.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.
@@ -28,6 +28,10 @@ response, and their rank among everyone is not the rank most screens want anyway
28
28
  last runs"_. `get()` is for a standings screen. `friends()` is for a standings screen among people
29
29
  the player knows.
30
30
 
31
+ **Want today's standings rather than all-time ones?** That is the same four reads over a board
32
+ declared `per_period` — one board for each day of a daily challenge — and it has its own section:
33
+ [A board for each day](#a-board-for-each-day-period-boards).
34
+
31
35
  `character()` is `mine()` for a character. **Read the leg your server WRITES.** Runs carry the
32
36
  entity that owns them, so if your server reports character-owned runs, `mine()` returns nothing and
33
37
  `character()` is the read you want — and vice versa. That mismatch is the commonest reason one of
@@ -226,6 +230,139 @@ is the second most likely reason a board looks empty.
226
230
  const theirs = await gem.leaderboards.character('time-trial', characterId);
227
231
  ```
228
232
 
233
+ ## A board for each day — period boards
234
+
235
+ Everything above ranks **every run a board has ever been given**. A daily challenge wants something
236
+ else: _today's_ standings, which start empty each day and close when the day does. Declare the board
237
+ `per_period` and it exists once per period of a challenge — one board for each day of a daily
238
+ challenge, each week of a weekly one, each month of a monthly one.
239
+
240
+ You do not give the board a schedule. It takes the challenge's: the same days, the same reset
241
+ time, the same time-zone rule. A board cannot drift from the challenge it belongs to, because it
242
+ has no clock of its own.
243
+
244
+ ### Declare it
245
+
246
+ Three pieces, and the middle one is the link. The run type names the **challenge** its runs belong
247
+ to — the `id` of a progression in the same config with a `reset_schedule` — and the board asks to
248
+ be split by that challenge's periods:
249
+
250
+ ```yaml
251
+ progressions:
252
+ - id: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
253
+ slug: daily-puzzle
254
+ config:
255
+ completion_threshold: 1
256
+ reset_schedule: { type: daily }
257
+
258
+ run_types:
259
+ - id: daily-run
260
+ int_value_1_name: moves
261
+ challenge: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
262
+
263
+ leaderboards:
264
+ - id: daily
265
+ run_type: daily-run
266
+ order_by:
267
+ - column: int_value_1
268
+ direction: asc
269
+ per_period: true
270
+ ```
271
+
272
+ - **If you already have a daily challenge, use its id.** The progression in
273
+ [daily-challenges.md](daily-challenges.md) is the same kind of thing; nothing new is needed.
274
+ - **The challenge does not have to be shown on your game's card.** A progression with a
275
+ `reset_schedule` and nothing else is enough — no `platform_display`, no stored content.
276
+ - **`daily`, `weekly`, `monthly` and a cron expression all work.** A progression with no
277
+ `reset_schedule`, or one that resets on an event, has no periods and is refused at upload.
278
+ - **One set of runs can back two boards.** Leave `per_period` off a second board over the same run
279
+ type and it ranks every run: "today" and "all time" from the same reports.
280
+
281
+ ### Record a run
282
+
283
+ Nothing changes in how a run is reported — your [serverless function](serverless-functions.md) or
284
+ your dedicated server reports it, never the browser. What changes is that the platform files each
285
+ run under a period:
286
+
287
+ - **The platform decides the period, from when it receives the run and the player's own time
288
+ zone.** Not from the device clock, and not from the run's `occurred_at`.
289
+ - **Name the period the run was played for.** Send `period_id` on the run, set to the challenge's
290
+ `periodLocalDate` from `gem.challenges.list()` as the player's game had it. A run that finishes
291
+ seconds before midnight and is checked seconds after still counts for the day it was played,
292
+ for **10 minutes** after that day ends. Leave it out and the run goes to whatever period it
293
+ arrives in — tomorrow's board, for that late run.
294
+ - ⚠️ **A run for a period that is not open for the player is refused, and the whole batch with
295
+ it** — `409 run_period_closed`. That is a day that ended more than ten minutes ago, or one that
296
+ has not started for them. Do not retry it: tell the player the day has closed.
297
+ - ⚠️ **Every run is a row.** A period board ranks every run it is given, so a player who plays
298
+ five times today holds five rows. For one row per player, have your function record one run per
299
+ player per period — `gem.leaderboards.mine('daily')` tells it whether today already has one.
300
+
301
+ ### Read it
302
+
303
+ Every read answers for **one period**, and says which:
304
+
305
+ ```js
306
+ const today = await gem.leaderboards.get('daily');
307
+ const yesterday = await gem.leaderboards.get('daily', { periodsBack: 1 });
308
+
309
+ heading(today.period.localDate, today.period.final ? 'Final' : 'So far');
310
+ ```
311
+
312
+ | Option | Reads |
313
+ | ----------------- | --------------------------------------------------------------------------------- |
314
+ | _(none)_ | the player's current period |
315
+ | `periodsBack: n` | `n` periods before it — yesterday for a daily, last week for a weekly. 0 to 400 |
316
+ | `period: id` | exactly that period, from an earlier result's `period.id` — for a shared link or a history screen |
317
+
318
+ `friends()`, `mine()` and `character()` take the same options. On `mine()` the runs, the personal
319
+ best and the total are that one period's, so `personalBest` is "your best today" and an empty
320
+ result each morning is the ordinary state.
321
+
322
+ The result's `period` is absent on an ordinary board and carries, on a period board:
323
+
324
+ | Field | What it is |
325
+ | -------------------- | ------------------------------------------------------------------------------- |
326
+ | `id` | the board's identity. Pass it back as `period` to read this same board again |
327
+ | `localDate` | the date — the same value the challenge gives as `periodLocalDate`. Absent for a cron challenge |
328
+ | `startAt`, `endsAt` | the **player's own** day, in Unix seconds |
329
+ | `opensAt`, `closesAt` | when the board accepts runs from **anyone**, in Unix seconds |
330
+ | `final` | `true` once no run can still arrive |
331
+
332
+ ⚠️ **Ask the platform which day it is; never work it out.** The player's day is a date on their
333
+ own calendar, in the time zone the platform holds for them. A board id built from the device clock
334
+ — `new Date().toISOString().slice(0, 10)` — names the wrong board for every player whose day is
335
+ not the UTC day, and for the hours either side of midnight for everyone else. Read with no option
336
+ for today, and take `period.id` from the result when you need to name it.
337
+
338
+ ### One day, many time zones
339
+
340
+ On the default `local` boundary each player's day is their own: it begins at their midnight.
341
+ **Players on the same date share one board, whatever their time zone** — the same board the
342
+ challenge's seed gives them all. Three consequences follow, and each one surprises somebody:
343
+
344
+ - **A day's board stays open about 50 hours.** It opens when the date begins in the earliest time
345
+ zone and closes when it ends in the latest. So the standings can change after _your_ day has
346
+ ended, and "you finished 3rd today" shown at the player's midnight can be wrong an hour later.
347
+ Say "so far" until `period.final` is `true`. Two or three days' boards are open at any moment.
348
+ - **Players whose day starts later see the earlier results first.** The puzzle is the same for
349
+ everyone on the date, so a player in a later time zone can see scores — and may be handed
350
+ solutions — before they play. If your board has to be strictly fair, put the challenge on one
351
+ clock for everybody: `reset_schedule: { type: daily, boundary: UTC }`. Everyone's day then turns
352
+ over at the same instant, and the board is open for exactly that day.
353
+ - **Rows carry no time.** `occurredAt` is `0` on a period board that follows each player's own time
354
+ zone: beside the board's date, a run's time would show where its player is. Render nothing for
355
+ `0`. Ranks are unaffected.
356
+
357
+ **Changing time zone does not buy a longer day.** A player's first run on a date fixes when that
358
+ date ends for them. Runs after that moment are refused for the date, even if the player has since
359
+ moved to a time zone where it is still running.
360
+
361
+ **From a serverless function or a dedicated server, name the player.** Their credential carries no
362
+ player, so a period read is answered in UTC unless you pass
363
+ `{ playerId }` — which is not that player's day on a `local` challenge. In the browser the read is
364
+ always for the signed-in player and `playerId` is ignored.
365
+
229
366
  ## Where the rows come from
230
367
 
231
368
  Runs are reported **from the server side** — by a dedicated server during its match, or by your
@@ -262,6 +399,9 @@ a `code`, and testing `err.reason === 'bad_request'` never matches, because a 40
262
399
  | `reason: 'invalid'`, `code: 'invalid_bucket'` | bucket values do not match the board's `bucket_by` | supply exactly the buckets the board declares |
263
400
  | `reason: 'not_found'`, `code: 'leaderboard_not_found'`, on `mine()` / `character()` | **also** the entity is not one you may read — an unknown character, one you do not own, one in another game, or a malformed id | **the same code as the row above**, which is the point: the entity read answers ONE refusal for every reason that depends on the target, deliberately, because a distinguishable answer would tell a caller whether a given player plays this game. Check the entity id as well as the board id — nothing in the response separates them |
264
401
  | `reason: 'forbidden'`, on `mine()` / `character()` / `friends()` | the caller may not act for this entity at all, or (on `friends()`) carries no player identity at all — a game server token, say. Decided from the caller's own claims, not from what was asked for | this one is worth telling apart, and it is not about the target: it will not start working because the player played more. Treat it as a bug in how the call was made, not as an empty board. `get()` never answers this — the global board needs no viewer |
402
+ | `reason: 'invalid'`, `code: 'invalid_period'` | `periodsBack` or `period` asked of a board that is not `per_period`; or a `period` that is not one of the challenge's, or more than 400 periods back | a board that ranks every run has no periods to select: drop the option, or declare the board `per_period`. Otherwise take the id from an earlier result's `period.id` rather than building one |
403
+ | `reason: 'invalid'`, `code: 'period_not_begun'` | the `period` asked for has not begun for this player, even if it has for players in time zones ahead of theirs | read with no option for the player's own current period |
404
+ | `TypeError` (thrown, no request sent) | `periodsBack` out of 0–400, a `period` that is not a period id, or both passed at once | pass one of them, or neither |
265
405
  | `reason: 'invalid'`, `code: 'bad_request'` | `limit` is not an integer in 1–100 | the SDK refuses this locally with a `TypeError` before any request, so seeing it from the server means something else built the URL |
266
406
  | `TypeError` (thrown, no request sent) | `limit` out of range, passed to `mine()` / `character()` | pass an integer 1–100, or omit it for the default 25 |
267
407
 
@@ -43,13 +43,13 @@
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
46
- "sha256": "4820868e911cc518a5c37cbd8c31b36a8a2374e280be14f3a6dfa45985685372",
47
- "updated": "2026-10-01T19:59:43Z"
46
+ "sha256": "7ba296ba3056407ca3c8c9757a3351f3a8062a943335ee52b4ee92e1b2721d3b",
47
+ "updated": "2026-10-02T18:52:17Z"
48
48
  },
49
49
  {
50
50
  "file": "dedicated-server-flow.md",
51
- "sha256": "06dfa9341e924628f67d33e8751f523ea27a3bc91eb07f1f52aadc5e2aa62de7",
52
- "updated": "2026-09-24T19:20:12Z"
51
+ "sha256": "6da1bfc820ab2cf79180f69f9fec4d9f377ddde690f53923e749a88ec6307e22",
52
+ "updated": "2026-10-02T19:04:21Z"
53
53
  },
54
54
  {
55
55
  "file": "deprecation-policy.md",
@@ -68,8 +68,8 @@
68
68
  },
69
69
  {
70
70
  "file": "getting-started.md",
71
- "sha256": "fd859a01988ecf57c857dcfba9958a8a32a65ff2a8ecfaf55f33f9045d368180",
72
- "updated": "2026-10-02T17:28:15Z"
71
+ "sha256": "d2179480be49085d828c9afbd89d1d72c00914c5607105831503f0652e521285",
72
+ "updated": "2026-10-02T21:18:29Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -78,8 +78,8 @@
78
78
  },
79
79
  {
80
80
  "file": "leaderboards.md",
81
- "sha256": "940be101a9a06a6ddad85a1afe18a23e9acca43772d158ff7be70ed0f2f6b28d",
82
- "updated": "2026-09-28T19:46:26Z"
81
+ "sha256": "4f065b915604284ec810aa3c7bc046bfd99f343332a70b35921ecf061503f867",
82
+ "updated": "2026-10-02T18:52:17Z"
83
83
  },
84
84
  {
85
85
  "file": "match-session.md",
@@ -118,8 +118,8 @@
118
118
  },
119
119
  {
120
120
  "file": "server-bundles.md",
121
- "sha256": "8190da0484b7e1ae6f636068b06a43729f3ef132488f5a0cfd2fc5c2988bc55b",
122
- "updated": "2026-10-02T17:28:15Z"
121
+ "sha256": "fed144c0d7b9f8be10fc3896a7325ab19aa660441c24038f39bc02b5fdf60cb3",
122
+ "updated": "2026-10-02T21:18:29Z"
123
123
  },
124
124
  {
125
125
  "file": "serverless-functions-js.md",
@@ -128,8 +128,8 @@
128
128
  },
129
129
  {
130
130
  "file": "serverless-functions.md",
131
- "sha256": "3b61ee955d3bc439c39a7cad9339f8ffe8c0fb584f0a25621b877e185160a45a",
132
- "updated": "2026-10-01T18:13:51Z"
131
+ "sha256": "c830daf736713744909700bf93ff5ba139b8b5d8ad3c2b4e057ca813fc6179f7",
132
+ "updated": "2026-10-02T18:52:17Z"
133
133
  },
134
134
  {
135
135
  "file": "trading-and-gifting.md",
@@ -138,8 +138,8 @@
138
138
  },
139
139
  {
140
140
  "file": "what-a-game-must-do.md",
141
- "sha256": "a0a2952a2b16b43b0af831ff58ad8fabe47b414f57fccd2cb2d866861a65465e",
142
- "updated": "2026-10-02T14:04:37Z"
141
+ "sha256": "d7b66e2c04f5cea169ae4cecd3926e350e272232af32752e60a7a388dfd8c461",
142
+ "updated": "2026-10-02T21:01:44Z"
143
143
  }
144
144
  ]
145
145
  }
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.6.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v7.6.0/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v7.8.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v7.8.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -183,11 +183,15 @@ do not decide whether a reward was earned. So:
183
183
  zone, and on one with no reset it grants once, ever.
184
184
  `progression_max` grants only an improvement on a best. The platform keeps that state, and the
185
185
  input cannot reset it;
186
- - if you key 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;
186
+ - if you key the order that grants with a `nonce` instead, build the key from something the
187
+ platform decides: the date of the player's daily, or an id the platform issued, such as a match
188
+ it ran. That date is `period_local_date` on your
189
+ [daily challenge](daily-challenges.md#the-one-call-that-answers-both), read for the caller with
190
+ `GET /v1/challenges?player_id=<their id>` (`daily:2026-09-26`), and it is the player's own day
191
+ unless the challenge's `boundary` fixes a zone. The UTC date from `now-ms` names a UTC day,
192
+ which for a player off UTC is not the day their daily runs on. **Never use an id from the call's
193
+ input.** A run id your client made up is a new id on every call, so it stops nothing. A nonce is
194
+ also only remembered for 72 hours, so it guards a day, not a week or a lifetime;
191
195
  - never pass the caller's request straight through to a progression write.
192
196
 
193
197
  ### What `call-api` can reach
@@ -386,6 +390,31 @@ The path's `{game_id}` is the game id from `get-context`, `entity_id` is the pla
386
390
  you, and `run_id` is a UUID you choose. A run may also carry `occurred_at`, in Unix seconds, which
387
391
  defaults to now. A stored batch answers `200` with `{ "inserted": 1, "duplicates": 0 }`.
388
392
 
393
+ **A run for a daily board names its day.** When the run type belongs to a challenge — its config
394
+ names one with `challenge`, which is what a
395
+ [`per_period` board](leaderboards.md#a-board-for-each-day-period-boards) ranks — the platform
396
+ files the run under one period of that challenge. It decides which from when the run arrives and
397
+ the player's own time zone, never from `occurred_at`. Add `"period_id"` to the run, set to the
398
+ `periodLocalDate` the player's game read from `gem.challenges.list()` and sent you with the result:
399
+
400
+ ```json
401
+ {
402
+ "run_id": "5b0d3c1e-8f7a-4c2e-9d61-0a4b7e2f9c13",
403
+ "entity_type": "player",
404
+ "entity_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
405
+ "run_type": "daily-run",
406
+ "int_value_1": 14,
407
+ "period_id": "2026-10-02"
408
+ }
409
+ ```
410
+
411
+ A run that names the day it was played for still counts for that day when it arrives up to 10
412
+ minutes after the day ended for the player. Without `period_id` the run goes to the period it
413
+ arrives in. ⚠️ **`period_id` is a claim the platform checks, not a choice it obeys**: a client
414
+ cannot use it to post to yesterday's board, or to a day that has not begun for the player. For a
415
+ cron challenge the id is the period's start, as `2026-10-02T06:00:00Z`. Send no `period_id` for a
416
+ run type that names no challenge; it is refused there.
417
+
389
418
  ⚠️ **A function that records the score its caller sent, unchecked, makes your board forgeable.**
390
419
  The platform trusts a function's runs as it trusts your dedicated server's, and it cannot tell a
391
420
  checked score from a forwarded one. Check the run against what the player cannot forge (section 1)
@@ -396,6 +425,7 @@ before you record it, or any player can put any score they like on your board.
396
425
  | `400 invalid_run` | A run failed its run type's checks. `details` names it by `index` and `run_id` | Fix that run and send the batch again |
397
426
  | `400 unknown_run_type` | The run type is not in the active config of the channel the function was called on | Publish the run type on that channel |
398
427
  | `400 too_many_runs` | More than 500 runs in one request | Split the batch |
428
+ | `409 run_period_closed` | A run's challenge period is not open for its player: the day ended more than 10 minutes ago, or has not begun for them. Nothing was written, and `details` names the run by `index` and `run_id` | Do not retry that run. Tell the player the day has closed, and send the rest of the batch without it |
399
429
  | `403 function_foreign_entity_write` | A function a player called named another player, or a character not theirs | Record runs only for the caller and their characters |
400
430
  | `429 rate_limited` | The budget of 60 requests a minute is spent. Nothing was written | Send more runs in each request, and fewer requests |
401
431
  | `503 service_unavailable` | The platform could not check the budget, so it refused the request. Nothing was written | Retry shortly |
@@ -328,11 +328,11 @@ orientations:
328
328
  landscape: partial
329
329
  ```
330
330
 
331
- | Value | What the player gets |
332
- | --- | --- |
333
- | `full` | nothing — the game plays this way round |
334
- | `partial` | a dismissible hint, once, if the other way is `full` |
335
- | `none` | a panel over the paused game asking them to rotate, with "play anyway" |
331
+ | Value | What the player gets |
332
+ | --------- | ---------------------------------------------------------------------- |
333
+ | `full` | nothing — the game plays this way round |
334
+ | `partial` | a dismissible hint, once, if the other way is `full` |
335
+ | `none` | a panel over the paused game asking them to rotate, with "play anyway" |
336
336
 
337
337
  Three consequences worth knowing before you hit them:
338
338
 
@@ -508,10 +508,14 @@ them**, so the whole migration for a stylesheet is one declaration per edge:
508
508
 
509
509
  ```css
510
510
  /* before */
511
- #hud { top: env(safe-area-inset-top, 0px); }
511
+ #hud {
512
+ top: env(safe-area-inset-top, 0px);
513
+ }
512
514
 
513
515
  /* after — right in the arcade, and unchanged everywhere else */
514
- #hud { top: var(--gem-safe-top, env(safe-area-inset-top, 0px)); }
516
+ #hud {
517
+ top: var(--gem-safe-top, env(safe-area-inset-top, 0px));
518
+ }
515
519
  ```
516
520
 
517
521
  The `env()` fallback is not decoration. Under a parent that has not sent the message — an older
@@ -805,6 +809,25 @@ pauseMenu.onOpen = () => gem.setWalletVisible(true);
805
809
  - **`false` means nobody is listening** — no connection yet, or an embedder that draws no wallet.
806
810
  Do nothing in its place.
807
811
 
812
+ ## No accidental text selection
813
+
814
+ **You write nothing for this.** A long press on a phone, a double-click on a button, a drag across
815
+ your HUD: on an ordinary page each selects text, raises the copy menu or flashes a tap highlight,
816
+ and in a game none of them is something the player meant. So at start-up the SDK turns all three
817
+ off across your document.
818
+
819
+ - **Your CSS always wins.** The SDK's rule has zero specificity, so any rule you write overrides it.
820
+ To make one element copyable — a room code, a share link — give it
821
+ `-webkit-user-select: text; user-select: text;`.
822
+ - **Inputs, text areas and editable content are untouched.** They still take typing, still select
823
+ and still paste.
824
+ - **It is a stylesheet the SDK adopts, not a `<style>` element**, so a strict `style-src` in your
825
+ own CSP does not block it. On iOS before 16.4, which lacks that API, the SDK sets the same
826
+ properties on `<html>` itself instead, all but the copy menu, which has no safe way to be handed
827
+ back to your inputs there.
828
+ - **To keep the browser's default instead** — a game that is mostly text the player should be able
829
+ to copy — pass `createGem({ selectableText: true })`.
830
+
808
831
  ## The Arcade's FPS overlay
809
832
 
810
833
  The Arcade has a frame-rate toggle beside your game, and **you do not have to write anything for it
@@ -876,15 +899,15 @@ player (`gem.player`), room members, leaderboard rows, friends, profiles, showca
876
899
  async opponent you read through the raw client, once you pass the row through `mapIdentity`. One
877
900
  renderer serves all of them.
878
901
 
879
- | Field | What it is | Type |
880
- | ----- | ---------- | ---- |
881
- | `playerId` | The player's id. **Absent on an anonymous row** | `string` |
882
- | `displayName` | The name the player chose | `PlayerText` |
883
- | `discriminator` | The digits that tell two same-named players apart — `0001`, no `#` | `string` |
884
- | `displayTag` | The precomposed `Name#0001` | `PlayerText` |
885
- | `avatarIconId` | The portrait item's id | `string` |
886
- | `avatarIconUrl` | The portrait's thumbnail | `string` |
887
- | `badges` | Equipped badges in display order, each `{ itemId, iconUrl }` — always a list, empty for now | array |
902
+ | Field | What it is | Type |
903
+ | --------------- | ------------------------------------------------------------------------------------------- | ------------ |
904
+ | `playerId` | The player's id. **Absent on an anonymous row** | `string` |
905
+ | `displayName` | The name the player chose | `PlayerText` |
906
+ | `discriminator` | The digits that tell two same-named players apart — `0001`, no `#` | `string` |
907
+ | `displayTag` | The precomposed `Name#0001` | `PlayerText` |
908
+ | `avatarIconId` | The portrait item's id | `string` |
909
+ | `avatarIconUrl` | The portrait's thumbnail | `string` |
910
+ | `badges` | Equipped badges in display order, each `{ itemId, iconUrl }` — always a list, empty for now | array |
888
911
 
889
912
  **The name and the tag are player-authored, so both are `PlayerText`** on every surface, not only
890
913
  some. Convert with `text.toPlain()` where you render, never before.
@@ -937,11 +960,11 @@ While it lasts the player earns Gems and Tickets faster (never achievement point
937
960
  platform raises their earning limits to match. The platform does all of that on its own side when
938
961
  it pays; your game does nothing to make a boost work.
939
962
 
940
- | Field | What it is | Type |
941
- | ----- | ---------- | ---- |
942
- | `boost.active` | Whether any currency earns faster than 1× right now | `boolean` |
943
- | `boost.multipliers` | One `{ currencyId, currencySlug, multiplier }` per boostable currency — `multiplier` is `2` for 2×, `1` when not boosted; a currency not listed is 1× | array |
944
- | `boost.expiresInDays` | Whole days until the soonest boosted currency drops back, rounded up and **capped at 7** — `7` means "7 or more"; absent when nothing is boosted | `number` |
963
+ | Field | What it is | Type |
964
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
965
+ | `boost.active` | Whether any currency earns faster than 1× right now | `boolean` |
966
+ | `boost.multipliers` | One `{ currencyId, currencySlug, multiplier }` per boostable currency — `multiplier` is `2` for 2×, `1` when not boosted; a currency not listed is 1× | array |
967
+ | `boost.expiresInDays` | Whole days until the soonest boosted currency drops back, rounded up and **capped at 7** — `7` means "7 or more"; absent when nothing is boosted | `number` |
945
968
 
946
969
  `boost` is always present on `gem.player`. When the Arcade has not said — an older Arcade, a
947
970
  backend without boosts, a read that has not landed yet — it reads as **not boosted**, exactly like a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "7.6.0",
3
+ "version": "7.8.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",