@indiegems/gem-web-sdk 10.2.0 → 10.3.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.
package/docs/changelog.md CHANGED
@@ -16,6 +16,22 @@ Its entries are grouped under up to five headings:
16
16
  - **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
17
17
  - **New capability**: something a game can now do. Nothing changes unless you use it.
18
18
 
19
+ ## 10.3.0
20
+
21
+ Upgrading from 10.2.0. Impact: required.
22
+
23
+ ### Rebuild
24
+
25
+ - The set of messages a frame sends to the Arcade has grown by one controls declaration message, so the message list and the FrameMessage union changed. Recompiling is enough. If your code switches exhaustively over frame message types, add a case for the new one.
26
+ - GemCapability gained a controls value. Recompiling is enough. If you map every capability to something exhaustively, add an entry for it.
27
+ - The recommended settings inside the preferences gained four optional fields for look sensitivity, gamepad look sensitivity, look inversion and touch movement. Recompiling is enough. The touch movement value appears as a plain string in the received preferences, while the host-side sendPrefs accepts only floating or fixed, so treat unknown values as the default.
28
+
29
+ ### New capability
30
+
31
+ - Your game can now declare which control choices it offers by using Gem.controls, which is a ControlsApi, or GemFrame.declareControls. You describe the choices with GemControlChoice entries inside a GemControlsDeclaration. Declaring them lets the Arcade show the matching camera and controls settings for your game. Check that the new controls capability is available before declaring.
32
+ - The SDK now provides camera look input. GemLook delivers GemLookDelta values, configured with GemLookOptions and scaled by a GemLookSpeed. GemLookBase gives the base look speed. Use these instead of your own look scaling so that the player's sensitivity choices apply to your game.
33
+ - The player's recommended settings now carry four optional fields: lookSensitivity, gamepadLookSensitivity, invertLookY and touchMovement. Read them to start with the player's preferred look sensitivity, look inversion and touch movement style. They may be absent, so keep your own defaults for when they are not provided.
34
+
19
35
  ## 10.2.0
20
36
 
21
37
  Upgrading from 10.1.1. Impact: required.
@@ -50,7 +50,7 @@ either way.
50
50
  ```jsonc
51
51
  // package.json
52
52
  "dependencies": {
53
- "@indiegems/gem-web-sdk": "10.2.0"
53
+ "@indiegems/gem-web-sdk": "10.3.0"
54
54
  }
55
55
  ```
56
56
 
@@ -33,8 +33,8 @@
33
33
  },
34
34
  {
35
35
  "file": "changelog.md",
36
- "sha256": "d7178e17c79418dc26cb685c667eb998b0d09dca6b1b2760268812c6af466151",
37
- "updated": "2026-10-11T16:47:39Z"
36
+ "sha256": "daae6b71cecc9761bc1e3ed5603c5d8f366e0533ce4ecc6d6c57f546efe1a292",
37
+ "updated": "2026-10-11T18:22:44Z"
38
38
  },
39
39
  {
40
40
  "file": "cross-promotion.md",
@@ -68,8 +68,8 @@
68
68
  },
69
69
  {
70
70
  "file": "getting-started.md",
71
- "sha256": "5758f3a68307af4650e6af3e74cba48e7110dd9c405aefa33b4eaa1c73b3f85a",
72
- "updated": "2026-10-11T16:47:39Z"
71
+ "sha256": "fd566ed7d7f0b1b2b9bea823de5455d2ee9a66072e7303df1bc5c12c1acddcbe",
72
+ "updated": "2026-10-11T18:22:44Z"
73
73
  },
74
74
  {
75
75
  "file": "inventory-and-catalogs.md",
@@ -123,8 +123,8 @@
123
123
  },
124
124
  {
125
125
  "file": "server-bundles.md",
126
- "sha256": "a0e995c9a4f05515f39c5958918bd38bccb53e1c20bf13959f81d7694aa72ef0",
127
- "updated": "2026-10-11T16:47:39Z"
126
+ "sha256": "c6935f10ba645e8a9d3fd48c10f313324bfdbca6fcbd273dfade6579dcfd32e8",
127
+ "updated": "2026-10-11T18:22:44Z"
128
128
  },
129
129
  {
130
130
  "file": "serverless-functions-js.md",
@@ -148,8 +148,8 @@
148
148
  },
149
149
  {
150
150
  "file": "what-a-game-must-do.md",
151
- "sha256": "2ef43f70b16f0004e6b82178a2b370b84e68b87754e50a649cf78164177244f0",
152
- "updated": "2026-10-11T16:21:27Z"
151
+ "sha256": "44fa07b8f379181cfac9a89c8fa672946d0e4aaeefe1fd3386cc3565abcb3c95",
152
+ "updated": "2026-10-11T17:59:34Z"
153
153
  }
154
154
  ]
155
155
  }
@@ -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/v10.2.0/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v10.2.0/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v10.3.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v10.3.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -939,6 +939,16 @@ same push as `muted` and `reducedMotion`, and again whenever the player changes
939
939
  | `keyboardLayout` | `'wasd'` / `'arrows'` | Which keys move by default. |
940
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
941
  | `vibration` | `true` / `false` | Your vibration toggle's starting state ([if your game vibrates](#if-your-game-vibrates)). |
942
+ | `lookSensitivity` | `20` to `400` | Look speed for touch, mouse and pen, as a percentage of the base speeds: `100` is 1×. |
943
+ | `gamepadLookSensitivity` | `20` to `400` | Look speed for a gamepad stick, as a percentage: `100` is 1×. |
944
+ | `invertLookY` | `true` / `false` | Up/down look inverted: pushing up or dragging up looks down. Pitch only, never yaw. |
945
+ | `touchMovement` | `'floating'` / `'fixed'` | On a touch screen, a movement stick that appears where the thumb lands, or one fixed in place. |
946
+
947
+ The last four are the camera and controls settings: see
948
+ [camera and look controls](#camera-and-look-controls) for telling the Arcade which of them your game
949
+ uses, and for `gem.controls.look()`, which applies the two sensitivities and the invert for you. An
950
+ older Arcade sends `recommended` without them; the SDK then fills each with its default (`100`,
951
+ `100`, `false`, `'floating'`), so whenever `recommended` is not `null` all nine fields are there.
942
952
 
943
953
  **They are recommendations, and the player's own choice in your game always wins:**
944
954
 
@@ -955,7 +965,10 @@ same push as `muted` and `reducedMotion`, and again whenever the player changes
955
965
  `keyboardLayout`.
956
966
 
957
967
  ```js
958
- const DEFAULTS = { volume: 100, batterySaver: false, keyboardLayout: 'wasd', gamepadConfirm: 'south', vibration: true };
968
+ const DEFAULTS = {
969
+ volume: 100, batterySaver: false, keyboardLayout: 'wasd', gamepadConfirm: 'south', vibration: true,
970
+ lookSensitivity: 100, gamepadLookSensitivity: 100, invertLookY: false, touchMovement: 'floating',
971
+ };
959
972
  let chosen = {}; // ONLY what the player picked in your settings screen
960
973
 
961
974
  // The player's choice, then the Arcade's recommendation, then your own default.
@@ -985,6 +998,94 @@ player's choices are kept between sessions.
985
998
  ([sound](#sound-your-own-mute-control-and-the-arcades)), and `reducedMotion` is the player's
986
999
  accessibility setting, which your game honours.
987
1000
 
1001
+ ## Camera and look controls
1002
+
1003
+ If your game turns a camera — a drag on a phone, a mouse under pointer lock, a gamepad's right
1004
+ stick — the player has four settings for it in the Arcade, per device, the way Roblox players do:
1005
+ **look sensitivity**, **gamepad look sensitivity**, **invert look up/down** and **touch movement**
1006
+ (a floating or a fixed thumbstick). They reach you in `gem.prefs.recommended`
1007
+ ([the table above](#start-from-the-players-arcade-wide-defaults-gemprefsrecommended)). Two calls
1008
+ make them work.
1009
+
1010
+ **1. Declare which ones your game uses — `gem.controls.declare`.** For each setting, `'player'`
1011
+ means you apply the Arcade's value (or the player's own choice in your settings, which still wins);
1012
+ `'game'` means your game decides it, and the Arcade's Settings shows that row as "Set by this game";
1013
+ leave out a setting your game does not use, and the Arcade says so beside it. Call it once at boot:
1014
+
1015
+ ```js
1016
+ gem.controls.declare({
1017
+ lookSensitivity: 'player',
1018
+ gamepadLookSensitivity: 'player',
1019
+ invertLookY: 'player',
1020
+ touchMovement: 'game', // this game always draws a fixed stick
1021
+ });
1022
+ ```
1023
+
1024
+ It is screen state, like [`gem.setWalletVisible`](#the-arcades-wallet-shown-unless-you-hide-it-never-drawn):
1025
+ each call is the whole answer, the latest wins, and the SDK holds it and sends it again after a
1026
+ reconnect. It returns `false` before the bridge is up (it is held and sent on connect) and under an
1027
+ embedder that does not show these settings; it never throws. A key or a value other than these is
1028
+ dropped.
1029
+
1030
+ **2. Turn look input into a camera turn — `gem.controls.look()`.** It is pure math: it listens to
1031
+ nothing and moves no camera. Give it the deltas your input code already has; it returns radians to
1032
+ **add**, `yaw` positive turning right and `pitch` positive looking up, at Roblox's base speeds
1033
+ (`gem.controls.LOOK_BASE`: touch 1° across and 0.66° up/down per CSS px, mouse 0.5° and 0.385°,
1034
+ a stick 240° and 184.8° a second at full tilt), times the player's sensitivity, with the invert
1035
+ applied. It also does what is easy to get wrong: the stick's deadzone (0.1) and curve, dragging up
1036
+ to look up, and on touch a slower pitch as the camera nears straight up or down.
1037
+
1038
+ ```js
1039
+ import * as THREE from 'three';
1040
+
1041
+ const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 500);
1042
+ camera.rotation.order = 'YXZ'; // yaw, then pitch
1043
+ let yaw = 0;
1044
+ let pitch = 0;
1045
+ const look = gem.controls.look(); // make one, keep it
1046
+
1047
+ function turn({ yaw: dYaw, pitch: dPitch }) {
1048
+ yaw -= dYaw; // three.js: a positive rotation.y turns LEFT, so subtract to turn right
1049
+ pitch = THREE.MathUtils.clamp(pitch + dPitch, -1.45, 1.45); // your clamp, as always
1050
+ camera.rotation.set(pitch, yaw, 0);
1051
+ }
1052
+
1053
+ // Touch and pen: the change since the last event. A mouse: movementX/Y under pointer lock.
1054
+ let last = null;
1055
+ canvas.addEventListener('pointerdown', (e) => { last = { x: e.clientX, y: e.clientY }; });
1056
+ canvas.addEventListener('pointerup', () => { last = null; });
1057
+ canvas.addEventListener('pointermove', (e) => {
1058
+ if (e.pointerType === 'mouse') {
1059
+ if (document.pointerLockElement === canvas) turn(look.fromPointer(e.movementX, e.movementY, 'mouse'));
1060
+ return;
1061
+ }
1062
+ if (!last) return;
1063
+ turn(look.fromPointer(e.clientX - last.x, e.clientY - last.y, e.pointerType, pitch));
1064
+ last = { x: e.clientX, y: e.clientY };
1065
+ });
1066
+
1067
+ // The right stick, once per frame.
1068
+ function frame(dt) {
1069
+ const pad = navigator.getGamepads().find(Boolean);
1070
+ if (pad) turn(look.fromStick(pad.axes[2], pad.axes[3], dt));
1071
+ }
1072
+ ```
1073
+
1074
+ - **Read live.** The sensitivities and the invert are read from `gem.prefs.recommended` on every
1075
+ call, so a change the player makes in the Arcade's Settings mid-game applies on the next move.
1076
+ Do not copy them into variables at boot.
1077
+ - **The player's choice in your game wins.** If your settings screen has its own look speed or
1078
+ invert, pass it — `gem.controls.look({ sensitivity: 1.5, invertY: true })`, or later
1079
+ `look.set({ sensitivity: 1.5 })` — and that input stops following the Arcade.
1080
+ `look.set({ sensitivity: undefined })` hands it back. `sensitivity` and `gamepadSensitivity` are
1081
+ multipliers (`1` is the base speed); the Arcade's percentages are divided by 100 for you.
1082
+ - **Pass `pitch` for touch**, the camera's current pitch in radians (0 at the horizon, positive
1083
+ looking up), or the slowdown near vertical is skipped. A mouse and a stick are never slowed.
1084
+ - **`touchMovement` is yours to draw.** The SDK has no thumbstick: read
1085
+ `gem.prefs.recommended?.touchMovement` and draw a floating or a fixed one, or declare
1086
+ `touchMovement: 'game'` if your game only has one kind.
1087
+ - **No camera, no call.** A game with no camera to turn declares nothing and never calls `look`.
1088
+
988
1089
  ## The Arcade's menu
989
1090
 
990
1091
  The Arcade keeps one menu button over every game, on every device. Its menu is how a player leaves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "10.2.0",
3
+ "version": "10.3.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -811,10 +811,18 @@
811
811
  "A game may ignore it entirely.",
812
812
  "",
813
813
  "OPTIONAL because it is additive: a parent that predates it sends the",
814
- "fields above without it, and the SDK reports `null`. All five fields",
815
- "come together or the object is absent -- a parent that keeps only some",
816
- "of them fills the rest from the defaults below rather than sending a",
817
- "partial object.",
814
+ "fields above without it, and the SDK reports `null`. The first five",
815
+ "fields come together or the object is absent -- a parent that keeps",
816
+ "only some of them fills the rest from the defaults below rather than",
817
+ "sending a partial object.",
818
+ "",
819
+ "THE CAMERA AND CONTROLS FOUR ARE OPTIONAL INSIDE IT (owner decision",
820
+ "2026-10-11): `lookSensitivity`, `gamepadLookSensitivity`, `invertLookY`",
821
+ "and `touchMovement`, Roblox's per-device camera settings. A parent built",
822
+ "against an older SDK sends the object without them, and the SDK fills",
823
+ "each missing one with its default (100, 100, false, `floating`), so on",
824
+ "the game side all four are present whenever the object is. A game says",
825
+ "which of them it leaves to the player with `gem.controlsDeclare`.",
818
826
  "",
819
827
  "`muted`, `reducedMotion` and `showFrameStats` stay where they are, at",
820
828
  "the top of this message, and keep their meaning: they are the Arcade's",
@@ -829,8 +837,8 @@
829
837
  "WIDENING AN ENUM HERE IS NOT FREE. The generated validator refuses the",
830
838
  "WHOLE message on any field it rejects, so a value an older frame does",
831
839
  "not know would cost it `muted` and `reducedMotion` as well. A new",
832
- "keyboard layout or confirm button needs a capability the frame",
833
- "advertises before a parent may send it."
840
+ "keyboard layout, confirm button or touch-movement mode needs a",
841
+ "capability the frame advertises before a parent may send it."
834
842
  ],
835
843
  "fields": {
836
844
  "volume": {
@@ -856,6 +864,42 @@
856
864
  "vibration": {
857
865
  "kind": "boolean",
858
866
  "$comment": "Whether the player wants vibration and controller rumble. Default true."
867
+ },
868
+ "lookSensitivity": {
869
+ "kind": "int",
870
+ "min": 20,
871
+ "max": 400,
872
+ "optional": true,
873
+ "$comment": "Look speed for touch, mouse and pen, as a percentage of the base speeds (Roblox's MouseSensitivity): 100 is 1x, 20..400. Default 100 when absent. The Arcade's slider has eleven positions (20, 36, 52, 68, 84, 100, 160, 220, 280, 340, 400) but any integer in range is valid."
874
+ },
875
+ "gamepadLookSensitivity": {
876
+ "kind": "int",
877
+ "min": 20,
878
+ "max": 400,
879
+ "optional": true,
880
+ "$comment": "Look speed for a gamepad stick, as a percentage of the base speed (Roblox's GamepadCameraSensitivity): 100 is 1x, 20..400. Default 100 when absent."
881
+ },
882
+ "invertLookY": {
883
+ "kind": "boolean",
884
+ "optional": true,
885
+ "$comment": "Up/down look inverted: pushing up or dragging up looks DOWN (Roblox's CameraYInverted). Flips pitch only, never yaw. Default false when absent."
886
+ },
887
+ "touchMovement": {
888
+ "kind": "string",
889
+ "maxLength": 16,
890
+ "format": "code",
891
+ "optional": true,
892
+ "$comment": [
893
+ "On a touch screen, the movement thumbstick: `floating` appears where",
894
+ "the thumb lands (Roblox's Dynamic Thumbstick), `fixed` stays in one",
895
+ "place (Roblox's Thumbstick). Stored on every device, meaningful on",
896
+ "touch. Default `floating` when absent.",
897
+ "",
898
+ "A CODE, NOT AN ENUM, so the vocabulary can grow without costing an",
899
+ "older frame the whole message (see WIDENING above): a parent sends",
900
+ "only `floating` or `fixed` today, and the SDK maps any other value to",
901
+ "the default, `floating`, so a game only ever sees those two."
902
+ ]
859
903
  }
860
904
  }
861
905
  }
@@ -2511,6 +2555,83 @@
2511
2555
  "rateLimit": { "perMinute": 60 }
2512
2556
  },
2513
2557
 
2558
+ {
2559
+ "type": "gem.controlsDeclare",
2560
+ "direction": "frameToParent",
2561
+ "transport": "port",
2562
+ "$comment": [
2563
+ "Which of the player's camera and controls settings this game leaves to",
2564
+ "the player and which it decides itself — Roblox's UserChoice versus",
2565
+ "`StarterPlayer.Dev*`, declared at runtime (owner decision 2026-10-11).",
2566
+ "The settings are the four camera and controls fields of",
2567
+ "`gem.prefs.recommended`; the parent's Settings panel shows each row",
2568
+ "according to this.",
2569
+ "",
2570
+ " 'player' the game applies the Arcade's value (or the player's own",
2571
+ " in-game choice, which wins); a normal row",
2572
+ " 'game' the game decides this itself; the parent shows the row",
2573
+ " disabled, \"Set by this game\"",
2574
+ " absent the game does not use this setting at all",
2575
+ "",
2576
+ "SCREEN STATE, NOT A TOGGLE, like `gem.walletDisplay`. Each message is the",
2577
+ "whole answer and the latest one wins; nothing accumulates. The SDK holds",
2578
+ "the latest declaration and re-sends it after every handshake, deduped per",
2579
+ "connection, so a parent that reconnects never shows a stale panel. With no",
2580
+ "message at all, the parent treats every setting as unused by this game.",
2581
+ "A declaration is per game session: the parent forgets it when the game is",
2582
+ "left.",
2583
+ "",
2584
+ "EACH VALUE IS A CODE, NOT AN ENUM, and an unknown KEY is ignored, so the",
2585
+ "vocabulary can grow without an older parent refusing the whole message:",
2586
+ "the SDK drops any key or value it does not know before sending, and a",
2587
+ "parent ignores any it does not know on receipt — treating that setting",
2588
+ "as absent.",
2589
+ "",
2590
+ "A PRESENTATION REQUEST ONLY. Not an authorization control: it changes",
2591
+ "which rows of the parent's own panel are editable, and nothing the game",
2592
+ "receives.",
2593
+ "",
2594
+ "Gated by `controls`: sent only to a parent that advertised it."
2595
+ ],
2596
+ "fields": {
2597
+ "uses": {
2598
+ "kind": "object",
2599
+ "$comment": "One optional entry per setting, each `player` or `game`. An empty object is a valid declaration: the game uses none of them.",
2600
+ "fields": {
2601
+ "lookSensitivity": {
2602
+ "kind": "string",
2603
+ "maxLength": 16,
2604
+ "format": "code",
2605
+ "optional": true,
2606
+ "$comment": "`player` or `game` for `gem.prefs.recommended.lookSensitivity`."
2607
+ },
2608
+ "gamepadLookSensitivity": {
2609
+ "kind": "string",
2610
+ "maxLength": 16,
2611
+ "format": "code",
2612
+ "optional": true,
2613
+ "$comment": "`player` or `game` for `gem.prefs.recommended.gamepadLookSensitivity`."
2614
+ },
2615
+ "invertLookY": {
2616
+ "kind": "string",
2617
+ "maxLength": 16,
2618
+ "format": "code",
2619
+ "optional": true,
2620
+ "$comment": "`player` or `game` for `gem.prefs.recommended.invertLookY`."
2621
+ },
2622
+ "touchMovement": {
2623
+ "kind": "string",
2624
+ "maxLength": 16,
2625
+ "format": "code",
2626
+ "optional": true,
2627
+ "$comment": "`player` or `game` for `gem.prefs.recommended.touchMovement`."
2628
+ }
2629
+ }
2630
+ }
2631
+ },
2632
+ "rateLimit": { "perMinute": 60 }
2633
+ },
2634
+
2514
2635
  {
2515
2636
  "type": "gem.menu",
2516
2637
  "direction": "frameToParent",
@@ -218,6 +218,12 @@
218
218
  "gem.menu",
219
219
  "gem.menuSelect"
220
220
  ]
221
+ },
222
+ "controls": {
223
+ "implemented": true,
224
+ "gates": [
225
+ "gem.controlsDeclare"
226
+ ]
221
227
  }
222
228
  }
223
229
  }