@indiegems/gem-web-sdk 9.1.4 → 10.1.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.
@@ -78,9 +78,118 @@ Changing only these two is not a change of schedule, so the upload does not refu
78
78
  `completion_threshold` is the target. `client_writable` decides who may report it, and
79
79
  [section 4](#4-who-may-report-it) is where that choice actually matters.
80
80
 
81
+ `display_name` and `description` are what a player reads. To translate them, or to have the
82
+ platform write the target into them, see
83
+ [section 2a](#2a-translate-the-copy-and-write-the-target-into-it).
84
+
81
85
  **Upload the config the way you upload any other.** A challenge is not promoted differently and
82
86
  does not need a new build to reach players.
83
87
 
88
+ ## 2a. Translate the copy, and write the target into it
89
+
90
+ Both of these are optional, and a config that uses neither keeps working as it is. A progression
91
+ may carry your own translations of its name and description, and a displayed challenge may have the
92
+ platform write its target into the text:
93
+
94
+ ```yaml
95
+ progressions:
96
+ - id: 9b1d6e40-52c7-4f0a-8e3d-1a7c4b9f2d65
97
+ slug: clear-lines
98
+ display_name: 'Clear {target, plural, one {# line} other {# lines}}'
99
+ description: 'Clear lines in a single run.'
100
+ localized:
101
+ de:
102
+ display_name: 'Räume {target, plural, one {# Reihe} other {# Reihen}} ab'
103
+ description: 'Räume Reihen in einem Durchgang ab.'
104
+ pl:
105
+ description: 'Usuń linie w jednej rozgrywce.'
106
+ config:
107
+ completion_threshold: 150
108
+ reset_schedule: { type: daily }
109
+ platform_display: challenge
110
+ ```
111
+
112
+ Quote the text. YAML reads an unquoted value that starts with `{` as a mapping, so
113
+ `display_name: {target} points` is not a string.
114
+
115
+ ### Translations
116
+
117
+ Any progression may carry `localized`, whether or not it is a displayed challenge.
118
+
119
+ - **The keys are the eleven locales:** `de`, `es`, `fr`, `it`, `ja`, `ko`, `pl`, `pt-BR`, `ru`,
120
+ `tr`, `zh-Hans`. `en` is refused as a key, and so is any other key.
121
+ - **Each entry has two optional fields**, `display_name` and `description`. You may translate one
122
+ and not the other.
123
+ - **The `display_name` and `description` you write outside `localized` are the default text**, and
124
+ the platform treats them as English. A missing locale, or a missing field in a locale, falls back
125
+ to the default text, one field at a time. In the example, a Polish reader gets the default name
126
+ and the Polish description, and a French reader gets the default text for both.
127
+ - **An empty translation is refused.** To show the default text, remove the key.
128
+ - **Translations are optional**, and promoting a config does not depend on them.
129
+
130
+ ### Write the target with {target}
131
+
132
+ On a displayed challenge (`platform_display: challenge`), `{target}` in `display_name` or
133
+ `description` is replaced by the challenge's `completion_threshold`. It works in the default text
134
+ and in every translation. Copy that already contains the literal text `{target}` is now shown with
135
+ the number in its place.
136
+
137
+ | You write | Target | A player reads |
138
+ | ------------------------------------------------------ | ------ | --------------------- |
139
+ | `Score {target} points` | 12000 | Score 12,000 points |
140
+ | `Erziele {target} Punkte`, under `de` | 12000 | Erziele 12.000 Punkte |
141
+ | `Clear {target, plural, one {# line} other {# lines}}` | 1 | Clear 1 line |
142
+ | the same | 150 | Clear 150 lines |
143
+
144
+ - **`target` is the only name.** Any other `{name}` is refused, and so is `{target}` on a
145
+ progression with no `completion_threshold`.
146
+ - **The number is grouped the way the reader's language groups it:** `12,000` in English, `12.000`
147
+ in German. Each text uses one language for its words, its plural rule and its number.
148
+ - **A plural needs an `other` branch.** `Clear {target, plural, one {# line} other {# lines}}` is
149
+ "Clear 1 line" at 1 and "Clear 150 lines" at 150.
150
+ - **Write category branches (`one`, `few`, `many`, `other`), not `=N`.** An exact branch is
151
+ accepted only when `N` equals the current threshold, so if you later change the threshold, copy
152
+ that used `=N` can fail its next upload. Each text uses the categories its own language has: a
153
+ category that no number takes in that language (`few` in English) is refused, and so is a branch
154
+ written twice.
155
+ - **Each player reads the target they are working toward.** A player who started a period before
156
+ you changed the threshold keeps reading their own number, in the copy and on the meter alike.
157
+ Section 8 has what a change of threshold does to a period in progress.
158
+
159
+ ### The rules for a displayed challenge's copy
160
+
161
+ - **On a displayed challenge the name and description are templates, and `{` and `}` are syntax.**
162
+ On any other progression they are plain text, and braces mean nothing.
163
+ - **The name is at most 64 characters and the description at most 280, counted
164
+ on the line a player is shown**, in every language, never on the template. Nothing is truncated;
165
+ text that renders too long is refused. A long plural is therefore fine when each line it renders
166
+ is short. The text as written may be up to 256 characters for a name and 4096 for a description.
167
+ - **A displayed challenge's name and description are each one line.**
168
+ - **The copy is screened at upload, in every language.** It is refused for words that claim to
169
+ speak for the platform or name a platform role, for the name of a platform currency, for reward
170
+ or credential words, and for links.
171
+ - **Some problems are refused today, and some only draw a warning.** A problem in a `localized`
172
+ entry, or in text that uses `{target}`, is refused. A problem in a displayed challenge's default
173
+ text that does not use `{target}` (a stray brace, a line break, a screened word) draws the
174
+ warning `progressions_copy_refusal_pending` with the same message, and the upload succeeds. That
175
+ warning will become a refusal, so fix the text when you see it. It follows the text, not its age:
176
+ default text without `{target}` on a challenge you add today draws the same warning.
177
+ - **A translation on a progression that is not a displayed challenge is plain text**, held to 64 /
178
+ 280 characters as written. It is still refused for a link or for words that claim to speak for
179
+ the platform, and its description may have line breaks.
180
+
181
+ ### What a player reads, and what your game reads
182
+
183
+ - **Where the platform shows a progression's name and description, a reader gets your translation
184
+ when there is one for their language, and the default text otherwise.**
185
+ - **Your game's own reads do not return this text.** `gem.challenges.list()` and the progression
186
+ read (`currentValue`, `isCompleted`) carry no name and no description.
187
+ - **`gem.gameConfig.load()` returns the config as you wrote it:** `display_name` and `description`
188
+ with `{target}` still in them, not replaced, and not chosen for the player's language. It also
189
+ returns your `localized` entries as written.
190
+ - **A game that shows its own challenge UI keeps rendering its own strings**, in
191
+ `gem.prefs.locale`.
192
+
84
193
  ## 3. Report progress from your game
85
194
 
86
195
  The write is the ordinary progression write. Your game already knows how to make it:
@@ -506,7 +615,9 @@ shows, just quietly meaningless.
506
615
 
507
616
  - **A new challenge gets a new id.** Always.
508
617
  - **To rename what the player sees, change `display_name`.** That is free and is not part of the
509
- challenge's identity.
618
+ challenge's identity. Update that name's translations in `localized` too
619
+ ([section 2a](#2a-translate-the-copy-and-write-the-target-into-it)): a translation you leave
620
+ behind keeps showing the old name to readers of that language.
510
621
  - **Changing a progression's `reset_schedule` is refused at upload**, for the same reason: a daily
511
622
  that becomes a weekly would carry its old id into a history that then describes two different
512
623
  things. A different period means a new progression. (Rewriting `0 0 * * *` as `daily` is not a
@@ -590,6 +701,17 @@ own streak on your store page. Your game does not have to render any of it.
590
701
  | `cron expression never fires` | an expression naming a date that does not exist (`0 0 30 2 *`) | fix the expression |
591
702
  | `reset_schedule changed from … to …` | you changed a live progression's period | use a NEW progression; `display_name` is what renames one |
592
703
  | 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 |
704
+ | a refusal at `localized.<key>` | the key is not one of the eleven locales, or it is `en` | use a listed locale; the text outside `localized` is the English |
705
+ | a refusal at a `localized.<locale>` field that is empty | an empty translation | remove the key to show the default text |
706
+ | a refusal naming a `{name}` other than `target` | a displayed challenge's copy referred to something else | `target` is the only name; remove the braces from the copy |
707
+ | a refusal for `{target}` with no threshold | the progression has no `completion_threshold` | set one, or remove `{target}` |
708
+ | a refusal on a plural branch | no `other` branch, an `=N` that is not the threshold, or a category the language does not have | write the category branches that language has, and always `other` |
709
+ | a refusal for text that is too long | the line a player is shown passes 64 (name) or 280 (description) | shorten the text; nothing is truncated for you |
710
+ | warning `progressions_copy_refusal_pending` | default text without `{target}` on a displayed challenge breaks a copy rule | the upload succeeds; fix the text now, because this will become a refusal |
711
+
712
+ A refusal about a progression's copy reads `progressions[<i>] (<slug>): <path>: <reason>`, where
713
+ `<path>` is `display_name`, `description`, `localized.<locale>.display_name`,
714
+ `localized.<locale>.description`, or `localized.<key>` for a refused key.
593
715
 
594
716
  Every refusal names the field and the reason. **Read the error rather than a list written down
595
717
  here** — the platform is what decides these, and this table is only as current as the day it was
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "9.1.4"
53
+ "@indiegems/gem-web-sdk": "10.1.0"
54
54
  }
55
55
  ```
56
56
 
@@ -175,11 +175,11 @@ gem.frame.progress(loaded, total); // while assets load
175
175
  gem.frame.ready(); // once the player can actually play
176
176
  ```
177
177
 
178
- And tell the Arcade where your settings are — `gem.setMenu({ onSettings })` puts them in the
179
- Arcade's own menu, top-right, beside Return to the Arcade
180
- ([what a game must do](what-a-game-must-do.md#the-arcades-menu-your-settings-live-in-it)). Outside
181
- the Arcade there is no such menu, so also give players a way out — a control that calls `gem.exit()`. A game a player
182
- cannot leave is one they leave by closing the tab.
178
+ And draw a settings button of your own — top-right is the recommendation — whose values start from
179
+ the player's Arcade-wide defaults, `gem.prefs.recommended`, until the player chooses their own
180
+ ([what a game must do](what-a-game-must-do.md#game-settings)). The Arcade's menu does not open
181
+ your settings. Outside the Arcade there is no Arcade menu at all, so also give players a way out — a
182
+ control that calls `gem.exit()`. A game a player cannot leave is one they leave by closing the tab.
183
183
 
184
184
  ## 6. Play it
185
185
 
@@ -33,8 +33,8 @@
33
33
  },
34
34
  {
35
35
  "file": "changelog.md",
36
- "sha256": "6333e652b2c3ffade4ced6759436b8ab3dcb95b25a43c1bd82fe9a6f23fd8a7d",
37
- "updated": "2026-10-10T01:45:03Z"
36
+ "sha256": "a194ff0323a071b622349d0b7de0f189d723472f2e6584594d752b21daf5f668",
37
+ "updated": "2026-10-10T20:52:09Z"
38
38
  },
39
39
  {
40
40
  "file": "cross-promotion.md",
@@ -43,8 +43,8 @@
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
46
- "sha256": "c198868e2cb1f1b5d9164bb5b1d23bf3f502987e45e10163c8b048a8eba24667",
47
- "updated": "2026-10-02T23:01:00Z"
46
+ "sha256": "f4842b2fe01c8780020326f4722ab69dc01c1f40e1e6455cf2fb27752094383d",
47
+ "updated": "2026-10-10T14:37:50Z"
48
48
  },
49
49
  {
50
50
  "file": "dedicated-server-flow.md",
@@ -68,8 +68,8 @@
68
68
  },
69
69
  {
70
70
  "file": "getting-started.md",
71
- "sha256": "22fab7f0fd67966239c38541a8620ac7fe614f100b96df3e94570b573d4a9f61",
72
- "updated": "2026-10-10T01:45:03Z"
71
+ "sha256": "f7a67995ffcbddafaf7ed5c6c1ae8bc2c25dabe60c588e5b70dfb73218445a36",
72
+ "updated": "2026-10-10T20:52:09Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -113,8 +113,8 @@
113
113
  },
114
114
  {
115
115
  "file": "rooms-and-matches.md",
116
- "sha256": "0abf3717a42c94ec832dfa1310c2d93a9f58236fd75b0aacaab301976bffe286",
117
- "updated": "2026-10-07T19:27:16Z"
116
+ "sha256": "23540a20bdd8c8d058eb06d8ef11085ffd8eaaeeaadc93a807608b7c279e240c",
117
+ "updated": "2026-10-10T18:05:31Z"
118
118
  },
119
119
  {
120
120
  "file": "saving-player-progress.md",
@@ -123,8 +123,8 @@
123
123
  },
124
124
  {
125
125
  "file": "server-bundles.md",
126
- "sha256": "bc59062fefb7c17986671e2a8b6b86b714d1c7e9774956a3df98adcef38a7fd2",
127
- "updated": "2026-10-10T01:45:03Z"
126
+ "sha256": "211384b7a68560979a43a57d25acc1e2897343958973b1c3f2a0d8fbeb0d7ffd",
127
+ "updated": "2026-10-10T20:52:09Z"
128
128
  },
129
129
  {
130
130
  "file": "serverless-functions-js.md",
@@ -143,13 +143,13 @@
143
143
  },
144
144
  {
145
145
  "file": "vibration.md",
146
- "sha256": "3608da64abcef207cf0875f9d32f3aed88d4fb23512bc70d0a9684a570263a44",
147
- "updated": "2026-10-06T17:36:25Z"
146
+ "sha256": "587031f88acb64d8da0933ead76e50a88190e24a17effd875de0828f8c436393",
147
+ "updated": "2026-10-10T20:30:31Z"
148
148
  },
149
149
  {
150
150
  "file": "what-a-game-must-do.md",
151
- "sha256": "bc02d4a864943da446d9411b338b75908d9e0c2e0bc8378bbd28bf3f0ccf7f2a",
152
- "updated": "2026-10-09T00:24:00Z"
151
+ "sha256": "a339443ece37e5a5311a05ff946a081ac6e8557d29e2ab59b90832e4ad40fc5d",
152
+ "updated": "2026-10-10T20:42:00Z"
153
153
  }
154
154
  ]
155
155
  }
@@ -746,16 +746,140 @@ for it would find it, and the room would still be briefly the wrong size — a v
746
746
  by a hidden one. The platform refuses the field outright, for this reason: a
747
747
  developer meets the rule locally rather than as a 400 in production.
748
748
 
749
+ ## 5c. Queueing for a match: tickets
750
+
751
+ `quickJoin()` puts a player in a room at once and lets the room fill around them. That is right for a
752
+ drop-in mode and wrong for one that must not start short — a duel, a team mode — because the first
753
+ player in sits alone in a lobby, and a match is sized by who is there when it starts.
754
+
755
+ `queueForMatch()` is the other way in. The player waits in a queue; when the platform has enough
756
+ players for one of the templates the ticket named, it makes the room, seats every one of them and
757
+ starts the match. Nobody hosts, and nobody calls `startMatch()`.
758
+
759
+ ```js
760
+ const ticket = await gem.rooms.queueForMatch({ templateSlugs: ['duel', 'brawl'] });
761
+ showSearching({ expiresAt: ticket.expiresAt, onCancel: () => ticket.cancel() });
762
+
763
+ const outcome = await ticket.outcome;
764
+ if (outcome.status === 'matched') enterMatch(outcome.room);
765
+ else backToMenu(outcome.status);
766
+ ```
767
+
768
+ **A template has to opt in.** A room template is queueable when its `matchmaking` block says
769
+ `enabled: true`; a ticket naming one that does not is refused whole, **400
770
+ `matchmaking_not_enabled`**. `fill_window_seconds` in that block is how long a queue holds out for
771
+ `max_players` before it accepts `min_players`. The block cannot be combined with `auto_start_host`.
772
+
773
+ **A ticket names every mode the player accepts**, one to eight templates, and is matched under
774
+ whichever fills first. That is one ticket, not several: a player can be on one ticket at a time, across
775
+ every game.
776
+
777
+ ### What the SDK does while the ticket waits
778
+
779
+ - **It keeps the ticket alive.** A ticket nobody heartbeats is expired by the platform, so that a
780
+ player who closed the tab does not hold a place in a lobby. The interval is the platform's setting
781
+ and arrives on the ticket; the SDK reads it from there and a game never sends a heartbeat.
782
+ - **The heartbeat is also how it learns the answer.** Each one returns the ticket as it stands, so a
783
+ match is noticed within one interval even when nothing crosses the bridge.
784
+ - **A relayed push makes it ask at once.** The match's own start is relayed like any room event (§3),
785
+ and one that arrives about a room the manager is not in, while a ticket waits, is treated as a cue:
786
+ the ticket is read immediately, and once more a second later if it still says `queued` — the
787
+ platform sends the push a moment before it marks the ticket. So on a page that relays, a match is
788
+ noticed in about the time the push takes, not at the next heartbeat. A tab returning to the
789
+ foreground is the same cue. Like every relay it is a hint: nothing is entered on its strength.
790
+ - **It refuses a second thing in flight.** While a ticket waits, `create`, `join`, `joinByCode`,
791
+ `quickJoin` and another `queueForMatch` are refused locally.
792
+ - **It cancels the ticket when the tab closes.** See below.
793
+ - **It raises no `seated` for the ticket's own room.** The owner is told by the outcome. Party
794
+ members, who hold no ticket, are told by `seated` as they are for any room their leader enters.
795
+
796
+ ### Queueing from inside a room
797
+
798
+ A player sitting in a hub or a social lobby can queue without leaving it. The ticket takes no seat
799
+ while it waits, and `gem.rooms.currentRoom` stays the room they are in.
800
+
801
+ When the ticket is matched, **the manager moves and the seat stays**. `currentRoom` becomes the
802
+ match's room and `joined` fires for it. The room the player queued from is not left: no leave is
803
+ sent, no `left` is raised, and the platform still counts them as a member there. When the match is
804
+ over, `gem.rooms.leave()` then `gem.rooms.join(hub.id)` puts them back — `join()` on a room a player
805
+ already holds a seat in answers with the room.
806
+
807
+ Keep the hub's id if you want that return trip: `currentRoom` is the match's room now, and the
808
+ manager does not hand the old one back.
809
+
810
+ **A tab closed before the player goes back leaves both rooms.** The SDK remembers the room a matched
811
+ ticket moved it out of, and on `pagehide` sends a keepalive leave for it as well as for the room it
812
+ is in. Otherwise the hub seat would outlive the page with nothing tracking it, and the player's next
813
+ session would start already inside a room. Once the player is back in the hub, it is the tracked
814
+ room again and the ordinary leave covers it.
815
+
816
+ ### When the tab closes
817
+
818
+ **The ticket is cancelled as the page goes away.** On `pagehide` the SDK sends the cancel as a
819
+ keepalive request, which the browser delivers after the document is gone, and settles the ticket
820
+ `cancelled`. Without it a closed tab's ticket would stay matchable until the platform noticed nobody
821
+ was keeping it alive, and a match formed in that window seats a player who is not there.
822
+
823
+ This also happens when the page is put in the back/forward cache. A frozen page keeps nothing alive,
824
+ so the player cannot come back to the same ticket; they come back to one that reads `cancelled` and
825
+ queue again.
826
+
827
+ The platform's own timeout is still there for a tab that crashed or lost its network, where no
828
+ request can be sent. It is the fallback, not the mechanism.
829
+
830
+ ### How a ticket ends
831
+
832
+ `ticket.outcome` settles once, and **resolves** for every ending the platform reports — these are
833
+ outcomes to branch on, not errors to catch:
834
+
835
+ | `status` | `reason` | What happened | Queue again? |
836
+ | --- | --- | --- | --- |
837
+ | `matched` | — | Seated, and the match is starting. `gem.rooms.currentRoom` is `outcome.room`, `joined` has fired, and the match events follow as for any room. | — |
838
+ | `expired` | `timed_out` | Nobody to play with before the ticket's expiry. | on a fresh press |
839
+ | `expired` | `heartbeat_lapsed` | The tab was throttled or offline too long to keep it alive. | on a fresh press |
840
+ | `cancelled` | `requested` | `ticket.cancel()`. | — |
841
+ | `cancelled` | `party_changed` | The party gained or lost a member while it waited. | once the party is settled |
842
+ | `failed` | `handoff_failed` | A match formed and could not be started, several times over. | later; this is the platform's fault |
843
+ | `lost` | — | The platform no longer has the ticket. Queues are held in memory and a restart loses them. | yes |
844
+ | `unavailable` | — | Matchmaking is turned off, or stopped answering for longer than a ticket survives. | not in a loop |
845
+
846
+ `lost` and `unavailable` are the SDK's own conclusions. A heartbeat answered "no such ticket" ends
847
+ the wait as `lost` at once. Three heartbeats in a row that got no usable answer end it too — as
848
+ `unavailable` when the platform said matchmaking is off, `lost` otherwise — and the SDK sends one
849
+ cancel on its way out, in case the requests arrived and only the answers were lost.
850
+
851
+ It **rejects** in one case: the ticket was matched and the room it was seated in could not be read. The
852
+ seat stands, so `myRooms()` shows the room and `join(id)` enters it.
853
+
854
+ **`cancel()` can lose the race.** A ticket the platform has already seated is never undone, so
855
+ `cancel()` resolves the ticket's real outcome, and that may be `matched`. Backing out from there is
856
+ `gem.rooms.leave()`.
857
+
858
+ ### The refusals when queueing
859
+
860
+ `queueForMatch()` itself rejects with `GemApiError` when no ticket was made. Branch on `code`:
861
+
862
+ | Code | Meaning |
863
+ | --- | --- |
864
+ | `already_queued` (409) | This player, or someone in their party, is already on a ticket — another tab, or another game. |
865
+ | `matchmaking_not_enabled` (400) | A named template has no enabled `matchmaking` block. A config fault, not a player one. |
866
+ | `matchmaking_unavailable` (503) | Matchmaking is turned off for the whole platform, or its store is not answering. |
867
+
868
+ ⚠️ **Nothing falls back to quick-join, and a game should not either.** Not on
869
+ `matchmaking_unavailable`, not on `expired`. Quick-join puts the player in a different kind of room —
870
+ alone, in a mode that was queued for precisely because it needs a lobby. Tell the player matchmaking is
871
+ unavailable and leave the button where it is.
872
+
749
873
  ## 6. Scope
750
874
 
751
875
  **In:** rooms, membership, host, ready state, the match lifecycle, and the dedicated-server connect
752
876
  flow reshaped around them. P2P _allocation_ falls out of the same shape — `startMatch` is one route
753
877
  and a P2P match simply reports `ready` at once.
754
878
 
755
- **Out:** matchmaking beyond the engine's find-or-create. `quickJoin()` is exactly `quick_join`: join
756
- an open public room matching the template, in a cluster this player can play well in, or create one.
757
- There is no ranked
758
- system, no skill rating and no queue, and inventing one is not a small addition to this.
879
+ **Out:** matching by anything but arrival order. `quickJoin()` is exactly `quick_join`: join an open
880
+ public room matching the template, in a cluster this player can play well in, or create one.
881
+ `queueForMatch()` (§5c) waits for a full lobby, oldest ticket first. There is no ranked system and no
882
+ skill rating, and inventing one is not a small addition to this.
759
883
 
760
884
  **Elsewhere:** P2P _connection_ and parties. Both are
761
885
  [`p2p-and-parties.md`](p2p-and-parties.md). Three things surface here:
@@ -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/v9.1.4/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v9.1.4/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v10.1.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v10.1.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
package/docs/vibration.md CHANGED
@@ -23,7 +23,9 @@ Two rules come before everything else on this page. Both are easy to get wrong.
23
23
 
24
24
  ## The whole integration
25
25
 
26
- Your game owns its vibration toggle; the Arcade has none. Keep the toggle in
26
+ Your game owns its vibration toggle. The Arcade keeps only the player's Arcade-wide default,
27
+ `gem.prefs.recommended.vibration`, which your toggle starts from until the player flips it
28
+ ([game settings](what-a-game-must-do.md#game-settings)). Keep the player's choice in
27
29
  `gem.deviceSettings`, never in `localStorage`, which every publish of your game wipes.
28
30
 
29
31
  ```js
@@ -32,10 +34,13 @@ await gem.awaitBridge();
32
34
  // Your settings, kept by the Arcade so they survive your next publish.
33
35
  let settings = (await gem.deviceSettings.wait(1_500)) ?? {};
34
36
 
37
+ // The player's own choice, else the Arcade's recommendation, else on.
38
+ const vibrationOn = () => settings.vibration ?? gem.prefs?.recommended?.vibration ?? true;
39
+
35
40
  // Build the row when the settings screen OPENS, never once at boot.
36
41
  function openSettings() {
37
42
  vibrationRow.hidden = !gem.vibration.available;
38
- vibrationToggle.checked = settings.vibration !== false;
43
+ vibrationToggle.checked = vibrationOn();
39
44
  }
40
45
 
41
46
  vibrationToggle.onchange = () => {
@@ -45,7 +50,7 @@ vibrationToggle.onchange = () => {
45
50
 
46
51
  // Check your own toggle before every call. Nothing else: no fallback.
47
52
  function onHit() {
48
- if (settings.vibration !== false) gem.vibration.vibrate(30);
53
+ if (vibrationOn()) gem.vibration.vibrate(30);
49
54
  }
50
55
  ```
51
56
 
@@ -564,14 +564,15 @@ credit for it until you say so.
564
564
  Five messages arrive that a game cannot ask for — the parent pushes them on its own schedule, and
565
565
  there is no route by which the frame could request one. `createGem` forwards all five:
566
566
 
567
- - **`onPrefs`** — `reducedMotion`, `muted`, `locale`, `showFrameStats`, sent once after the
568
- handshake and again on every change. Also **held**, as `gem.prefs`, because a component mounted
567
+ - **`onPrefs`** — `reducedMotion`, `muted`, `locale`, `showFrameStats` and `recommended`, sent
568
+ once after the handshake and again on every change. Also **held**, as `gem.prefs`, because a component mounted
569
569
  after the push would otherwise have missed it and cannot ask again. Read it where you need it
570
570
  rather than caching it — the locale changes mid-session. `muted` is the Arcade's mute: the SDK
571
571
  silences your page itself, and your own mute control follows it — see
572
572
  [sound](#sound-your-own-mute-control-and-the-arcades) below. `showFrameStats` you need not act
573
573
  on: the SDK counts your frames itself — see [the Arcade's FPS overlay](#the-arcades-fps-overlay). See the language section for how to combine
574
- `locale` with your own detection.
574
+ `locale` with your own detection. `recommended` is the player's Arcade-wide defaults, which your
575
+ own settings start from — see [game settings](#game-settings).
575
576
  - **`onPause`** — **pause gameplay and release the cursor.** Cooperative; nothing enforces it.
576
577
 
577
578
  ```js
@@ -586,7 +587,7 @@ there is no route by which the frame could request one. `createGem` forwards all
586
587
  the dialog sitting on top of your game.
587
588
 
588
589
  **Save on pause.** When a player on a phone chooses Return to the Arcade in
589
- [the Arcade's menu](#the-arcades-menu-your-settings-live-in-it), or presses the device's Back, your
590
+ [the Arcade's menu](#the-arcades-menu), or presses the device's Back, your
590
591
  game is **parked**, not closed:
591
592
  it receives `onPause(true)` and stays loaded while the player browses. They may come back and
592
593
  press Continue — `onPause(false)`, and play resumes where it stopped — or the parked game may be
@@ -704,8 +705,8 @@ the `var()` above, or read `gem.viewport`. The release checklist looks for the b
704
705
  On a phone your game is full-bleed, and the arcade keeps one row of its own over the top of it:
705
706
 
706
707
  - **The menu button**, in the **top-right corner**, always there. It opens
707
- [the Arcade's menu](#the-arcades-menu-your-settings-live-in-it): Return to the Arcade, your
708
- settings, mute and the rest.
708
+ [the Arcade's menu](#the-arcades-menu): Return to the Arcade, mute, the player's Arcade-wide
709
+ settings and the rest. Not your settings: those are [your own button](#game-settings).
709
710
  - **The player's wallets** (Gems, Tickets, their achievement score), in the **middle of the row**,
710
711
  [there unless your game hides them](#the-arcades-wallet-shown-unless-you-hide-it-never-drawn),
711
712
  during play as well as on your menus. They are translucent, so your game shows through them — but they are still the Arcade's,
@@ -723,8 +724,8 @@ Keep the left for Home / Back even on a screen that has neither today: a player
723
724
  is once, across every game, and information that sits there on one screen teaches them the wrong
724
725
  thing on the next.
725
726
 
726
- **Put no settings, pause-menu or burger button of your own top-right**: your settings open from the
727
- Arcade's menu, and a second button under the Arcade's would mean something else in every game.
727
+ **Your settings button goes top-right, clear of the Arcade's controls**: never under one, and below
728
+ any the Arcade keeps in that corner. [Game settings](#game-settings) shows how to place it.
728
729
 
729
730
  **How much room the top-left has.** The menu button is about 48px across — the size of a Home or
730
731
  Back button. With every wallet shown, the narrowest phones (360px wide) leave roughly 68px to the
@@ -903,22 +904,104 @@ and anything that must follow them, is a save or `gem.me.settings`:
903
904
  draws the line in full. Never block your first frame on it either: apply defaults, then apply what
904
905
  arrives.
905
906
 
906
- ## The Arcade's menu: your settings live in it
907
+ ## Game settings
907
908
 
908
- The Arcade keeps one menu button over every game, top-right, on every device. Its menu is how a
909
- player leaves (**Return to the Arcade**), mutes, goes fullscreen and reports a problem — and how they
910
- open **your settings**, once you say you have some:
909
+ **Your settings belong to your game: draw a settings button of your own, and the screen it opens.**
910
+ The Arcade does not put your settings in its menu any more — its menu holds the player's
911
+ _Arcade-wide_ settings instead — so a game with no button of its own has settings nobody can reach.
912
+
913
+ **Put the button in the top-right corner.** That is a recommendation, not a rule the Arcade
914
+ enforces, but a player who finds settings in the same corner in every game never has to look. Place
915
+ it with the reserved-area properties so it never sits under an Arcade control, whatever the Arcade
916
+ keeps in that corner on this device:
917
+
918
+ ```css
919
+ #settings-button {
920
+ position: absolute;
921
+ top: max(
922
+ var(--gem-safe-top, env(safe-area-inset-top, 0px)),
923
+ var(--gem-reserved-tr-height, 0px)
924
+ );
925
+ right: var(--gem-safe-right, 0px);
926
+ }
927
+ ```
928
+
929
+ ### Start from the player's Arcade-wide defaults: `gem.prefs.recommended`
930
+
931
+ The player sets their defaults once, in the Arcade's Settings, separately for each device — a phone
932
+ and a television can say different things. They reach your game as `gem.prefs.recommended`, in the
933
+ same push as `muted` and `reducedMotion`, and again whenever the player changes one:
934
+
935
+ | Field | Values | What it suggests |
936
+ | ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
937
+ | `volume` | `0` to `100` | Your master volume's starting level, as a percentage — divide by 100 for a gain. A level, never a mute. |
938
+ | `batterySaver` | `true` / `false` | Spend less power: a lower frame rate, fewer effects, a lower resolution. |
939
+ | `keyboardLayout` | `'wasd'` / `'arrows'` | Which keys move by default. |
940
+ | `gamepadConfirm` | `'south'` / `'east'` | Which face button confirms, by position: the bottom one (A on an Xbox controller) or the right one (B). The other one goes back. |
941
+ | `vibration` | `true` / `false` | Your vibration toggle's starting state ([if your game vibrates](#if-your-game-vibrates)). |
942
+
943
+ **They are recommendations, and the player's own choice in your game always wins:**
944
+
945
+ - **Start from `recommended` only when the player has no saved choice** for that setting. Decide it
946
+ setting by setting: a player who chose a volume and nothing else still gets the recommended
947
+ keyboard layout.
948
+ - **Never overwrite the player's own choice when the recommendation changes.** Keep what the player
949
+ chose apart from what you worked out, and save only what they chose: a recommended volume saved
950
+ as if it were theirs is one the next change to the recommendation can no longer reach. A "Reset
951
+ to defaults" in your settings clears what the player chose, so the recommendation applies again.
952
+ - **`null` means no advice**: an older Arcade, or an embedder that keeps no such settings. Use your
953
+ own defaults. Never hold your first frame for it.
954
+ - **You may ignore any of it.** A game with no keyboard support has nothing to do with
955
+ `keyboardLayout`.
956
+
957
+ ```js
958
+ const DEFAULTS = { volume: 100, batterySaver: false, keyboardLayout: 'wasd', gamepadConfirm: 'south', vibration: true };
959
+ let chosen = {}; // ONLY what the player picked in your settings screen
960
+
961
+ // The player's choice, then the Arcade's recommendation, then your own default.
962
+ const setting = (key) => chosen[key] ?? gem.prefs?.recommended?.[key] ?? DEFAULTS[key];
963
+
964
+ const gem = createGem({
965
+ // The recommendation changed: apply it again. What the player chose does not move.
966
+ onPrefs: () => applySettings(setting),
967
+ });
968
+
969
+ chosen = (await gem.deviceSettings.wait(1_500)) ?? {};
970
+ applySettings(setting);
971
+
972
+ // The player moved YOUR slider: that one setting is now theirs.
973
+ volumeSlider.oninput = () => {
974
+ chosen = { ...chosen, v: 1, volume: volumeSlider.valueAsNumber };
975
+ gem.deviceSettings.put(chosen); // debounce it
976
+ applySettings(setting);
977
+ };
978
+ ```
979
+
980
+ [Device settings](#device-settings-what-this-device-remembers-about-your-game) above is where the
981
+ player's choices are kept between sessions.
982
+
983
+ **Read `muted` and `reducedMotion` as before.** They are not recommendations and they did not move:
984
+ `muted` is the Arcade's mute, which the SDK applies for you
985
+ ([sound](#sound-your-own-mute-control-and-the-arcades)), and `reducedMotion` is the player's
986
+ accessibility setting, which your game honours.
987
+
988
+ ## The Arcade's menu
989
+
990
+ The Arcade keeps one menu button over every game, on every device. Its menu is how a player leaves
991
+ (**Return to the Arcade**), mutes, goes fullscreen, reports a problem and changes their Arcade-wide
992
+ settings — the ones that reach you as [`gem.prefs.recommended`](#game-settings). You can add up to
993
+ four entries of your own:
911
994
 
912
995
  ```js
913
- const inArcadeMenu = gem.setMenu({
914
- onSettings: () => settingsScreen.open(),
996
+ gem.setMenu({
915
997
  items: [{ id: 'how-to-play', label: t('How to play'), onSelect: () => help.open() }],
916
998
  });
917
999
  ```
918
1000
 
919
- - **`onSettings` puts a Settings entry in the menu.** The Arcade closes its menu, resumes your game,
920
- and calls it; you draw your own settings screen, as you always did. Leave it out and the menu has
921
- no Settings entry.
1001
+ - **The Arcade no longer shows a "Game settings" row for `onSettings`.** Your settings open from
1002
+ [your own button](#game-settings). `gem.setMenu({ onSettings })` is still accepted, so a game that
1003
+ passes it keeps working, and another embedder may still show an entry for it — but in the Arcade
1004
+ it adds nothing.
922
1005
  - **`items` are up to four entries of your own**, shown in a group below the Arcade's. `id` is your
923
1006
  name for the entry (1 to 32 characters); `label` is what the player reads, **already in their
924
1007
  language** (`gem.prefs.locale`) — the Arcade does not translate it, cuts it at 40 characters, and
@@ -934,14 +1017,9 @@ const inArcadeMenu = gem.setMenu({
934
1017
  session, with `onWillTerminate` first. Either way your game heard `onPause(true)` when the menu
935
1018
  opened: save there — it is the edge you are sure to get.
936
1019
 
937
- **Do not draw a settings button of your own while `gem.setMenu` returns `true`.** One menu, in one
938
- place, in every game is the point. Draw your own **only when it returns `false` after the bridge has
939
- settled** — an embedder with no menu, or your game running outside any embedder:
940
-
941
- ```js
942
- await gem.awaitBridge();
943
- ownSettingsButton.hidden = gem.setMenu({ onSettings: () => settingsScreen.open() });
944
- ```
1020
+ `gem.setMenu` returns `false` under an embedder with no menu, and outside any embedder. Your
1021
+ settings button is there either way; what such a game also needs is a way out of its own, a control
1022
+ that calls `gem.exit()`.
945
1023
 
946
1024
  Your own **back or home** button — the one that moves around inside your game — goes **top-left**,
947
1025
  which the Arcade [leaves clear](#the-arcades-own-controls-reserved-areas). It never leaves the game:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "9.1.4",
3
+ "version": "10.1.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",