@indiegems/gem-web-sdk 7.9.1 → 8.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.
@@ -0,0 +1,141 @@
1
+ # Vibration
2
+
3
+ `navigator.vibrate` does nothing on any iPhone. It does not exist in Safari, in any other iOS
4
+ browser, or in the Gem Arcade iPhone app, and Firefox removed it on every platform. A game that
5
+ calls it vibrates on Android and nowhere else.
6
+
7
+ `gem.vibration` vibrates wherever the player's device can. It takes the same pattern as
8
+ `navigator.vibrate` and returns the same boolean, so switching is a rename:
9
+
10
+ ```js
11
+ navigator.vibrate(30); // before: Android only
12
+ gem.vibration.vibrate(30); // after: Android, and the Gem Arcade iPhone app
13
+ ```
14
+
15
+ Two rules come before everything else on this page. Both are easy to get wrong.
16
+
17
+ 1. **Read `gem.vibration.available` live, after `awaitBridge()`, every time you need it.** On an
18
+ iPhone it is `false` until the bridge to the Arcade connects. If you build your settings screen
19
+ at boot and hide the vibration toggle then, it stays hidden on exactly the device this exists
20
+ for.
21
+ 2. **Never call `navigator.vibrate`**, with or without a guard, and never write a fallback to it.
22
+ `gem.vibration.vibrate` already calls `navigator.vibrate` itself wherever that works.
23
+
24
+ ## The whole integration
25
+
26
+ Your game owns its vibration toggle; the Arcade has none. Keep the toggle in
27
+ `gem.deviceSettings`, never in `localStorage`, which every publish of your game wipes.
28
+
29
+ ```js
30
+ await gem.awaitBridge();
31
+
32
+ // Your settings, kept by the Arcade so they survive your next publish.
33
+ let settings = (await gem.deviceSettings.wait(1_500)) ?? {};
34
+
35
+ // Build the row when the settings screen OPENS, never once at boot.
36
+ function openSettings() {
37
+ vibrationRow.hidden = !gem.vibration.available;
38
+ vibrationToggle.checked = settings.vibration !== false;
39
+ }
40
+
41
+ vibrationToggle.onchange = () => {
42
+ settings = { ...settings, v: 1, vibration: vibrationToggle.checked };
43
+ void gem.deviceSettings.put(settings);
44
+ };
45
+
46
+ // Check your own toggle before every call. Nothing else: no fallback.
47
+ function onHit() {
48
+ if (settings.vibration !== false) gem.vibration.vibrate(30);
49
+ }
50
+ ```
51
+
52
+ There is no `available` check before `vibrate`: where vibration is not available, `vibrate` does
53
+ nothing and returns `false`. `available` exists to decide whether to show the toggle.
54
+
55
+ ## What a pattern is
56
+
57
+ Exactly what `navigator.vibrate` takes. A number is one vibration of that many milliseconds; an
58
+ array alternates vibration and pause, starting with a vibration.
59
+
60
+ - **`0` or `[]` stops** a pattern that is still playing.
61
+ - **A new call replaces** whatever is still playing.
62
+ - **The pattern is capped**: at most 99 entries, each at most 10 000 ms, and **at most 10 seconds
63
+ in all**, vibrations and pauses together. A longer pattern is cut short, not refused.
64
+ - **A negative, `NaN` or infinite entry refuses the whole pattern**: `vibrate` returns `false`.
65
+
66
+ `vibrate` returns `true` when the pattern was accepted, never that a motor moved: the player may
67
+ have vibration switched off in their phone's settings, which your game cannot see.
68
+
69
+ ## Nothing vibrates before the first tap
70
+
71
+ Browsers allow vibration only after the player has tapped or pressed a key in your game. Before
72
+ that, `vibrate` returns `false` and vibrates nothing, on Android and iPhone alike. So a buzz on
73
+ load, or on a title screen nobody has touched, never plays.
74
+
75
+ ## A cookbook, by feel
76
+
77
+ On an iPhone, a vibration of 40 ms or less plays as a crisp tap, stronger the longer it is; a
78
+ longer one plays as a continuous buzz. On Android every entry is a buzz of that length.
79
+
80
+ | Feel | Pattern | Use it for |
81
+ | --- | --- | --- |
82
+ | tick | `10` | a menu cursor moving, a dial clicking |
83
+ | tap | `20` | a button press, a piece placed |
84
+ | hit | `30` to `50` | taking damage, a landing, a collision |
85
+ | heavy | `100` | an explosion, a boss hit; use it rarely |
86
+ | alert | `[200, 100, 200]` | a warning, a timer running out |
87
+ | cancel | `0` | stopping whatever is playing |
88
+
89
+ Keep hit feedback at about 100 ms or less. Anything longer stops feeling like feedback and starts
90
+ feeling like a phone call.
91
+
92
+ ## Stop on pause
93
+
94
+ When your game pauses, stop anything still playing:
95
+
96
+ ```js
97
+ onPause: (paused) => {
98
+ if (paused) gem.vibration.vibrate(0);
99
+ },
100
+ ```
101
+
102
+ The Arcade also stops a running pattern itself when it pauses your game or the player leaves it,
103
+ but a pause your game makes on its own is yours to stop.
104
+
105
+ ## Where it vibrates
106
+
107
+ | Where the player is | Vibrates? |
108
+ | --- | --- |
109
+ | An Android phone, in the Gem Arcade app or in Chrome | yes |
110
+ | An iPhone, in the Gem Arcade app | yes |
111
+ | An iPhone or iPad, in Safari or any other browser | no: `available` is `false` |
112
+ | An iPad, in the Gem Arcade app | no: an iPad has no vibration motor |
113
+ | Firefox, anywhere | no: `available` is `false` |
114
+ | A desktop or laptop with a mouse | no: `available` is `false` |
115
+
116
+ A laptop with a touch screen may report `available` as `true` and then vibrate nothing. That is
117
+ harmless; it is why the toggle is your player's to switch off.
118
+
119
+ ## Gamepad rumble is separate
120
+
121
+ A controller's rumble is `GamepadHapticActuator` (`gamepad.vibrationActuator`), not
122
+ `navigator.vibrate`. It is unaffected by any of this, and `gem.vibration` does not drive it.
123
+
124
+ ## What not to do
125
+
126
+ - **`navigator.vibrate?.(x)`.** It is still a direct call: it vibrates on Android and silently
127
+ nowhere else.
128
+ - **`if ('vibrate' in navigator)` to decide whether to show the toggle.** It hides the toggle on
129
+ every iPhone, where `gem.vibration` works. Use `gem.vibration.available`.
130
+ - **A wrapper that falls back to `navigator.vibrate`.** `gem.vibration.vibrate` already does that
131
+ where it works, and nowhere else does a fallback do anything.
132
+ - **A user-agent check** to hide the toggle on iOS. Read `available`; it already knows.
133
+ - **Reading `available` once at boot.** It is `false` on an iPhone until the bridge connects.
134
+ - **Vibrating on load or every frame.** Nothing plays before the first tap, and a call every frame
135
+ is past the limit of 600 a minute: past it, `vibrate` returns `false`.
136
+ - **Hit feedback longer than about 100 ms.**
137
+ - **Keeping the toggle in `localStorage`.** It is gone after your next publish; use
138
+ `gem.deviceSettings`.
139
+
140
+ When `vibrate` refuses a call for a reason you can fix (no tap yet, a bad pattern, the limit), the
141
+ SDK says so once a session through your `log` option, which is `console.warn` unless you set it.
@@ -353,6 +353,117 @@ one refused orientation, in fullscreen, once they are already the right way roun
353
353
  That is best effort (iOS does not implement it, and the native app's webview declines
354
354
  it), which is why the panel exists and why it always offers a way past.
355
355
 
356
+ **The same declaration decides how a computer or a television shows your game.** Neither is held
357
+ two ways round, so the Arcade reads your phone and tablet rows instead and, for a portrait-only
358
+ game, draws a centred portrait column.
359
+ [Art beside a portrait game](#art-beside-a-portrait-game-on-a-wide-screen-portrait-borders) below
360
+ has the exact rule, and the art you can put beside that column.
361
+
362
+ ## Art beside a portrait game on a wide screen: portrait borders
363
+
364
+ **You need this only if your game is portrait-only and you want your own art beside it on a
365
+ computer or a television.** On a desktop browser, the Arcade's desktop app or a TV, the Arcade
366
+ draws a portrait-only game as a centred 9:16 column and fills the space either side with its own
367
+ bars. Portrait borders put your art there instead: you publish a named set of images with your
368
+ store page, and your game says which one goes with what is on screen.
369
+
370
+ ### When the Arcade letterboxes a game
371
+
372
+ **Your store page's `orientations` decide it, and nothing else does.** Declaring borders never
373
+ turns the letterbox on, and there is no desktop orientation to declare. The rule:
374
+
375
+ 1. Take `tablet` if you declared both its `portrait` and its `landscape`; otherwise `phone` if you
376
+ declared both of its; otherwise stop, and the game is not letterboxed.
377
+ 2. The game is letterboxed to portrait when, on that device, `portrait` is `full` or `partial` and
378
+ `landscape` is `none`.
379
+
380
+ | `orientations`, each pair portrait / landscape | Letterboxed on a computer or TV |
381
+ | ---------------------------------------------- | ------------------------------- |
382
+ | nothing declared | no |
383
+ | tablet `full` / `none` | yes |
384
+ | phone `full` / `none`, no tablet | yes |
385
+ | phone `partial` / `none`, no tablet | yes |
386
+ | phone `full` / `none`, tablet `full` / `full` | no — the tablet decides |
387
+ | phone `full` / `none`, tablet `none` / `full` | no — the tablet decides |
388
+ | tablet `none` / `full` | no |
389
+ | tablet `partial` / `partial` | no |
390
+ | tablet `full` / `partial` | no |
391
+
392
+ A phone or a tablet is never letterboxed: it gets the rotate panel from the section above. On a
393
+ computer or TV, a game the rule does not letterbox fills the play area and its borders never show.
394
+ **A store page with borders and orientations that never letterbox uploads with a
395
+ `portrait_borders_unused` warning** — the page is accepted, and the art will not appear anywhere.
396
+
397
+ ### Declaring the borders
398
+
399
+ Add them to your store page's `media`, one entry per image:
400
+
401
+ ```yaml
402
+ media:
403
+ portrait_borders:
404
+ - name: meadow # the first entry is the default
405
+ src: borders/meadow.jpg
406
+ - name: alpine
407
+ src: borders/alpine.jpg
408
+ ```
409
+
410
+ - **Up to 16 entries**, each with a `name` and a `src`. A name is 1 to 40 characters of `a-z`,
411
+ `0-9`, `_` and `-`, starts with a letter or digit, and is unique on the page.
412
+ - **The first entry is the default.** The Arcade shows it the moment play starts, before your game
413
+ has said anything, so put first the one most players should see.
414
+ - **16:9, at least 1920×1080; 2560×1440 is ideal.** PNG, JPEG or WebP. An AVIF, GIF or SVG uploads
415
+ without complaint and then fails the page's processing.
416
+ - **Opaque.** A transparent area shows the Arcade's own bars through it.
417
+ - **Keep the interesting part away from the middle.** The image is scaled to cover the whole play
418
+ area and centred, and your game covers the centre. How much shows either side changes with the
419
+ shape of the player's screen — in a window narrower than the column, the art shows above and
420
+ below instead — so nothing in the art can line up with your game's edge.
421
+ - **Upload with a current `gemctl`.** An older one refuses `portrait_borders` as an unknown key.
422
+
423
+ ### Choosing one from your game
424
+
425
+ Call `gem.showPortraitBorders` at start-up and on every theme change, with the name of the border
426
+ that goes with what is on screen, and `null` for a theme that has no art:
427
+
428
+ ```js
429
+ const BORDERS = new Set(['meadow', 'alpine']); // the names your store page declares
430
+
431
+ function applyTheme(id) {
432
+ document.body.dataset.theme = id;
433
+ gem.showPortraitBorders(BORDERS.has(id) ? id : null); // null: the Arcade's own bars show again
434
+ }
435
+ ```
436
+
437
+ - **Call it unconditionally.** On a phone, or on a screen where your game is not letterboxed, the
438
+ Arcade keeps your choice and shows nothing, and nothing tells your game which case it is in — do
439
+ not look for a signal. `gem.frame.parentSupports('portraitBorders')` says whether this Arcade can
440
+ paint borders at all, never whether it is painting them now.
441
+ - **It is safe at any time.** Before the connection exists your choice is held and sent when the
442
+ Arcade connects. A burst of calls — a theme picker previewing on hover — reaches the Arcade as
443
+ its final value, at most one a second, so it never trips a rate limit. An embedder that paints
444
+ no borders is not told, and nothing is raised.
445
+ - **The Arcade owns the change.** When and how the image swaps is the Arcade's; you get no
446
+ acknowledgement and no say in the timing. A game that restores a saved theme just after it
447
+ starts shows the default for a moment, then its own.
448
+ - **It shows only while your game is playing** — not before play starts, and not while your key
449
+ art covers the play area.
450
+ - **A string that is not a valid name is refused on your side**, with a console warning, and never
451
+ sent. The SDK does not trim it, lower-case it or shorten it.
452
+ - **A valid name your page does not declare is ignored**, and the Arcade keeps what it is showing.
453
+ On the dev and test channels the Arcade's own page logs a console warning naming the value, the
454
+ channel whose page it looked in, and the names that page declares; on release it says nothing.
455
+
456
+ ### Publishing order and experiments
457
+
458
+ - **Publish the store page before, or together with, the build that uses a new name.** A build
459
+ asking for a name its page does not carry yet keeps the current image until the page arrives.
460
+ Rolling a build back is harmless; rolling the page back can remove a name, and the Arcade then
461
+ keeps showing what it has.
462
+ - **Every arm of a store-page experiment must carry every name your build uses.** The set comes
463
+ from the page the player opened, so an arm without a name falls under the rule above.
464
+ - **The borders come from the store page on screen.** To see your dev-channel borders, open your
465
+ game's store page on the dev channel. `gemctl serve` does not letterbox and shows no borders.
466
+
356
467
  ## An achievement unlock — celebrate it yourself, or declare the Arcade's
357
468
 
358
469
  By default the Arcade draws no card when a player unlocks one of your game's
@@ -429,8 +540,10 @@ credit for it until you say so.
429
540
  entry fields few and ordinary — and never gate first play behind one. A player made to type on a
430
541
  remote before they may play mostly does not.
431
542
  - **A TV performance budget.** TV-browser SoCs are weak next to any desktop: ship a player-settable
432
- render-scale option (render at DPR 1 and upscale), bound memory, assume a fixed 16:9 frame — there
433
- is no `orientationchange` rescue coming — and keep your heights `dvh`-safe.
543
+ render-scale option (render at DPR 1 and upscale), bound memory, assume a fixed frame — 16:9, or
544
+ a centred 9:16 column when your store page makes you
545
+ [a letterboxed portrait game](#art-beside-a-portrait-game-on-a-wide-screen-portrait-borders) —
546
+ with no `orientationchange` rescue coming, and keep your heights `dvh`-safe.
434
547
  - **Reduced motion.** TV browsers rarely expose `prefers-reduced-motion`, so the pushed
435
548
  `gem.prefs.reducedMotion` is the only signal a TV player has; honor it. It arrives with
436
549
  [the prefs the parent pushes](#what-the-parent-says-about-the-session-prefs-pause-and-terminate)
@@ -1016,7 +1129,15 @@ renderer serves all of them.
1016
1129
  | `displayTag` | The precomposed `Name#0001` | `PlayerText` |
1017
1130
  | `avatarIconId` | The portrait item's id | `string` |
1018
1131
  | `avatarIconUrl` | The portrait's thumbnail | `string` |
1019
- | `badges` | Equipped badges in display order, each `{ itemId, iconUrl }` — always a list, empty for now | array |
1132
+ | `badges` | Equipped badges, at most two, in the player's order, each `{ itemId, iconUrl }` | array |
1133
+
1134
+ **A player chooses what they wear**: a portrait and up to two badges, each an icon they own — one
1135
+ an achievement in your game or any other may have awarded them ([achievements.md](achievements.md)
1136
+ §1). The portrait arrives as `avatarIconId` and `avatarIconUrl`, and the badges as `badges`, in the
1137
+ order the player put them in. `badges` is always a list — empty for a player who wears none, and on
1138
+ a row whose identity is withheld — so draw what is there, never pad it to two, and key on `itemId`,
1139
+ never on a position. A player can also equip a title, and colours and animations for their name and
1140
+ title. The SDK's identity does not carry those, so draw the name in your own style.
1020
1141
 
1021
1142
  **The name and the tag are player-authored, so both are `PlayerText`** on every surface, not only
1022
1143
  some. Convert with `text.toPlain()` where you render, never before.
@@ -1183,6 +1304,18 @@ or after an `await`; and **never gate a reward on it**, because your game is tol
1183
1304
  not even whether the player shared.
1184
1305
  [Linking to your game](getting-started.md#linking-to-your-game) is the section.
1185
1306
 
1307
+ ## If your game vibrates
1308
+
1309
+ Not on the list above either. `navigator.vibrate` does nothing on any iPhone, so call
1310
+ `gem.vibration.vibrate` instead: it takes the same pattern, returns the same boolean, and vibrates
1311
+ wherever the player's device can, the Gem Arcade iPhone app included.
1312
+
1313
+ Two things before you reach for it: **read `gem.vibration.available` live, after `awaitBridge()`**,
1314
+ to decide whether to show your vibration toggle, because on an iPhone it is `false` until the
1315
+ bridge connects; and **never call `navigator.vibrate`**, guarded or not, because the SDK already
1316
+ does wherever that works. Your game owns the toggle and keeps it in `gem.deviceSettings`.
1317
+ [`vibration.md`](vibration.md) is the page.
1318
+
1186
1319
  ## A decision the player must not make, if your game has one
1187
1320
 
1188
1321
  Not on the list above either: most games trust the browser with everything they do and lose nothing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "7.9.1",
3
+ "version": "8.1.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -590,7 +590,7 @@
590
590
  "kind": "array",
591
591
  "maxItems": 2,
592
592
  "optional": true,
593
- "$comment": "Equipped badges in display order — empty until platform achievements equip any, and never more than two. Each is an item id and a thumbnail URL, the same pin caveat as `avatarIconUrl`.",
593
+ "$comment": "Equipped badges in the order the player arranged them — never more than two, and empty when the player wears none. Each is an item id and a thumbnail URL, the same pin caveat as `avatarIconUrl`.",
594
594
  "items": {
595
595
  "kind": "object",
596
596
  "fields": {
@@ -2604,6 +2604,66 @@
2604
2604
  }
2605
2605
  },
2606
2606
 
2607
+ {
2608
+ "type": "gem.vibrate",
2609
+ "direction": "frameToParent",
2610
+ "transport": "port",
2611
+ "$comment": [
2612
+ "Ask the parent to play a vibration pattern on the player's device, where",
2613
+ "the frame cannot: `navigator.vibrate` is undefined in every WebKit (so in",
2614
+ "every iOS browser and the iOS app's WKWebView) and in Firefox 129 and",
2615
+ "later. Gated by the `vibration` capability: sent only to a parent that",
2616
+ "advertised it.",
2617
+ "",
2618
+ "THE SAME VALUE `navigator.vibrate` WOULD HAVE BEEN GIVEN. `pattern` is the",
2619
+ "Vibration API's pattern, already validated and normalized by the SDK:",
2620
+ "alternating on and off durations in milliseconds, starting with on, with",
2621
+ "no trailing pause. An empty array CANCELS a running pattern. A new",
2622
+ "message replaces whatever is still playing.",
2623
+ "",
2624
+ "ONE WAY, AND NOTHING ANSWERS IT. No requestId, and no message travels",
2625
+ "back: the game already has its answer (`gem.vibration.vibrate` returns",
2626
+ "whether the message left the frame), and a reply would tell a game the",
2627
+ "state of the parent's own gates.",
2628
+ "",
2629
+ "WHAT THE PARENT OWES, none of which this schema can enforce:",
2630
+ "",
2631
+ " REFUSE A TOTAL OVER 10 000 ms. The sum of every entry, on and off, is",
2632
+ " at most 10 000. The SDK truncates to that before sending; a message",
2633
+ " over it was written by hand, and is refused whole, never clamped.",
2634
+ "",
2635
+ " GATE ON THE FRAME'S OWN ACTIVATION, NOT THE PAGE'S. The player's Play",
2636
+ " click has already activated the parent document before any game runs,",
2637
+ " so the parent's sticky activation proves nothing about the game. Drop",
2638
+ " the message until a tap inside this frame document has activated it.",
2639
+ "",
2640
+ " DROP IT WHILE THE GAME CANNOT BE SEEN: the page hidden, or the game",
2641
+ " paused behind the parent's own menu. Cancel a running pattern on pause",
2642
+ " or hide.",
2643
+ "",
2644
+ " SEND NOTHING BACK. No outcome, no acknowledgement, and no error for a",
2645
+ " refusal - not for an unadvertised type, a malformed or oversized",
2646
+ " pattern, or the rate limit - so a game cannot read the parent's gates",
2647
+ " or its limiter.",
2648
+ "",
2649
+ "Not an Arcade surface: the parent draws nothing and the player sees",
2650
+ "nothing of the platform's. It is a device effect the game would have had",
2651
+ "from `navigator.vibrate` on a Chromium browser."
2652
+ ],
2653
+ "fields": {
2654
+ "pattern": {
2655
+ "kind": "array",
2656
+ "maxItems": 99,
2657
+ "$comment": "At least 0 entries (an empty array cancels) and at most 99, Chromium's bound. The total is at most 10 000 ms, which the parent checks: a sum has no field shape.",
2658
+ "items": { "kind": "int", "min": 0, "max": 10000 }
2659
+ }
2660
+ },
2661
+ "rateLimit": {
2662
+ "perMinute": 600,
2663
+ "$comment": "Bounds a BUGGY game - one that vibrates every frame - at ten a second. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK. What bounds the motor is the parent's native throttle and its duty-cycle budget."
2664
+ }
2665
+ },
2666
+
2607
2667
  {
2608
2668
  "type": "gem.frameStats",
2609
2669
  "direction": "frameToParent",
@@ -2655,6 +2715,47 @@
2655
2715
  "rateLimit": { "perMinute": 120 }
2656
2716
  },
2657
2717
 
2718
+ {
2719
+ "type": "gem.portraitBorders",
2720
+ "direction": "frameToParent",
2721
+ "transport": "port",
2722
+ "$comment": [
2723
+ "Which of the store page's portrait borders the parent paints beside the",
2724
+ "game while it letterboxes a portrait game on a wide screen. `name` is one",
2725
+ "of the names the page declares under `media.portrait_borders`; absent",
2726
+ "means clear the sides to the parent's own bars, for a theme with no art.",
2727
+ "",
2728
+ "A KEY INTO PUBLISHED ART, NEVER THE ART. The images are the store page's,",
2729
+ "served and reviewed like every other store image; the frame chooses only",
2730
+ "which one and when. The parent looks `name` up by exact match in the list",
2731
+ "the page it is showing declares, and never renders it as text or builds a",
2732
+ "URL from it. A name that list does not hold is ignored and the current",
2733
+ "image stays. Transitions, their timing and any minimum dwell are the",
2734
+ "parent's, swaps wait while a trusted overlay is up, and the frame gets no",
2735
+ "acknowledgement.",
2736
+ "",
2737
+ "`name` is a plain bounded string with no format: a border name may carry",
2738
+ "digits and hyphens, which the `code` format refuses. The SDK sends only",
2739
+ "names matching `^[a-z0-9][a-z0-9_-]{0,39}$`, the store page's own rule, and",
2740
+ "a parent checks the same rule on receipt.",
2741
+ "",
2742
+ "STATE, NOT A MOMENT. The SDK holds the game's latest choice until the",
2743
+ "bridge connects and sends at most one a second, latest wins, so a burst",
2744
+ "(a theme picker previewing on hover) delivers its final value well inside",
2745
+ "the limit below.",
2746
+ "",
2747
+ "Gated by `portraitBorders`, which a parent that paints borders advertises",
2748
+ "to EVERY game on every device, whether or not the game declares borders or",
2749
+ "is letterboxed right now. It is never a signal of form factor or letterbox",
2750
+ "state, and the parent answers nothing, so the frame learns nothing about",
2751
+ "how it is being shown."
2752
+ ],
2753
+ "fields": {
2754
+ "name": { "kind": "string", "maxLength": 40, "optional": true }
2755
+ },
2756
+ "rateLimit": { "perMinute": 60 }
2757
+ },
2758
+
2658
2759
  {
2659
2760
  "type": "gem.error",
2660
2761
  "direction": "both",
@@ -123,6 +123,12 @@
123
123
  "gem.frameStats"
124
124
  ]
125
125
  },
126
+ "portraitBorders": {
127
+ "implemented": true,
128
+ "gates": [
129
+ "gem.portraitBorders"
130
+ ]
131
+ },
126
132
  "engagement": {
127
133
  "implemented": true,
128
134
  "gates": [
@@ -191,6 +197,15 @@
191
197
  "gem.requestShare"
192
198
  ]
193
199
  },
200
+ "vibration": {
201
+ "implemented": true,
202
+ "gates": [
203
+ "gem.vibrate",
204
+ {
205
+ "namespace": "gem.vibration"
206
+ }
207
+ ]
208
+ },
194
209
  "gameMenu": {
195
210
  "implemented": true,
196
211
  "gates": [