@indiegems/gem-web-sdk 7.4.0 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +54 -0
  2. package/dist/gem-web-sdk.esm.js +1 -1
  3. package/dist/gem-web-sdk.game.js +1 -1
  4. package/dist/gem-web-sdk.min.js +1 -1
  5. package/dist/gem-web-sdk.sdk.js +1 -1
  6. package/dist/gem-web-sdk.server.js +1 -1
  7. package/docs/achievements.md +232 -0
  8. package/docs/analytics.md +271 -0
  9. package/docs/api/api-reference-game.md +15308 -0
  10. package/docs/api/api-reference-server.md +4397 -0
  11. package/docs/api/reference.json +24130 -0
  12. package/docs/arcade-economy.md +263 -0
  13. package/docs/async-opponents.md +396 -0
  14. package/docs/brand-art.md +334 -0
  15. package/docs/cross-promotion.md +137 -0
  16. package/docs/daily-challenges.md +530 -0
  17. package/docs/dedicated-server-flow.md +879 -0
  18. package/docs/deprecation-policy.md +124 -0
  19. package/docs/friends.md +233 -0
  20. package/docs/game-economy-tutorial.md +326 -0
  21. package/docs/getting-started.md +552 -0
  22. package/docs/inventory-and-catalogs.md +1035 -0
  23. package/docs/leaderboards.md +277 -0
  24. package/docs/manifest.json +135 -0
  25. package/docs/match-session.md +288 -0
  26. package/docs/p2p-and-parties.md +783 -0
  27. package/docs/platform-rules.md +372 -0
  28. package/docs/player-sign-in.md +89 -0
  29. package/docs/room-shapes.md +195 -0
  30. package/docs/rooms-and-matches.md +779 -0
  31. package/docs/saving-player-progress.md +457 -0
  32. package/docs/server-bundles.md +1041 -0
  33. package/docs/serverless-functions.md +748 -0
  34. package/docs/trading-and-gifting.md +98 -0
  35. package/docs/what-a-game-must-do.md +1231 -0
  36. package/package.json +7 -2
  37. package/schema/bridge.v1.json +5 -5
  38. package/schema/capabilities.public.json +189 -0
@@ -0,0 +1,530 @@
1
+ # Daily challenges
2
+
3
+ Games do not have to have dailies. This is a capability, not an obligation — but if your game has
4
+ something a player could finish today and finish again tomorrow, the platform can hold that state,
5
+ count the streak, put a marker on your game's card in the arcade so a player scanning the catalog
6
+ sees there is something waiting in it, and — if you want it to — **pay the player for finishing**.
7
+
8
+ **A daily challenge is a progression.** Not a new kind of object, not its own config section:
9
+ a progression that repeats on a known period and asks to be shown. Everything you already know
10
+ about progressions applies, including rewards, and
11
+ [inventory-and-catalogs.md](inventory-and-catalogs.md) is still where progressions are explained.
12
+
13
+ > **Not friend challenges.** A daily challenge is one every player gets from your game. A player
14
+ > sending one friend a challenge from your game — "beat my score" — is a different feature with a
15
+ > different name, and [friends.md](friends.md#friend-challenges) covers it. Players see the words
16
+ > "Friend challenges" for that one, never a bare "Challenges", which already heads the Arcade's
17
+ > section for dailies.
18
+
19
+ ## 1. Is this the mechanism you want
20
+
21
+ | You want | Use | Because |
22
+ | ------------------------------------------------------- | ------------------------------------ | ---------------------------------------------- |
23
+ | "Clear 10 piles today", shown in the arcade, streaked | `daily` + `platform_display: challenge` | yesterday survives; a missed day is not a zero |
24
+ | The same, and it pays the player | the same, plus rewards ([section 5](#5-paying-for-a-challenge)) | a challenge pays like any other progression |
25
+ | A season track, a battle pass | an ordinary progression | it accumulates; it does not reset |
26
+ | An internal counter you do not want shown | `daily`, no `platform_display` | the platform stores it and displays nothing |
27
+
28
+ There is no longer a choice to make between "a challenge" and "a progression that pays". **Every
29
+ progression keeps the same state and the same reward machinery whatever its schedule.** The
30
+ schedule decides when its value resets; `platform_display: challenge` decides whether the arcade
31
+ shows it, keeps a per-period history for it and counts a streak from it. The two are independent.
32
+
33
+ > If you have read an older version of this page, or a config with three extra `cron` progressions
34
+ > beside a challenge to carry its rewards: that workaround is obsolete, and the refusals that
35
+ > forced it are gone. Declare the rewards on the challenge.
36
+
37
+ ## 2. Declare it in your game config
38
+
39
+ Two fields on an ordinary progression:
40
+
41
+ ```yaml
42
+ progressions:
43
+ - id: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
44
+ slug: clear-ten-piles
45
+ display_name: 'Clear ten piles'
46
+ description: 'Clear ten piles in a single run.'
47
+ config:
48
+ completion_threshold: 10
49
+ reset_schedule: { type: daily } # daily | weekly | monthly
50
+ platform_display: challenge # none (the default) | challenge
51
+ ```
52
+
53
+ `reset_schedule` is the whole of the scheduling — there is no cron expression to write. A day runs
54
+ from **each player's own midnight** to the next, a week from their Monday, a month from their 1st.
55
+ `platform_display: challenge` is what makes it the arcade's business rather than only yours; the
56
+ default is `none`, and a progression on a daily schedule that says nothing appears on no platform
57
+ surface at all.
58
+
59
+ Two optional keys on the schedule move that boundary, and most games need neither:
60
+
61
+ ```yaml
62
+ reset_schedule: { type: daily, boundary: UTC, reset_time: '04:00' }
63
+ ```
64
+
65
+ - **`boundary`** — `local`, the default, is each player's own time zone. An IANA zone name such as
66
+ `UTC` or `Asia/Tokyo` puts every player on that one calendar instead, so everybody's day turns
67
+ over at the same instant: what a daily leaderboard, or a server that works out "today" for
68
+ itself, needs.
69
+ - **`reset_time`** — the clock time, `HH:MM` in that zone, at which a period begins. The default is
70
+ `00:00`; `'04:00'` lets a late-night player finish the day they started. Quote it: some YAML
71
+ parsers read an unquoted `04:00` as a number.
72
+
73
+ Changing only these two is not a change of schedule, so the upload does not refuse it
74
+ ([section 7](#7-an-id-is-permanent-and-so-is-its-schedule)).
75
+
76
+ `completion_threshold` is the target. `client_writable` decides who may report it, and
77
+ [section 4](#4-who-may-report-it) is where that choice actually matters.
78
+
79
+ **Upload the config the way you upload any other.** A challenge is not promoted differently and
80
+ does not need a new build to reach players.
81
+
82
+ ## 3. Report progress from your game
83
+
84
+ The write is the ordinary progression write. Your game already knows how to make it:
85
+
86
+ ```js
87
+ await gem.me.mutations.addProgression('clear-ten-piles', 1); // a counter
88
+ await gem.me.mutations.maxProgression('best-depth-today', 7); // a record
89
+ ```
90
+
91
+ `addProgression` accumulates and `maxProgression` keeps the best, exactly as they do for any other
92
+ progression. The platform routes the write to the current period on its own — your game does not
93
+ name a period, compute one, or pass a date — and the ordinary progression read returns what you
94
+ wrote:
95
+
96
+ ```js
97
+ await gem.me.mutations.addProgression('clear-ten-piles', 10);
98
+ const row = await gem.me.progressions.get('clear-ten-piles');
99
+ // { currentValue: 10, isCompleted: true, completionCount: 1 }
100
+ ```
101
+
102
+ **The value you read is this period's, and it resets when the period does** — without your game
103
+ doing anything, and whether or not the player plays. A read taken after the boundary reports zero
104
+ for a player who has not started today, so do not cache the value across `periodEndsAt`
105
+ ([section 6](#6-a-period-is-the-players-own-day-the-platform-says-where-it-ends-and-it-picks-the-subject)).
106
+
107
+ `completionCount` is the exception: it counts completions over the progression's whole life, so it
108
+ is how many days the player has finished this challenge, not how many times today.
109
+
110
+ ## 4. Who may report it
111
+
112
+ `client_writable` decides whether the browser may make that write or whether it must come from your
113
+ game's server or your serverless function.
114
+
115
+ A browser credential is public. A `client_writable` challenge is therefore **forgeable**: a player
116
+ can complete today's challenge without playing. For a challenge that only moves a marker and a
117
+ streak, that is an acceptable trade and the platform accepts it.
118
+
119
+ ⚠️ **A progression that carries `platform_grants` may not be `client_writable`, and the upload
120
+ refuses the combination.** The platform grants on your game SERVER's or serverless FUNCTION's
121
+ say-so, never a browser's. So a challenge that pays platform currency is server- or
122
+ function-reported, necessarily. A function pays only from a progression that opts in
123
+ (`function_may_grant: true`), only the player who called it, and once per call — Gems included,
124
+ so a daily that pays Gems needs no dedicated server
125
+ ([serverless-functions.md](serverless-functions.md#paying-the-player-who-called)).
126
+
127
+ **Choose server-reported when the challenge is a claim about skill you intend to stand behind** — a
128
+ leaderboard-adjacent "beat this in under a minute" — and client-reported when it is a nudge to play.
129
+ If your game has no server, a serverless function can report it: the game calls the function when
130
+ the player finishes, and the function checks the claim and writes the progression. A `daily` reset
131
+ with an `on_complete` mapping is also what makes the function pay once per day, whatever the input
132
+ says. For a challenge that pays nothing, client-reported is a reasonable place to be.
133
+
134
+ ## 5. Paying for a challenge
135
+
136
+ A challenge pays exactly as any other progression does, through the same two fields:
137
+
138
+ - **`rewards_on_complete`** — operations run in your game's own inventory each time the challenge
139
+ is completed: your currency, an item, an unlock.
140
+ - **`platform_grants`** — a mapping onto a **platform** currency, which the platform mints into the
141
+ player's arcade balance.
142
+
143
+ A worked daily that pays fifteen Gems and a hundred of your own currency:
144
+
145
+ ```yaml
146
+ progressions:
147
+ - id: 4f2a9c71-3e8b-4d15-9a62-7c0e5b1d8f43
148
+ slug: clear-ten-piles
149
+ display_name: 'Clear ten piles'
150
+ config:
151
+ completion_threshold: 10
152
+ reset_schedule: { type: daily }
153
+ platform_display: challenge
154
+ client_writable: false # required by platform_grants, and the default
155
+ rewards_on_complete:
156
+ - type: currency_grant # your game's own currency, by id
157
+ currency_id: 7b1d6c2e-5a4f-4e3b-9c8d-2f1e0a9b8c7d
158
+ amount: 100
159
+ platform_grants:
160
+ - mode: on_complete
161
+ amount: 15
162
+ target: { platform_currency_slug: GEMS }
163
+ ```
164
+
165
+ It pays **once per completion**, and the period is what makes the next completion possible: finish
166
+ today, get paid; finish again tomorrow, get paid again; finish twice today, paid once. Your game
167
+ server can deliberately re-arm a completion — a `progressionSet` below the threshold, or a
168
+ `progressionReset` — and the next completion pays again. That is by design and it is your game's
169
+ decision; the platform's own ceilings are what bound it.
170
+
171
+ Four things to know before you ship one:
172
+
173
+ - **The platform sets the caps; your game sets only `amount`.** Every cap on a platform currency
174
+ is the platform's, set per currency: what one firing may grant, what one game may grant a player
175
+ per window, and what all games together may grant a player per day of their own calendar (the
176
+ **daily maximum**) — and a player's Boost raises them for that player. A firing
177
+ that would go over is **trimmed to what is left**, for every platform currency: a player 50 short
178
+ of the daily maximum who finishes a daily granting 200 is granted 50, and once the day is spent a
179
+ firing grants 0. The receipt reads `clamped`, names the ceiling (`platform_max_per_window`), and
180
+ its `granted` is what actually landed. When the player reaches the daily maximum, **the arcade
181
+ tells them** — in its own chrome, with when more arrives — and that notice is never relayed into
182
+ your game. To show the player how much more they can earn today, and when more arrives, read
183
+ `gem.platform.earnAllowance.get()`
184
+ ([Showing how much more a player can earn](inventory-and-catalogs.md#showing-how-much-more-a-player-can-earn))
185
+ rather than finding out from a `clamped` receipt — for display only, never to decide whether to
186
+ grant. The numbers are
187
+ platform policy and can change without a release of this SDK; `gemctl platform config` shows
188
+ the current ones. A `max_per_window` (or the older `max_per_entity`) on a mapping is accepted,
189
+ ignored, and warned about with `platform_grant_max_per_window_ignored`, so leave it out.
190
+ - **Platform grants fire on every channel — `dev`, `test` and `release` — from a game server or
191
+ a serverless function.** On `dev` and `test` that includes a server you run yourself with a
192
+ developer-minted token (what `gemctl serve` hands a local server), so a Gems-paying daily can be
193
+ tested end to end on your own machine; a developer-minted token never reaches `release`. A
194
+ function the player called pays on every channel too, that player only, from an opted-in
195
+ progression. The receipt reads `fired` or `clamped` the same on every
196
+ channel.
197
+ - **There is one wallet, so a test payout is real.** Gems paid while you test on `dev` land in the
198
+ player's real balance and spend the same per-window allowance, and the same platform ceilings, a
199
+ released game spends. Nothing resets that allowance. It is kept per player, per currency and per
200
+ game, so once you exhaust it, wait for the window to turn over or test with another account.
201
+ - **The allowance runs on the player's own days, and a daily counts on its own date.** The allowance's
202
+ day is a date on the player's own calendar, in the time zone the platform holds for them
203
+ (UTC when it holds none), and it refills at their midnight. Against your game's cap, a daily's
204
+ grant counts on the daily's own date, wherever in the player's day it fires, and any other
205
+ grant — a weekly's, a monthly's, a cron's — on the date the player is on when it fires. Against
206
+ the daily maximum, every grant counts on the date the player is on when it lands. So the
207
+ allowance refills when the daily resets for a daily on the player's own calendar with the default
208
+ `reset_time`; with a later `reset_time`, or a `boundary` that fixes one zone, the card's "resets
209
+ in" is not in general when the allowance refills. Each date's allowance is spent once, whatever
210
+ the player does to their time zone.
211
+
212
+ **When a completion pays a platform currency, show it where it happened.** The arcade draws the
213
+ player's Gems and Tickets in its own chrome, and without a cue a payout lands there silently, in a
214
+ corner the player is not looking at. At the moment your game shows the challenge complete, call
215
+ `gem.cueReward` with the currency that paid (`GEMS` or `ARCADE_TICKETS`) and the centre of its icon
216
+ beside the payout on your completion screen, and the payout flies from there into the wallet:
217
+ [inventory-and-catalogs.md](inventory-and-catalogs.md#showing-the-reward-land-in-the-arcades-wallet).
218
+ **If it returns `false`, do nothing** — no celebration of your own, no counter, no flight;
219
+ platform-currency display belongs to the arcade, and a game not connected to it shows no
220
+ platform-currency amount at all.
221
+
222
+ **Cue every payout the same way, at the daily maximum too.** Do not predict a payout's outcome from
223
+ anything you hold. To show the player how much more your game can still pay today, read
224
+ [the earn allowance](inventory-and-catalogs.md#showing-how-much-more-a-player-can-earn) — display
225
+ only, never a gate: grant anyway and let the receipt say what landed. When a payout is trimmed or
226
+ pays nothing because the maximum is reached, the arcade lands what did fit and shows the player
227
+ that the daily max is reached and when more arrives; the notice is the player's and is never relayed
228
+ into your game. To draw the currency on your completion screen, use the platform's art — the icon
229
+ beside the amount, the hero only when the currency is the subject of a reward presentation:
230
+ [brand-art.md](brand-art.md). Which currencies your daily may pay is `gemctl platform config`'s
231
+ answer, and what each is for is [arcade-economy.md](arcade-economy.md).
232
+
233
+ ## 5a. Use `daily`, `weekly` or `monthly`. Avoid cron for a challenge.
234
+
235
+ `daily`, `weekly` and `monthly` are **calendar periods** — a day, a week from Monday, a month from
236
+ the 1st — on each player's own calendar unless you fix a zone ([section 2](#2-declare-it-in-your-game-config)).
237
+ The upload stores the expressions `0 0 * * *`, `0 0 * * 1` and `0 0 1 * *` as those named kinds. A
238
+ progression may also carry any other cron expression, and a challenge may too, but for a challenge
239
+ you almost certainly do not want to:
240
+
241
+ | | `daily` / `weekly` / `monthly` | a custom cron |
242
+ | ---------------------------- | --------------------------------------------- | ---------------------------------------- |
243
+ | Whose clock | the player's own, or the zone you fix | one UTC clock for every player |
244
+ | Storefront marker, My Games | yes | yes |
245
+ | Rewards, platform grants | yes | yes |
246
+ | The platform-wide **streak** | yes | **no** — a streak of its own, on your store page |
247
+ | The player's history calendar| yes | no |
248
+ | Label in the arcade | localised in every language the arcade speaks | generic |
249
+ | Period length | a day, a week, a month | must be between **1 and 31 days** |
250
+
251
+ A streak spans games, which only works when every game agrees on what the player's "today" is. A
252
+ custom cron period is yours alone and cannot be compared with anything, so a cron challenge is
253
+ **game-local**: it shows progress, pays and keeps its own streak, and it never joins the
254
+ platform-wide streak or a history.
255
+
256
+ **If you wrote `0 0 * * *`, write `daily`.** The upload stores it as `daily` either way — which puts
257
+ it on each player's own calendar, not on UTC midnight. Reach for a custom cron only when the period
258
+ genuinely cannot be a day, a week or a month — and expect the arcade to say less about it.
259
+
260
+ All displayed cron challenges in one game must share one expression: they share one calendar on the
261
+ card, and two expressions would count each other's challenges in the fraction.
262
+
263
+ ## 6. A period is the player's own day, the platform says where it ends, and it picks the subject
264
+
265
+ **A daily is a date, and each player reaches it at their own reset.** With the default `boundary`,
266
+ a player's day runs from midnight to midnight in the time zone the platform holds for them; a
267
+ player it holds no zone for is on UTC. A config that fixes a zone puts everybody on that one
268
+ calendar ([section 2](#2-declare-it-in-your-game-config)).
269
+
270
+ Two consequences, and the second is the one that breaks code:
271
+
272
+ - **Never derive the boundary from a clock.** The zone the platform holds for a player need not be
273
+ the device's, and a challenge may fix a zone or a reset time your game does not know. Ask the
274
+ platform where the current period ends; a game that computed it disagrees with the platform about
275
+ which day the player's progress lands in.
276
+ - ⚠️ **Key a period on `periodLocalDate`, or on `seed` for a cron challenge — never on
277
+ `periodStartAt`.** Two players on the same date, playing the same board, have different start
278
+ instants when their zones differ, so a start instant used as a key splits one day's board into
279
+ many. Key on `periodLocalDate ?? seed`.
280
+
281
+ ### The one call that answers both
282
+
283
+ ```js
284
+ const challenges = await gem.challenges.list();
285
+ const today = challenges.find((c) => c.slug === 'clear-ten-piles');
286
+
287
+ if (today?.seed) {
288
+ startBoard(boardFromSeed(today.seed)); // everyone playing today gets this board
289
+ scheduleRefresh(today.periodEndsAt); // Unix seconds — when this seed expires
290
+ }
291
+ ```
292
+
293
+ Find yours by `slug` or `id` rather than by position: a game that adds a weekly later gets a second
294
+ entry, and `list()[0]` quietly becomes whichever one sorts first.
295
+
296
+ `gem.challenges.list()` returns every challenge your config declares, for the period running now:
297
+
298
+ | Field | What it is |
299
+ | ------------------------------- | --------------------------------------------------------------------- |
300
+ | `id` | the progression's id from your config — permanent, and what to key on |
301
+ | `slug` | your slug, when you declared one |
302
+ | `window` | `daily`, `weekly`, `monthly` or `cron` — and the platform may add one |
303
+ | `periodLocalDate` | the period's date, `YYYY-MM-DD` — the day, the Monday, the 1st; absent for a cron challenge |
304
+ | `periodStartAt`, `periodEndsAt` | this player's period bounds, in Unix seconds |
305
+ | `boundary`, `resetTime` | the calendar the challenge runs on — `local` or a zone name — and the `HH:MM` a period begins at |
306
+ | `seed` | the subject selector for this challenge in this period |
307
+ | `data`, `dataHash` | content your studio stored for this period, when it stored some — [below](#pre-generated-content-the-board-itself) |
308
+
309
+ It carries **no progress and no streak**, but it is the player's own: a `local` challenge's bounds
310
+ follow that player's time zone. Players on the same `periodLocalDate` get the same `seed` and the
311
+ same content.
312
+
313
+ ⚠️ **Cache it per player, and the channel is part of the key.** The answer comes from the config
314
+ assigned to _your caller's channel_, and a test channel can declare challenges the live one does
315
+ not. A cache keyed on the game alone will hand your unreleased challenge's id and slug to live
316
+ players. The key is `(game, channel, player, periodsBack)` and it is valid until `periodEndsAt` —
317
+ never across channels, and never across players. That holds even though a challenge's `seed` and
318
+ stored content are the same on every channel that shows it: _which_ challenges are in the list, and
319
+ how each is declared, is the channel's config, and that is what the channel in the key protects.
320
+
321
+ ⚠️ **Do not send `periodStartAt` or `periodEndsAt` to other players.** For a `local` challenge they
322
+ imply the player's UTC offset, which the platform never shows another player. Send
323
+ `periodLocalDate`.
324
+
325
+ **A counter challenge does not need this call.** "Clear 10 piles" is fully described by your config:
326
+ declare it, report progress, done. This is for a challenge with a **subject** — "clear _today's_
327
+ board" — which needs the two things a browser cannot produce.
328
+
329
+ ### The seed: one board for everybody
330
+
331
+ `seed` is a short lowercase hex string. Feed it to your own generator and every player on that
332
+ date gets the same puzzle, board, word or level — each when their own day begins:
333
+
334
+ ```js
335
+ function boardFromSeed(seed) {
336
+ const rng = mulberry32(parseInt(seed.slice(0, 8), 16)); // your PRNG, your rules
337
+ return shuffle(DECK, rng);
338
+ }
339
+ ```
340
+
341
+ Four things to know, and the last one is a security property:
342
+
343
+ - **It is a string on purpose.** JavaScript numbers are doubles, so parsing a 64-bit seed as one
344
+ loses its low bits and two clients rounding differently end up on different boards. Slice the
345
+ hex, or parse as much of it as your generator needs — do not `Number()` the whole thing.
346
+ - **It expires at `periodEndsAt`, and the next period's is different.** That instant is the
347
+ platform's answer to "when does this stop being today" for this player; read again after it
348
+ rather than computing the next boundary. Clamp any timer you build from it — `Math.max(60_000, …)` — so a boundary your
349
+ game could not read becomes a slow retry rather than a tight loop.
350
+ - **The same challenge has the same seed in every channel.** The channel is not part of what the
351
+ platform derives it from, so your dev channel shows you the live channel's board. Convenient for
352
+ testing; do not build channel-specific boards on it. Stored content is shared the same way —
353
+ [below](#pre-generated-content-the-board-itself).
354
+ - **Treat the length as opaque** — take a prefix, never assume one. And an **empty** `seed` means
355
+ the platform sent something the SDK would not pass on: it is falsy, so `if (today?.seed)` is the
356
+ guard, and a challenge missing its period is left out of the list entirely rather than handed to
357
+ you with a made-up boundary.
358
+ - **Past periods are one argument away.** `gem.challenges.list({ periodsBack: 1 })` is the previous
359
+ period, counted **per window** — so `1` is yesterday for a daily and last week for a weekly, not
360
+ "one day ago" for both. It reaches 400 periods back, which is what an "all my past boards" screen
361
+ or a reconciliation pass needs.
362
+ - **Past periods are counted in the zone the player has now.** `periodsBack: 1` is the date before
363
+ this one; its bounds are computed in the player's current zone, so do not compare them with
364
+ bounds read before the player moved zones. Its `periodLocalDate` is still the date to key on.
365
+ - ⚠️ **Comparing `periodEndsAt` against the device clock is the one place that clock still
366
+ matters** — and a wrong one costs you little: you read late and show yesterday's subject a while
367
+ longer. It cannot file a player's progress in the wrong period, because the platform decides that
368
+ from when the write arrives, not from anything your game sends.
369
+ - ⚠️ **The seed is NOT a secret.** It is derived from public inputs and is entirely predictable —
370
+ its job is _agreement_, not secrecy. Anyone can compute the next one. Never authorize anything
371
+ with it, never use it where something must be unguessable, and remember that a player's client
372
+ holds it: if knowing today's board in advance would break your game, the subject has to come from
373
+ your own server instead.
374
+
375
+ ### Pre-generated content: the board itself
376
+
377
+ A seed says _which_ board; your game still has to build it. When building it is expensive — a
378
+ puzzle that must be proven solvable, say — or when your boards are hand-authored and you would
379
+ rather not ship a new build for each one, your studio can **generate each period's content ahead of
380
+ time and store it on the platform**. The platform then hands it to your game beside the seed:
381
+
382
+ ```js
383
+ const today = (await gem.challenges.list()).find((c) => c.slug === 'daily-drop');
384
+
385
+ const board = parseBoard(today?.data) ?? boardFromPool(today?.seed); // content first, then the fallback
386
+ startBoard(board);
387
+ ```
388
+
389
+ `data` is a string of up to 4,096 bytes — usually JSON — that your producer uploaded for this
390
+ challenge and period. `dataHash` is 16 lowercase hex identifying it. The producer is your
391
+ studio's CI job, a cron job, an agent or a developer running `gemctl challenges data push`; it is
392
+ never your game, and a player or server token cannot write content.
393
+
394
+ ⚠️ **Content is not per channel.** It is stored once for the challenge and period, and served on
395
+ **every channel whose assigned config displays that challenge** — and on no other. There is no
396
+ separate `dev` copy to test and then promote: promoting a config that displays the challenge is
397
+ what puts its content in front of that channel's players. So content you push to try out on `dev`
398
+ is served on `release` as soon as the `release` config displays the same challenge id. To trial
399
+ different content before release, declare the trial on `dev` under a **different challenge id**.
400
+ What stays per channel is the player's standing — progress, streaks and completions — so a playtest
401
+ on `dev` never touches anyone's `release` progress. (A platform payout is another matter: there is
402
+ one wallet — [section 5](#5-paying-for-a-challenge).)
403
+
404
+ Five rules:
405
+
406
+ - ⚠️ **`data` may be absent, and your game must keep working when it is.** It is omitted — never
407
+ `''` — for any period your producer did not fill, and a producer's runway can run out. Keep the
408
+ seed path or a bundled pool as the fallback, and treat content you cannot parse as absent.
409
+ - **It becomes readable when its period begins, not before.** A date's content is served to each
410
+ player from their own reset on that date; nothing your game can call returns it early. Past periods keep their content —
411
+ `list({ periodsBack: 1 })` returns yesterday's — for 400 days. Further back than that you get the
412
+ period and seed with no content, which for a weekly is about 57 periods and for a monthly about 13.
413
+ - ⚠️ **It is not authority.** Every player in the period receives the same bytes, so content must
414
+ never hold anything a verifier relies on being secret — a puzzle's solution stays on your server.
415
+ The same rule as the seed, for the same reason.
416
+ - ⚠️ **It must never hold personal data.** The platform stores it as an opaque string and cannot
417
+ check what is inside.
418
+ - **The platform never parses it.** Its shape is your game's contract with itself. Version it
419
+ inside the payload if it will ever change.
420
+
421
+ **Send `dataHash` and `periodLocalDate` with every result** your server checks — or `seed` for a cron
422
+ challenge, which has no date. Your studio can correct the content of a period that is already
423
+ running, and a client that read it before the correction keeps the old content for up to an hour;
424
+ the hash is how your server tells which board a result was played on.
425
+
426
+ #### Verifying a result against the content
427
+
428
+ A server bundle reads the same list for a named player — `gem.challenges.list({ playerId })` — and
429
+ checks a submitted result against the entry the client says it played:
430
+
431
+ 1. Read the current period and the one before it **for the player who sent the result**:
432
+ `gem.challenges.list({ playerId })` and `gem.challenges.list({ playerId, periodsBack: 1 })`. The
433
+ second is what accepts a result played just before the period rolled over. The player is
434
+ required because your server holds no player of its own: a read that names nobody is answered in
435
+ UTC, which is not the player's own period for a `local` challenge — a player east of UTC is
436
+ already on a date the UTC read has not reached. The player must play your game or be in it now;
437
+ anyone else is refused with `not_found`.
438
+ 2. Find that challenge in either read whose `periodLocalDate` (or, for a cron challenge, `seed`) and
439
+ `dataHash` both match what the client sent. **No match is a rejection** — never fall back to
440
+ validating against whichever board is current, which would score a player against content they
441
+ never saw.
442
+ 3. Validate the result against that entry's `data`.
443
+
444
+ Never match on `periodStartAt`: it is the player's own instant, not the period's identity.
445
+
446
+ ## 7. An id is permanent, and so is its schedule
447
+
448
+ A challenge's id is carried on every player's history for a long time, with nothing pointing back at
449
+ your config to explain it. **Reusing an id for a different challenge silently merges two challenges
450
+ into one history** and makes every number computed from it unexplainable — not wrong in a way that
451
+ shows, just quietly meaningless.
452
+
453
+ - **A new challenge gets a new id.** Always.
454
+ - **To rename what the player sees, change `display_name`.** That is free and is not part of the
455
+ challenge's identity.
456
+ - **Changing a progression's `reset_schedule` is refused at upload**, for the same reason: a daily
457
+ that becomes a weekly would carry its old id into a history that then describes two different
458
+ things. A different period means a new progression. (Rewriting `0 0 * * *` as `daily` is not a
459
+ change — they are the same schedule.)
460
+ - **Changing `boundary` or `reset_time` is allowed**, and keeps the id and the history, but it
461
+ moves every player's current period at the instant you promote it. A player moved back to an
462
+ earlier date loses nothing. A player moved **forward** onto the next date loses the one they were
463
+ on if they had not finished it, and their streak with it. So promote the change when nobody's
464
+ date moves forward: a switch from `UTC` to `local` between 00:00 and 09:59 UTC, as close to 00:00
465
+ as you can, and from `local` to `UTC` between 12:00 and 23:59 UTC, as close to 12:00 as you can.
466
+
467
+ ## 8. Retuning the target mid-period is safe
468
+
469
+ `completion_threshold` is snapshotted onto each player's row at their first progress in a period.
470
+ Raising it later cannot un-complete a period a player already finished, and cannot rewrite a
471
+ historical percentage.
472
+
473
+ **Lowering it mid-period is safe too, and has a consequence worth knowing:** a player who already
474
+ started that period keeps the target they started with. Two players in the same period can be
475
+ working toward different numbers, and every platform surface shows each player their own. That is
476
+ deliberate — the alternative rewrites history — but it will generate a support question the first
477
+ time two friends compare.
478
+
479
+ ## 9. Streaks
480
+
481
+ The platform counts consecutive periods in which a player completed **at least one** displayed
482
+ challenge — in any game, not only yours — and keeps a longest-ever alongside the current one. Your
483
+ game declares nothing to get this; it follows from the challenge.
484
+
485
+ Three things to know before you build a mechanic on it:
486
+
487
+ - **One completion is a day.** A player who finishes three of your challenges advances their streak
488
+ once, the same as a player who finishes one. A streak measures days the player showed up, not how
489
+ much a game offered.
490
+ - **A streak is the platform's, and it is shown on platform surfaces.** Your game reports progress
491
+ and does not read the streak back — see below.
492
+ - **Whether a player's streak is shown at all can depend on who they are.** Some players do not see
493
+ streak surfaces. Do not design a mechanic whose fairness depends on every player seeing one.
494
+
495
+ Challenges on a custom cron are game-local: each keeps a streak of its own, shown on your game's
496
+ store page, and none advances the platform-wide one ([section 5a](#5a-use-daily-weekly-or-monthly-avoid-cron-for-a-challenge)).
497
+
498
+ **A streak counts the player's own days**, whatever calendar a challenge's period runs on: a
499
+ challenge that fixes `boundary: UTC` still advances the streak on the day it is completed in the
500
+ player's own time zone.
501
+
502
+ ## 10. What your game can read back
503
+
504
+ **What the challenge IS — yes.** `gem.challenges.list()` gives you the period's date, its
505
+ boundaries and the seed, for every challenge your config declares (section 6). That read carries no
506
+ progress; its bounds are the player's own, and it is cheap, so you may call it freely. Your game
507
+ server reads the same list for one player with `gem.challenges.list({ playerId })`
508
+ ([verifying a result](#verifying-a-result-against-the-content)).
509
+
510
+ **How the player is doing in THIS period — yes**, through the ordinary progression read
511
+ (section 3): `currentValue` and `isCompleted` are this period's, and reset with it.
512
+
513
+ **Their per-period history and their streak — no.** There is no history read and no streak read in
514
+ the SDK. The arcade is what displays those — the marker on your game's card, and the player's own
515
+ progress, history and streak on platform surfaces. Your game does not have to render any of it.
516
+
517
+ ## 11. When an upload is refused
518
+
519
+ | What you get | What happened | What to do |
520
+ | -------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------- |
521
+ | `platform_grants: a mapped progression must not be client_writable` | you gave a paying challenge `client_writable: true` | report it from your game server or a serverless function, or drop the platform grant |
522
+ | `platform_display: challenge requires a reset_schedule` | you asked to display a progression that never resets | add `daily`, `weekly` or `monthly` |
523
+ | `needs a period of at least one day` / `at most 31 days` | a displayed challenge on a cron outside the bounds | prefer `daily`, `weekly` or `monthly` |
524
+ | `cron expression never fires` | an expression naming a date that does not exist (`0 0 30 2 *`) | fix the expression |
525
+ | `reset_schedule changed from … to …` | you changed a live progression's period | use a NEW progression; `display_name` is what renames one |
526
+ | warning `platform_grant_max_per_window_ignored` (or `platform_grant_cap_uses_retired_max_per_entity`) | a mapping declares `max_per_window` (or `max_per_entity`) | nothing is refused and the upload succeeds; remove the key, because the platform sets the caps |
527
+
528
+ Every refusal names the field and the reason. **Read the error rather than a list written down
529
+ here** — the platform is what decides these, and this table is only as current as the day it was
530
+ written.