@indiegems/gem-web-sdk 7.9.0 → 8.0.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.
@@ -571,73 +571,117 @@ the `var()` above, or read `gem.viewport`. The release checklist looks for the b
571
571
 
572
572
  ### The arcade's own controls: reserved areas
573
573
 
574
- On a phone your game is full-bleed, and the arcade keeps one cluster of its own over it, in the
575
- **top-right corner**:
574
+ On a phone your game is full-bleed, and the arcade keeps one row of its own over the top of it:
576
575
 
577
- - **The menu button**, in the corner itself, always there. It opens
576
+ - **The menu button**, in the **top-right corner**, always there. It opens
578
577
  [the Arcade's menu](#the-arcades-menu-your-settings-live-in-it): Return to the Arcade, your
579
578
  settings, mute and the rest.
580
- - **The player's wallets** (Gems, Tickets, their achievement score), to the left of it, while your
581
- screen [shows them](#the-arcades-wallet-show-it-never-draw-it). When gameplay hides them with
582
- `gem.setWalletVisible(false)`, the cluster shrinks to the menu button alone.
579
+ - **The player's wallets** (Gems, Tickets, their achievement score), in the **middle of the row**,
580
+ [always there](#the-arcades-wallet-always-shown-never-drawn), during play as well as on your
581
+ menus. They are translucent, so your game shows through them — but they are still the Arcade's,
582
+ and still taps the Arcade takes.
583
583
 
584
- **The top-left corner is yours.** Put your own back or home button there — the one that moves around
585
- _inside_ your game, back to your level select or your title screen. **Put no settings, pause-menu or
586
- burger button of your own top-right**: your settings open from the Arcade's menu, and a second
587
- button under the Arcade's would mean something else in every game.
584
+ **The top of the screen has one layout, in every game:**
585
+
586
+ | Where | Whose | What goes there |
587
+ | -------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
588
+ | **Top-left** | yours | Your **Home / Back** buttons, drawn when a screen needs them, in the space to the left of the wallets. They move around _inside_ your game — back to your level select or title screen — and never leave it. |
589
+ | **Middle and top-right** | the Arcade's | Its wallets and its menu button. Nothing of yours. |
590
+ | **Centre and right, below the Arcade's row** | yours | Your **information**: score, moves, a timer, a level name. It starts under the bottom of the Arcade's row, never beside it or under it. |
591
+
592
+ Keep the left for Home / Back even on a screen that has neither today: a player learns where "back"
593
+ is once, across every game, and information that sits there on one screen teaches them the wrong
594
+ thing on the next.
595
+
596
+ **Put no settings, pause-menu or burger button of your own top-right**: your settings open from the
597
+ Arcade's menu, and a second button under the Arcade's would mean something else in every game.
598
+
599
+ **How much room the top-left has.** The menu button is about 48px across — the size of a Home or
600
+ Back button. With every wallet shown, the narrowest phones (360px wide) leave roughly 68px to the
601
+ left of the wallets: room for **one** button. Two buttons, or a wide one, go on their own row below
602
+ the Arcade's. Do not copy those numbers — read the space, as below.
588
603
 
589
604
  Those rectangles of your frame are **reserved**: keep essential UI and every touch target out of
590
605
  them. **Never hard-code their size or height.** They differ by phone, and in native fullscreen the
591
606
  whole row may rise into the camera band — read them, as below.
592
607
 
593
- An inset cannot say that — as a top inset, a tab a few dozen pixels wide would take your whole top
608
+ An inset cannot say that — as a top inset, a button a few dozen pixels wide would take your whole top
594
609
  strip. So `gem.viewport` also carries the rectangles, in the same CSS pixels of your frame, measured
595
- from its top-left corner:
610
+ from its top-left corner — one per wallet shown, and one for the menu button:
596
611
 
597
612
  ```json
598
613
  {
599
614
  "insets": { "top": 47, "right": 0, "bottom": 34, "left": 0 },
600
- "reserved": [{ "x": 8, "y": 51, "width": 104, "height": 34 }]
615
+ "reserved": [
616
+ { "x": 68, "y": 51, "width": 72, "height": 28 },
617
+ { "x": 144, "y": 51, "width": 72, "height": 28 },
618
+ { "x": 220, "y": 51, "width": 72, "height": 28 },
619
+ { "x": 304, "y": 51, "width": 48, "height": 48 }
620
+ ]
601
621
  }
602
622
  ```
603
623
 
604
624
  **The SDK turns them into one pair of custom properties per corner**, written beside
605
625
  `--gem-safe-*` before anything else hears the push: `--gem-reserved-tl-width` and
606
626
  `--gem-reserved-tl-height`, and the same pair for `tr`, `bl` and `br` — how far that corner's
607
- reserved rectangles reach in from its two edges. Push the corner's UI clear of it:
627
+ reserved rectangles reach in from its two edges. A rectangle belongs to the corner its centre lies
628
+ nearest to.
629
+
630
+ ⚠️ **The wallets sit in the middle, so they count toward BOTH top corners.** A wallet left of
631
+ centre is a top-left rectangle, one right of centre a top-right one. While wallets are shown,
632
+ `--gem-reserved-tl-height` is the bottom of the wallets and `--gem-reserved-tl-width` reaches to the
633
+ middle of the screen — even though the corner itself, left of the first wallet, is clear. So:
608
634
 
609
635
  ```css
610
- /* your back button in the top-left corner, which the arcade leaves clear */
636
+ /* your information — score, moves, a timer — below the WHOLE of the arcade's row: the taller of
637
+ the two top corners. It moves up by itself when gameplay hides the wallets. */
638
+ #info {
639
+ top: max(
640
+ var(--gem-safe-top, env(safe-area-inset-top, 0px)),
641
+ var(--gem-reserved-tl-height, 0px),
642
+ var(--gem-reserved-tr-height, 0px)
643
+ );
644
+ right: var(--gem-safe-right, 0px);
645
+ }
646
+ /* ONE Home or Back button, level with the arcade's row, in the clear corner to the wallets' left */
611
647
  #back {
612
648
  top: var(--gem-safe-top, env(safe-area-inset-top, 0px));
613
649
  left: var(--gem-safe-left, 0px);
614
650
  }
615
- /* a score in the top-right corner, below the arcade's cluster */
616
- #score {
617
- top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tr-height, 0px));
618
- right: var(--gem-safe-right, 0px);
619
- }
620
- /* or a row along the top, stopping short of it */
621
- #top-bar {
622
- padding-left: var(--gem-reserved-tl-width, 0px);
623
- padding-right: var(--gem-reserved-tr-width, 0px);
651
+ /* a ROW of your own buttons does not fit beside the wallets: start it below the arcade's row,
652
+ like your information */
653
+ #nav {
654
+ top: max(
655
+ var(--gem-safe-top, env(safe-area-inset-top, 0px)),
656
+ var(--gem-reserved-tl-height, 0px),
657
+ var(--gem-reserved-tr-height, 0px)
658
+ );
624
659
  }
625
660
  ```
626
661
 
627
- All eight are written on every push, `0px` for a clear corner, so a rotation never leaves a stale
628
- value; they are never set under an embedder that sends no viewport, which is what the `0px`
629
- fallback is for. A rectangle belongs to the corner its centre lies nearest to. For a HUD laid out
630
- from script, read `gem.viewport.reserved` — `[]` when nothing is covered.
662
+ The per-corner properties cannot say how wide the clear corner is — only that the corner's
663
+ rectangles reach in to the middle. To know whether your button fits beside the wallets, read the
664
+ rectangles from script: the clear width at the top-left is the smallest `x` among
665
+ `gem.viewport.reserved` (the whole width when the list is empty).
631
666
 
632
- **Lay out against it whenever it changes.** Shown wallets widen the top-right rectangle; hiding them
633
- shrinks it to the menu button alone. A wallet the arcade pops up for a moment while they are hidden
634
- never reserves anything — it floats over your game and moves nothing. Read every corner, not only the
635
- one this page names today: `--gem-reserved-tl-*` is `0px` now, and a layout that honours it anyway
636
- is right under any embedder.
667
+ ```js
668
+ const reserved = gem.viewport?.reserved ?? [];
669
+ const clearLeft = reserved.length ? Math.min(...reserved.map((r) => r.x)) : window.innerWidth;
670
+ nav.classList.toggle('below-arcade-row', clearLeft < nav.offsetWidth + 8);
671
+ ```
672
+
673
+ All eight properties are written on every push, `0px` for a clear corner, so a rotation never leaves
674
+ a stale value; they are never set under an embedder that sends no viewport, which is what the `0px`
675
+ fallback is for. `gem.viewport.reserved` is `[]` when nothing is covered.
676
+
677
+ **Lay out against it whenever it changes.** The wallets' rectangles are there for the whole
678
+ session on the Arcade's phone layout, and their number and width can change as the player's
679
+ balances and currencies do. Read every corner, not only the ones this page names today: a layout that honours
680
+ all four is right under any embedder.
637
681
 
638
682
  **Older SDKs get it folded into the top inset.** A game on an SDK from before reserved areas cannot
639
683
  read the rectangles, so the arcade sends it none and instead raises `insets.top` — and so
640
- `--gem-safe-top` — past the bottom of the cluster. That game keeps working, at the cost of a full-width
684
+ `--gem-safe-top` — past the bottom of the row. That game keeps working, at the cost of a full-width
641
685
  band; upgrading gives the band back.
642
686
 
643
687
  ## Which store-page experiment brought this player in: cohorts
@@ -854,38 +898,25 @@ its music out than cut it, say. The Arcade still shows its button for your game
854
898
  so this is a promise: your `onPrefs` handler must go quiet on `muted: true` and come back on `false`,
855
899
  and the release checklist checks that it does.
856
900
 
857
- ## The Arcade's wallet: show it, never draw it
901
+ ## The Arcade's wallet: always shown, never drawn
858
902
 
859
903
  The player's platform balances — Gems, Tickets (`ARCADE_TICKETS`) and their achievement score —
860
904
  belong to the Arcade. **Your game never draws one**, and never shows a pending state for one: the
861
- Arcade takes every figure from the platform and draws the wallets itself, beside its menu button in
862
- the top-right corner of your frame on a phone.
863
-
864
- **They are shown unless your game hides them.** A game that does nothing has every wallet on show
865
- the whole time. The one rule: **hide them during gameplay, show them on your menus.**
866
-
867
- ```js
868
- level.onStart = () => gem.setWalletVisible(false);
869
- pauseMenu.onOpen = () => gem.setWalletVisible(true);
870
- ```
871
-
872
- - **While hidden, the Arcade still pops a wallet up** for a moment when something happens to it —
873
- a reward you cued with `gem.cueReward`, a spend, a landing, a daily-maximum note, a score change
874
- — then hides it again. Your state never cancels that, so a reward mid-level still shows.
875
- - **It is screen state, not a toggle.** Each call is the whole answer and replaces the last. Call it
876
- as often as you like: before the Arcade connects the state is held and sent when it does, and
877
- again after any reconnect; a repeat of the current state is not re-sent.
878
- - **Shown wallets take space; a pop does not.** Shown, they widen the top-right
879
- [reserved area](#the-arcades-own-controls-reserved-areas) beside the menu button, so lay your
880
- screen out against `--gem-reserved-tr-*`; hidden, it shrinks back to the button alone. A pop floats
881
- over your game and never moves its layout.
882
- - **Narrowing is optional.** On a screen that sells in one currency — a ticket store — you may show
883
- only that one: `gem.setWalletVisible(true, { only: ['ARCADE_TICKETS'] })`, with `'score'` for the
884
- achievement score. Use it only there; leaving it out shows every wallet, which is always safe.
885
- - **Where the Arcade docks its wallets beside your frame** — a desktop page — they are always on
886
- show, and this changes nothing. Call it anyway; the same code is right everywhere.
887
- - **`false` means nobody is listening** — no connection yet, or an embedder that draws no wallet.
888
- Do nothing in its place.
905
+ Arcade takes every figure from the platform and draws the wallets itself, in the middle of the row
906
+ across the top of your frame on a phone, with its menu button at the right end.
907
+
908
+ **The Arcade shows every wallet the whole time, during play too** (since 2026-10-04). They are
909
+ small, translucent chips in the middle of the top row, and they are
910
+ [reserved rectangles](#the-arcades-own-controls-reserved-areas): lay every screen out below both
911
+ `--gem-reserved-tl-height` and `--gem-reserved-tr-height`, gameplay included. There is nothing for
912
+ your game to call.
913
+
914
+ ⚠️ **`gem.setWalletVisible` no longer hides anything in the Arcade.** It was how a game hid the
915
+ wallets for gameplay and showed them on its menus, and narrowed them to one currency on a store
916
+ screen. The Arcade still accepts the call — it returns `true`, and existing games need no change —
917
+ but the wallets stay up whatever it is told. **Do not design a screen around the top row being
918
+ clear during play**: it is not. Another embedder may still honour the call, so leaving it in is
919
+ harmless; new code does not need it.
889
920
 
890
921
  ## No accidental text selection
891
922
 
@@ -1152,6 +1183,18 @@ or after an `await`; and **never gate a reward on it**, because your game is tol
1152
1183
  not even whether the player shared.
1153
1184
  [Linking to your game](getting-started.md#linking-to-your-game) is the section.
1154
1185
 
1186
+ ## If your game vibrates
1187
+
1188
+ Not on the list above either. `navigator.vibrate` does nothing on any iPhone, so call
1189
+ `gem.vibration.vibrate` instead: it takes the same pattern, returns the same boolean, and vibrates
1190
+ wherever the player's device can, the Gem Arcade iPhone app included.
1191
+
1192
+ Two things before you reach for it: **read `gem.vibration.available` live, after `awaitBridge()`**,
1193
+ to decide whether to show your vibration toggle, because on an iPhone it is `false` until the
1194
+ bridge connects; and **never call `navigator.vibrate`**, guarded or not, because the SDK already
1195
+ does wherever that works. Your game owns the toggle and keeps it in `gem.deviceSettings`.
1196
+ [`vibration.md`](vibration.md) is the page.
1197
+
1155
1198
  ## A decision the player must not make, if your game has one
1156
1199
 
1157
1200
  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.0",
3
+ "version": "8.0.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -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",
@@ -191,6 +191,15 @@
191
191
  "gem.requestShare"
192
192
  ]
193
193
  },
194
+ "vibration": {
195
+ "implemented": true,
196
+ "gates": [
197
+ "gem.vibrate",
198
+ {
199
+ "namespace": "gem.vibration"
200
+ }
201
+ ]
202
+ },
194
203
  "gameMenu": {
195
204
  "implemented": true,
196
205
  "gates": [