@indiegems/gem-web-sdk 13.2.2 → 13.3.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.
@@ -23,8 +23,8 @@
23
23
  },
24
24
  {
25
25
  "file": "arcade-economy.md",
26
- "sha256": "9058c26bc11266f235dfa3c72c80a3fc962aa9f204ef491006e6d34545f5eeeb",
27
- "updated": "2026-10-09T00:24:00Z"
26
+ "sha256": "338dcdfe4aaa90241f7132677a0135d0ec8e32336f4302413dc0f11391b37623",
27
+ "updated": "2026-10-15T23:20:43Z"
28
28
  },
29
29
  {
30
30
  "file": "async-opponents.md",
@@ -38,8 +38,8 @@
38
38
  },
39
39
  {
40
40
  "file": "changelog.md",
41
- "sha256": "1b3fc8bb0464ca818298376b96cb20f1eb611059d2b9b9f2f8c538b57b19a559",
42
- "updated": "2026-10-15T19:06:19Z"
41
+ "sha256": "1f95ccc1c4e84075e6f8a7a7d36403593196c3d7bc271f5cd3a992c77becf3de",
42
+ "updated": "2026-10-15T23:30:59Z"
43
43
  },
44
44
  {
45
45
  "file": "daily-challenges.md",
@@ -68,8 +68,8 @@
68
68
  },
69
69
  {
70
70
  "file": "getting-started.md",
71
- "sha256": "b152abdec73f8b72c39f896bf64faccf3a1924a1cf2b42bae566ff41752a836a",
72
- "updated": "2026-10-15T19:06:19Z"
71
+ "sha256": "d131f187f5a3146a6ec13289d889606c4de770378921dc3b56b7c1b678b5f162",
72
+ "updated": "2026-10-15T23:30:59Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -128,8 +128,8 @@
128
128
  },
129
129
  {
130
130
  "file": "server-bundles.md",
131
- "sha256": "55c841c525b7a765baa6749f30602291a884f05819d2afbb4f32138d215dd2a8",
132
- "updated": "2026-10-15T19:06:19Z"
131
+ "sha256": "f6654b2865d04cbcb8ed1bb1e157107e27d96348d74e8bf1be040a747991b743",
132
+ "updated": "2026-10-15T23:30:59Z"
133
133
  },
134
134
  {
135
135
  "file": "serverless-functions-js.md",
@@ -153,8 +153,8 @@
153
153
  },
154
154
  {
155
155
  "file": "what-a-game-must-do.md",
156
- "sha256": "439683ca206b2230e48ab46d5eb9a945964fb6f2e308ffcc8ec1e1b9423fc5e8",
157
- "updated": "2026-10-15T13:34:34Z"
156
+ "sha256": "e97ca7a5b01a40d57900eb1116e961768f62bccfd412afd02bca245cdb7beeab",
157
+ "updated": "2026-10-15T20:36:02Z"
158
158
  }
159
159
  ]
160
160
  }
@@ -0,0 +1,219 @@
1
+ {
2
+ "$comment": [
3
+ "The published fields whose value is PlayerText (player-authored text), and the",
4
+ "first release from which each has been PlayerText in that entry. String(), a",
5
+ "template literal or JSON.stringify on one shows \"[gem: use gem.text.toPlain()]\";",
6
+ "convert it with text.toPlain(). `entry` is the package export (`sdk` is",
7
+ "@indiegems/gem-web-sdk/sdk), `field` is <Type>.<property>, and `since` is null",
8
+ "for a field no release has shipped yet. Written by",
9
+ "`node scripts/player-text-fields.mjs`."
10
+ ],
11
+ "schema_version": 1,
12
+ "fields": [
13
+ {
14
+ "entry": "function",
15
+ "field": "Friend.displayName",
16
+ "since": "7.6.0"
17
+ },
18
+ {
19
+ "entry": "function",
20
+ "field": "Friend.displayTag",
21
+ "since": "7.6.0"
22
+ },
23
+ {
24
+ "entry": "function",
25
+ "field": "LeaderboardEntry.displayName",
26
+ "since": "7.6.0"
27
+ },
28
+ {
29
+ "entry": "function",
30
+ "field": "LeaderboardEntry.displayTag",
31
+ "since": "7.6.0"
32
+ },
33
+ {
34
+ "entry": "function",
35
+ "field": "PlayerIdentity.displayName",
36
+ "since": "7.6.0"
37
+ },
38
+ {
39
+ "entry": "function",
40
+ "field": "PlayerIdentity.displayTag",
41
+ "since": "7.6.0"
42
+ },
43
+ {
44
+ "entry": "function",
45
+ "field": "PlayerProfile.displayName",
46
+ "since": "7.6.0"
47
+ },
48
+ {
49
+ "entry": "function",
50
+ "field": "PlayerProfile.displayTag",
51
+ "since": "7.6.0"
52
+ },
53
+ {
54
+ "entry": "function",
55
+ "field": "ShowcaseFriendEntry.displayName",
56
+ "since": "7.6.0"
57
+ },
58
+ {
59
+ "entry": "function",
60
+ "field": "ShowcaseFriendEntry.displayTag",
61
+ "since": "7.6.0"
62
+ },
63
+ {
64
+ "entry": "function",
65
+ "field": "Transfer.message",
66
+ "since": "7.6.0"
67
+ },
68
+ {
69
+ "entry": "game",
70
+ "field": "Friend.displayName",
71
+ "since": "5.11.0"
72
+ },
73
+ {
74
+ "entry": "game",
75
+ "field": "Friend.displayTag",
76
+ "since": "5.11.0"
77
+ },
78
+ {
79
+ "entry": "game",
80
+ "field": "GemPlayer.displayHandle",
81
+ "since": "1.2.0"
82
+ },
83
+ {
84
+ "entry": "game",
85
+ "field": "GemPlayer.displayName",
86
+ "since": "1.2.0"
87
+ },
88
+ {
89
+ "entry": "game",
90
+ "field": "GemPlayer.displayTag",
91
+ "since": "5.11.0"
92
+ },
93
+ {
94
+ "entry": "game",
95
+ "field": "LeaderboardEntry.displayName",
96
+ "since": "5.11.0"
97
+ },
98
+ {
99
+ "entry": "game",
100
+ "field": "LeaderboardEntry.displayTag",
101
+ "since": "5.11.0"
102
+ },
103
+ {
104
+ "entry": "game",
105
+ "field": "PlayerIdentity.displayName",
106
+ "since": "5.11.0"
107
+ },
108
+ {
109
+ "entry": "game",
110
+ "field": "PlayerIdentity.displayTag",
111
+ "since": "5.11.0"
112
+ },
113
+ {
114
+ "entry": "game",
115
+ "field": "PlayerProfile.displayName",
116
+ "since": "5.11.0"
117
+ },
118
+ {
119
+ "entry": "game",
120
+ "field": "PlayerProfile.displayTag",
121
+ "since": "5.11.0"
122
+ },
123
+ {
124
+ "entry": "game",
125
+ "field": "ShowcaseFriendEntry.displayName",
126
+ "since": "5.11.0"
127
+ },
128
+ {
129
+ "entry": "game",
130
+ "field": "ShowcaseFriendEntry.displayTag",
131
+ "since": "5.11.0"
132
+ },
133
+ {
134
+ "entry": "sdk",
135
+ "field": "Friend.displayName",
136
+ "since": "5.11.0"
137
+ },
138
+ {
139
+ "entry": "sdk",
140
+ "field": "Friend.displayTag",
141
+ "since": "5.11.0"
142
+ },
143
+ {
144
+ "entry": "sdk",
145
+ "field": "GemPlayer.displayHandle",
146
+ "since": "3.2.0"
147
+ },
148
+ {
149
+ "entry": "sdk",
150
+ "field": "GemPlayer.displayName",
151
+ "since": "3.2.0"
152
+ },
153
+ {
154
+ "entry": "sdk",
155
+ "field": "GemPlayer.displayTag",
156
+ "since": "5.11.0"
157
+ },
158
+ {
159
+ "entry": "sdk",
160
+ "field": "GemServerProfile.displayName",
161
+ "since": "3.2.0"
162
+ },
163
+ {
164
+ "entry": "sdk",
165
+ "field": "GemServerProfile.displayTag",
166
+ "since": "5.11.0"
167
+ },
168
+ {
169
+ "entry": "sdk",
170
+ "field": "LeaderboardEntry.displayName",
171
+ "since": "5.11.0"
172
+ },
173
+ {
174
+ "entry": "sdk",
175
+ "field": "LeaderboardEntry.displayTag",
176
+ "since": "5.11.0"
177
+ },
178
+ {
179
+ "entry": "sdk",
180
+ "field": "PlayerIdentity.displayName",
181
+ "since": "5.11.0"
182
+ },
183
+ {
184
+ "entry": "sdk",
185
+ "field": "PlayerIdentity.displayTag",
186
+ "since": "5.11.0"
187
+ },
188
+ {
189
+ "entry": "sdk",
190
+ "field": "PlayerProfile.displayName",
191
+ "since": "5.11.0"
192
+ },
193
+ {
194
+ "entry": "sdk",
195
+ "field": "PlayerProfile.displayTag",
196
+ "since": "5.11.0"
197
+ },
198
+ {
199
+ "entry": "sdk",
200
+ "field": "ShowcaseFriendEntry.displayName",
201
+ "since": "5.11.0"
202
+ },
203
+ {
204
+ "entry": "sdk",
205
+ "field": "ShowcaseFriendEntry.displayTag",
206
+ "since": "5.11.0"
207
+ },
208
+ {
209
+ "entry": "server",
210
+ "field": "GemServerProfile.displayName",
211
+ "since": "2.2.0"
212
+ },
213
+ {
214
+ "entry": "server",
215
+ "field": "GemServerProfile.displayTag",
216
+ "since": "5.11.0"
217
+ }
218
+ ]
219
+ }
@@ -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/v13.2.2/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v13.2.2/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v13.3.1/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v13.3.1/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -1189,6 +1189,10 @@ What it does, so you know what to expect:
1189
1189
  - **Your budget is `1000 / targetFps`**, and `targetFps` is 60 unless you say. A game that caps
1190
1190
  itself at 30 frames a second on battery saver passes `targetFps: 30`, or every frame reads as
1191
1191
  slow.
1192
+ - **Capping your frame rate? Report your draws, or the Arcade's FPS overlay still reads 60.** The
1193
+ overlay counts the frames the browser offers your page, not the frames you draw. Call
1194
+ `gem.reportFrame()` each time you draw and it counts your draws instead, so a player can see
1195
+ Battery saver working. See [The Arcade's FPS overlay](#the-arcades-fps-overlay).
1192
1196
  - **It never counts a paused game**: not while the Arcade has you paused (`gem.paused`), and not
1193
1197
  while the tab is hidden. It also ignores the first 2 seconds, which are your game loading.
1194
1198
  - **It holds a hand-picked tier.** While `graphicsQualityAuto` is `false` it stays at the start and
@@ -1371,15 +1375,31 @@ Arcade, which draws a small readout in its own chrome. **Your game draws nothing
1371
1375
  is sent until `showFrameStats` arrives `true`; the counter stops when it goes `false`, while the
1372
1376
  Arcade has your game paused (`onPause`), and when the bridge closes. A game whose player never
1373
1377
  presses the toggle never runs any of it.
1374
- - **What the number measures.** The `requestAnimationFrame` ticks the browser delivers to your
1375
- game's document. That is exactly your render rate if you render on that callback — three.js,
1376
- Phaser and Babylon.js do, and so does any engine's web export — and it is measured inside your
1377
- frame on purpose:
1378
- the Arcade page cannot count your frames from outside, because a cross-origin iframe renders on
1379
- its own schedule, and a page-side counter would read a steady 60 over a game hitching at 25.
1380
- - **If you render in a worker.** A game that draws through an `OffscreenCanvas` in a worker is
1381
- timed on its main thread here, which is not its renderer. There is no hook yet for such a game to
1382
- report its own figure; if you need one, ask.
1378
+ - **What the number measures, by default: the frames your page is offered, not the frames you
1379
+ draw.** It counts the `requestAnimationFrame` ticks the browser delivers to your game's
1380
+ document. That is your render rate only if you draw on every one of them — three.js, Phaser and
1381
+ Babylon.js do unless you cap them — and it is measured inside your frame on purpose: the Arcade
1382
+ page cannot count your frames from outside, because a cross-origin iframe renders on its own
1383
+ schedule, and a page-side counter would read a steady 60 over a game hitching at 25.
1384
+ - **If you skip frames, report your draws.** A game that caps itself on Battery saver by drawing
1385
+ every other tick still receives 60 ticks a second, so the default figure reads about 60 and
1386
+ nobody can see the cap working. The same goes for a game that draws through an `OffscreenCanvas`
1387
+ in a worker, which the SDK can only time on its main thread. Call `gem.reportFrame()` each time
1388
+ your game draws a frame, and the overlay counts your draws instead, labelled as the game's own
1389
+ figure:
1390
+
1391
+ ```js
1392
+ function render() {
1393
+ renderer.render(scene, camera);
1394
+ gem.reportFrame(); // once per frame you actually draw
1395
+ }
1396
+ ```
1397
+
1398
+ Call it on **every** frame you draw, from your first: the first call switches the overlay to your
1399
+ draws for the rest of the session, so a screen that draws without it reads as dropped frames, and
1400
+ a second call for one frame counts it twice. It is free while the overlay is off — a flag check,
1401
+ nothing sent — so leave it in your loop. A worker renderer posts to the main thread when it draws
1402
+ and calls it there.
1383
1403
  - **Do not draw an FPS counter of your own over the Arcade's.** Two readouts that disagree — and
1384
1404
  they will, since they count different things — teach a player that one of them is lying. A debug
1385
1405
  overlay in your own builds is fine; keep it off in an Arcade build, or at least off by default.
@@ -1390,6 +1410,10 @@ Arcade, which draws a small readout in its own chrome. **Your game draws nothing
1390
1410
  `showFrameStats` is also on `gem.prefs` for you to read, held like the rest, but the only reason to
1391
1411
  read it is to know the Arcade is already showing one.
1392
1412
 
1413
+ The browser offers no way to count the frames it actually put on screen for your page, which is
1414
+ why your draws come from you: without `gem.reportFrame()` the SDK cannot tell a frame you drew from
1415
+ one you skipped.
1416
+
1393
1417
  ## The player's language: prefer what the page sends, detect the rest
1394
1418
 
1395
1419
  **`gem.prefs.locale` is the language the Arcade page is RENDERING in.** That is the one fact a frame
@@ -1492,6 +1516,103 @@ and showcase rows used to be plain strings and are now `PlayerText`, as they alr
1492
1516
  members. `gem.player.displayHandle` and `gem.player.avatarUrl` still work, and are
1493
1517
  [deprecated](deprecation-policy.md) in favour of `displayTag` and `avatarIconUrl`.
1494
1518
 
1519
+ ### A name turned into a string: what catches it
1520
+
1521
+ `PlayerText` is not a string, so TypeScript refuses `el.innerHTML = who.displayName`, a `string`
1522
+ variable or parameter, and string methods such as `.length` and `.toUpperCase()`. **Four uses
1523
+ still compile**, because each accepts any value, and each puts `[gem: use gem.text.toPlain()]` on
1524
+ screen where the name should be:
1525
+
1526
+ ```js
1527
+ String(who.displayName); // and who.displayName.toString()
1528
+ `Hi ${who.displayName}`;
1529
+ 'Hi ' + who.displayName;
1530
+ JSON.stringify({ name: who.displayName });
1531
+ ```
1532
+
1533
+ A game that moved to a release where a field became `PlayerText` and kept a line like these
1534
+ compiles, builds and uploads cleanly, and shows players the placeholder. Three things catch it
1535
+ first:
1536
+
1537
+ - **The console, while you develop.** On a page served from your own machine (your bundler's dev
1538
+ or preview server, `gemctl serve`, with or without `--dev`), the first time each field is turned
1539
+ into a string the SDK logs one `console.warn` naming the field and the fix, with a stack trace
1540
+ that points at your line. A published game logs nothing, and the placeholder is the same.
1541
+ - **A lint rule.** If your ESLint runs typescript-eslint with type information, its
1542
+ `restrict-template-expressions` and `restrict-plus-operands` rules already flag the template and
1543
+ the `+`. This rule flags all four; paste it into `eslint.config.js`:
1544
+
1545
+ ```js
1546
+ // Flags String(x), x.toString(), `${x}`, '…' + x and JSON.stringify(x) on PlayerText.
1547
+ const noPlayerTextString = {
1548
+ meta: {
1549
+ type: 'problem',
1550
+ schema: [],
1551
+ messages: {
1552
+ stringified:
1553
+ 'This is PlayerText: as a string it shows "[gem: use gem.text.toPlain()]". Convert it with text.toPlain().',
1554
+ },
1555
+ },
1556
+ create(context) {
1557
+ const services = context.sourceCode.parserServices;
1558
+ const checker = services.program.getTypeChecker();
1559
+ const isPlayerText = (type) =>
1560
+ (type.isUnion() ? type.types : [type]).some((t) => t.getSymbol()?.getName() === 'PlayerText');
1561
+ const typeOf = (node) => checker.getTypeAtLocation(services.esTreeNodeToTSNodeMap.get(node));
1562
+ const check = (node) => {
1563
+ if (node && isPlayerText(typeOf(node))) context.report({ node, messageId: 'stringified' });
1564
+ };
1565
+ // JSON.stringify also looks one level into an object: { name: who.displayName }.
1566
+ const checkJson = (node) => {
1567
+ if (!node) return;
1568
+ const type = typeOf(node);
1569
+ const tsNode = services.esTreeNodeToTSNodeMap.get(node);
1570
+ const inside = checker
1571
+ .getPropertiesOfType(type)
1572
+ .some((p) => isPlayerText(checker.getTypeOfSymbolAtLocation(p, tsNode)));
1573
+ if (isPlayerText(type) || inside) context.report({ node, messageId: 'stringified' });
1574
+ };
1575
+ return {
1576
+ CallExpression(node) {
1577
+ const { callee } = node;
1578
+ if (callee.type === 'Identifier' && callee.name === 'String') check(node.arguments[0]);
1579
+ if (callee.type !== 'MemberExpression' || callee.computed) return;
1580
+ const isJson = callee.object.type === 'Identifier' && callee.object.name === 'JSON';
1581
+ if (isJson && callee.property.name === 'stringify') checkJson(node.arguments[0]);
1582
+ if (callee.property.name === 'toString') check(callee.object);
1583
+ },
1584
+ TemplateLiteral(node) {
1585
+ // A tagged template is the tag's to handle, and an html`` tag escapes.
1586
+ if (node.parent.type !== 'TaggedTemplateExpression') node.expressions.forEach(check);
1587
+ },
1588
+ BinaryExpression(node) {
1589
+ if (node.operator === '+') [node.left, node.right].forEach(check);
1590
+ },
1591
+ AssignmentExpression(node) {
1592
+ if (node.operator === '+=') check(node.right);
1593
+ },
1594
+ };
1595
+ },
1596
+ };
1597
+
1598
+ export default [
1599
+ // …your existing config, with type information on (parserOptions.projectService: true)
1600
+ {
1601
+ files: ['**/*.ts', '**/*.tsx', '**/*.mts'],
1602
+ plugins: { gem: { rules: { 'no-player-text-string': noPlayerTextString } } },
1603
+ rules: { 'gem/no-player-text-string': 'error' },
1604
+ },
1605
+ ];
1606
+ ```
1607
+
1608
+ It needs type information, so it lints TypeScript only. In plain JavaScript, rely on the console
1609
+ warning.
1610
+ - **The field list.** The package carries `docs/player-text-fields.json`: every field that holds
1611
+ `PlayerText`, by entry (`sdk` is `@indiegems/gem-web-sdk/sdk`), each with the first release in
1612
+ which that entry shipped it as `PlayerText`. Search your code for every field it lists, not only
1613
+ the ones that are new since your last pin: a field that has been `PlayerText` for years
1614
+ stringifies to the placeholder just the same.
1615
+
1495
1616
  ### The signed-in player's Boost: show it, never gate on it
1496
1617
 
1497
1618
  `gem.player` also carries **`boost`** — the player's Boost, which the Arcade sells for real money.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "13.2.2",
3
+ "version": "13.3.1",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -58,6 +58,7 @@
58
58
  "docs/manifest.json",
59
59
  "docs/api/*.md",
60
60
  "docs/api/reference.json",
61
+ "docs/player-text-fields.json",
61
62
  "CHANGELOG.md"
62
63
  ]
63
64
  }
@@ -3302,9 +3302,17 @@
3302
3302
  "WHAT IT COUNTS. The frame document's own requestAnimationFrame delivery,",
3303
3303
  "which is exact for an engine that renders on that callback (three.js,",
3304
3304
  "Phaser, Babylon.js, any engine's web export) and is what the SDK measures",
3305
- "with no code from the game. A game that renders in a worker through OffscreenCanvas is",
3306
- "measured on its main thread here, not at its renderer; reporting its own",
3307
- "figure is a hook this version does not carry.",
3305
+ "with no code from the game. It is NOT the frames the game draws: a game",
3306
+ "honouring Battery saver by drawing every other tick still reads about 60,",
3307
+ "and a game rendering in a worker through OffscreenCanvas is timed on its",
3308
+ "main thread, not at its renderer.",
3309
+ "",
3310
+ "OR WHAT THE GAME SAYS IT DREW. A game that calls `gem.reportFrame()` each",
3311
+ "time it draws is counted by its draws instead, from its first call for",
3312
+ "the rest of the session, and `counts` says which figure this is, so the",
3313
+ "Arcade labels it rather than calling draws page frames or the reverse.",
3314
+ "No web API reports the compositor's presented frames to a document, so",
3315
+ "the game's report is the only figure that counts draws.",
3308
3316
  "",
3309
3317
  "SENT ONLY WHILE ASKED. Nothing is counted and nothing is sent until",
3310
3318
  "`gem.prefs.showFrameStats` arrives true, and the counter stops on false",
@@ -3326,7 +3334,20 @@
3326
3334
  ],
3327
3335
  "fields": {
3328
3336
  "fps": { "kind": "int", "min": 0, "max": 1000 },
3329
- "frameTimeP95Ms": { "kind": "int", "min": 0, "max": 100000 }
3337
+ "frameTimeP95Ms": { "kind": "int", "min": 0, "max": 100000 },
3338
+ "counts": {
3339
+ "kind": "enum",
3340
+ "values": ["pageFrames", "gameDraws"],
3341
+ "optional": true,
3342
+ "$comment": [
3343
+ "Which figure `fps` and `frameTimeP95Ms` are. `pageFrames`: the",
3344
+ "requestAnimationFrame ticks delivered to the game's document, measured",
3345
+ "by the SDK. `gameDraws`: the frames the game reported drawing through",
3346
+ "`gem.reportFrame()`. ABSENT from an SDK before this field, which always",
3347
+ "measured page frames, so a receiver reads absent as `pageFrames`.",
3348
+ "Additive: a parent that ignores it draws the numbers as before."
3349
+ ]
3350
+ }
3330
3351
  },
3331
3352
  "rateLimit": { "perMinute": 120 }
3332
3353
  },