@indiegems/gem-web-sdk 10.1.1 → 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/CHANGELOG.md +32 -0
- package/dist/game.d.ts +52 -1
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +2 -2
- package/dist/gem-web-sdk.function.js +2 -2
- package/dist/gem-web-sdk.game.js +2 -2
- package/dist/gem-web-sdk.min.js +2 -2
- package/dist/gem-web-sdk.sdk.js +2 -2
- package/dist/gem-web-sdk.server-api.js +1 -1
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/index.d.ts +42 -3
- package/dist/sdk.d.ts +162 -11
- package/dist/server-api.d.ts +70 -2
- package/dist/server.d.ts +103 -3
- package/docs/api/api-reference-game.md +616 -10
- package/docs/api/api-reference-server.md +244 -4
- package/docs/api/reference.json +993 -12
- package/docs/changelog.md +32 -0
- package/docs/getting-started.md +1 -1
- package/docs/manifest.json +10 -10
- package/docs/rooms-and-matches.md +7 -0
- package/docs/server-bundles.md +46 -2
- package/docs/what-a-game-must-do.md +140 -1
- package/package.json +1 -1
- package/schema/bridge.v1.json +220 -7
- package/schema/capabilities.public.json +12 -0
package/docs/changelog.md
CHANGED
|
@@ -16,6 +16,38 @@ 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
|
+
|
|
35
|
+
## 10.2.0
|
|
36
|
+
|
|
37
|
+
Upgrading from 10.1.1. Impact: required.
|
|
38
|
+
|
|
39
|
+
### Rebuild
|
|
40
|
+
|
|
41
|
+
- The match object on the server types now has two more methods, setBackfill and reportPlayers, besides end. Recompile your server. If you wrote your own implementation or test double of the server or match server types, add the two new methods or it will no longer type-check.
|
|
42
|
+
- The list of frame-to-parent message names, the frame message type and the capability type each gained a new member for the avatar picker. Recompile. If your code switches exhaustively over any of them, or checks that every member is handled, add a case for the new avatar picker member.
|
|
43
|
+
- The order source type gained two new values, one for test account provisioning and one for developer grants, and each progression state entry in a paginated response may now carry an optional completion threshold. Recompile. If you switch exhaustively over order sources, handle the new values.
|
|
44
|
+
|
|
45
|
+
### New capability
|
|
46
|
+
|
|
47
|
+
- Games running inside the Arcade can now read the player's current Arcade avatar from GemPlayer.avatarIconId. It is only set when the value is a valid identifier, and the Arcade sends the player again whenever the worn avatar changes, so listen for player updates rather than reading it once. Games can also ask the Arcade to open its avatar picker. Check Gem.avatarPickerAvailable first, then call Gem.openAvatarPicker. It returns false outside the Arcade, before the connection is ready, when the Arcade does not offer the picker, or when you call it too often, and it never throws or waits. Nothing answers the request directly; the outcome is an updated player.
|
|
48
|
+
- If your code hosts a game frame, you can now pass the avatar identifier when sending player information and handle the avatar picker request through the new host callback and option. Incoming picker requests have a new message type with its own validator. Without a handler, the request is refused.
|
|
49
|
+
- Dedicated game servers can now control matchmaking backfill. Call setBackfill to stop or restart the platform topping up a running match from the matchmaking queue, optionally with a seat count, and it returns the resulting backfill state. Call reportPlayers to tell the platform whether each player has connected, never showed up, or left. The last two remove the player from the room. The server SDK also reports each joining player as connected on its own, which gives a seat filled from the queue a time limit. If the platform does not offer these routes, it stops trying for that match after one refused call. The matching low-level server API operations and request and response types are available too.
|
|
50
|
+
|
|
19
51
|
## 10.1.1
|
|
20
52
|
|
|
21
53
|
Upgrading from 10.1.0. Impact: none.
|
package/docs/getting-started.md
CHANGED
package/docs/manifest.json
CHANGED
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"file": "changelog.md",
|
|
36
|
-
"sha256": "
|
|
37
|
-
"updated": "2026-10-
|
|
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": "
|
|
72
|
-
"updated": "2026-10-
|
|
71
|
+
"sha256": "fd566ed7d7f0b1b2b9bea823de5455d2ee9a66072e7303df1bc5c12c1acddcbe",
|
|
72
|
+
"updated": "2026-10-11T18:22:44Z"
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"file": "inventory-and-catalogs.md",
|
|
@@ -113,8 +113,8 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"file": "rooms-and-matches.md",
|
|
116
|
-
"sha256": "
|
|
117
|
-
"updated": "2026-10-
|
|
116
|
+
"sha256": "be22367f8dc9147e815a14ddb417788fa6614720a0df4f9f01624bf4667877d3",
|
|
117
|
+
"updated": "2026-10-11T10:17:11Z"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
"file": "saving-player-progress.md",
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"file": "server-bundles.md",
|
|
126
|
-
"sha256": "
|
|
127
|
-
"updated": "2026-10-
|
|
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": "
|
|
152
|
-
"updated": "2026-10-
|
|
151
|
+
"sha256": "44fa07b8f379181cfac9a89c8fa672946d0e4aaeefe1fd3386cc3565abcb3c95",
|
|
152
|
+
"updated": "2026-10-11T17:59:34Z"
|
|
153
153
|
}
|
|
154
154
|
]
|
|
155
155
|
}
|
|
@@ -770,6 +770,13 @@ else backToMenu(outcome.status);
|
|
|
770
770
|
`matchmaking_not_enabled`**. `fill_window_seconds` in that block is how long a queue holds out for
|
|
771
771
|
`max_players` before it accepts `min_players`. The block cannot be combined with `auto_start_host`.
|
|
772
772
|
|
|
773
|
+
**A running match can take late arrivals.** Add `backfill: { enabled: true }` to the template's
|
|
774
|
+
`matchmaking` block (the template must also allow joining in progress) and a match the platform made
|
|
775
|
+
is topped up from the queue: a ticket that arrives after the match started is seated in it when it
|
|
776
|
+
has an open seat, instead of waiting for a whole new lobby. To the queueing player nothing is
|
|
777
|
+
different — the ticket ends `matched`, in a room whose match is already running. The match's server
|
|
778
|
+
decides how long that lasts; see [the server guide](server-bundles.md#topping-a-running-match-up-from-the-queue).
|
|
779
|
+
|
|
773
780
|
**A ticket names every mode the player accepts**, one to eight templates, and is matched under
|
|
774
781
|
whichever fills first. That is one ticket, not several: a player can be on one ticket at a time, across
|
|
775
782
|
every game.
|
package/docs/server-bundles.md
CHANGED
|
@@ -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.
|
|
84
|
-
curl -fsSL https://downloads.gemarcade.com/sdk/v10.
|
|
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
|
|
|
@@ -769,6 +769,50 @@ publish is your code plus whatever of the SDK it reached. No API origin is baked
|
|
|
769
769
|
that arrives on `host.apiBaseUrl` at runtime. (`/server` remains as the server-only door for a
|
|
770
770
|
bundle that wants nothing of the browser half in its graph.)
|
|
771
771
|
|
|
772
|
+
### Topping a running match up from the queue
|
|
773
|
+
|
|
774
|
+
A match the platform matchmade from a room template with `matchmaking.backfill` turned on takes
|
|
775
|
+
waiting players into its open seats from the moment it starts, and again whenever a seat frees. Your
|
|
776
|
+
server does nothing to get that. Two calls let it take control:
|
|
777
|
+
|
|
778
|
+
```js
|
|
779
|
+
// Nobody new once the final round starts.
|
|
780
|
+
await gem.match.setBackfill({ open: false });
|
|
781
|
+
|
|
782
|
+
// Open again, for at most two more players.
|
|
783
|
+
await gem.match.setBackfill({ open: true, seats: 2 });
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
- **Closing removes nobody**, and a player already on the way in still arrives.
|
|
787
|
+
- **`seats` counts down** as seats fill and backfill stops at 0. Omit it for every open seat.
|
|
788
|
+
- It rejects with `GemApiError.code === 'backfill_not_enabled'` on a match the platform is not
|
|
789
|
+
topping up: the room template has no backfill, or the room was not matchmade.
|
|
790
|
+
|
|
791
|
+
```js
|
|
792
|
+
leave(playerId) {
|
|
793
|
+
// This game does not let a player back in once they quit.
|
|
794
|
+
void gem.match.reportPlayers([{ playerId, state: 'left' }]);
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
`gem.match.reportPlayers` tells the platform about players, one of three ways:
|
|
799
|
+
|
|
800
|
+
| `state` | Meaning | Effect |
|
|
801
|
+
| --- | --- | --- |
|
|
802
|
+
| `connected` | the player has connected to your server | noted |
|
|
803
|
+
| `no_show` | the player was given a seat and never connected | removed from the room; the seat is free |
|
|
804
|
+
| `left` | the player was in the match and is gone for good | removed from the room; the seat is free |
|
|
805
|
+
|
|
806
|
+
⚠️ **`left` is final.** A removed player is not put back by reconnecting; they have to join again.
|
|
807
|
+
Do not report it from every `leave()` in a game where a dropped connection is expected to return.
|
|
808
|
+
|
|
809
|
+
**You do not report `connected` yourself** in a bundle built with `defineServer`: the SDK reports
|
|
810
|
+
each player as they join. That report is what gives a seat filled from the queue a time limit. Once
|
|
811
|
+
your server has reported anyone connected, a player the platform seats afterwards has a limited
|
|
812
|
+
time to connect too, and is removed if they do not — so a seat is not held by somebody who closed
|
|
813
|
+
the tab on the way in. A bundle that drives the lifecycle by hand reports `connected` from its own
|
|
814
|
+
`join`, or gets no time limit.
|
|
815
|
+
|
|
772
816
|
### What is not on the server surface, and why
|
|
773
817
|
|
|
774
818
|
`gem.p2p`, `gem.dedicatedServer`, `gem.me`, `gem.characters` and `gem.gameConfig` are browser-only.
|
|
@@ -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 = {
|
|
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
|
|
@@ -1310,6 +1411,44 @@ your own page. It carries a day count and never a date, and nothing about what t
|
|
|
1310
1411
|
This is a platform rule, not advice — see
|
|
1311
1412
|
[platform rules §8](platform-rules.md#8-a-players-boost-is-for-display-never-a-gate).
|
|
1312
1413
|
|
|
1414
|
+
### Arcade avatars: the character the player wears, and changing it mid-game
|
|
1415
|
+
|
|
1416
|
+
Some games draw **Arcade avatars**: whole characters the player owns as Arcade portrait items and
|
|
1417
|
+
wears as their Arcade portrait (store tag `arcade-avatars`). Such a game keys its own model on
|
|
1418
|
+
**`avatarIconId`**, the worn item's id — on `gem.player` for the signed-in player, as on every room
|
|
1419
|
+
member — rather than on the thumbnail.
|
|
1420
|
+
|
|
1421
|
+
**Redraw from `onPlayer`, not from boot alone.** The Arcade sends the player again whenever the
|
|
1422
|
+
portrait they wear changes, including mid-game from the Arcade's own menu, without your game
|
|
1423
|
+
asking. `onPlayer` runs and `gem.player` holds the new value; a field missing from the new player
|
|
1424
|
+
is missing now.
|
|
1425
|
+
|
|
1426
|
+
**A "Change avatar" button belongs in your own UI**, and `gem.openAvatarPicker()` opens the
|
|
1427
|
+
Arcade's picker from it. The Arcade pauses your game while the picker is open, saves the player's
|
|
1428
|
+
choice, sends the player again, and resumes you:
|
|
1429
|
+
|
|
1430
|
+
```js
|
|
1431
|
+
const gem = createGem({ onPlayer: (p) => hero.wear(avatarOfItem(p.avatarIconId)) });
|
|
1432
|
+
|
|
1433
|
+
changeAvatar.hidden = !gem.avatarPickerAvailable; // read it live
|
|
1434
|
+
changeAvatar.onclick = () => {
|
|
1435
|
+
if (!gem.openAvatarPicker()) changeAvatar.hidden = true;
|
|
1436
|
+
};
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
- **`gem.avatarPickerAvailable`** says whether the Arcade will open its picker here. It is `false`
|
|
1440
|
+
outside the Arcade, before the bridge is up, and under an embedder that has no picker.
|
|
1441
|
+
- **`gem.openAvatarPicker()`** returns `true` when the Arcade was asked and `false` when it was not
|
|
1442
|
+
(the same cases, or more than six asks a minute). It never throws and never waits.
|
|
1443
|
+
- **Nothing answers it.** The outcome is the new player through `onPlayer`. A player who closes the
|
|
1444
|
+
picker unchanged produces nothing, so never wait for an answer or poll `gem.player`.
|
|
1445
|
+
|
|
1446
|
+
**Your server is not told.** A server-authoritative game reads each member's `avatar_icon_id` from
|
|
1447
|
+
the room read (`GET /v1/games/{game_id}/game-rooms/{game_room_id}`, or `gem.room.get()` on a
|
|
1448
|
+
dedicated server), and nothing pushes a change to it. So when `onPlayer` brings a different
|
|
1449
|
+
`avatarIconId`, have your client tell your server, and have the server **re-read the room** —
|
|
1450
|
+
never trust the id the client sends.
|
|
1451
|
+
|
|
1313
1452
|
## Which backend — no longer yours, and what the override is for
|
|
1314
1453
|
|
|
1315
1454
|
The Arcade names the API root on every `gem.token`, and the SDK sends there. Your game never types
|
package/package.json
CHANGED
package/schema/bridge.v1.json
CHANGED
|
@@ -547,7 +547,17 @@
|
|
|
547
547
|
"",
|
|
548
548
|
"ADDITIVE, so no `v` bump: every new field is optional (an older parent",
|
|
549
549
|
"sends none of them and must still be accepted), and an older frame",
|
|
550
|
-
"ignores fields it does not know (rule 4)."
|
|
550
|
+
"ignores fields it does not know (rule 4).",
|
|
551
|
+
"",
|
|
552
|
+
"RE-SENT WHENEVER IT CHANGES, and always the whole message. The parent",
|
|
553
|
+
"sends it after the handshake, again whenever the identity changes (a",
|
|
554
|
+
"sign-in in place), and again whenever the WORN PORTRAIT changes — the",
|
|
555
|
+
"player picked another one in the parent's own avatar picker, whether a",
|
|
556
|
+
"game opened it (`gem.requestAvatarPicker`) or the player did, mid-game,",
|
|
557
|
+
"from the parent's menu. A frame replaces its whole copy with each one; a",
|
|
558
|
+
"field absent from the latest message is absent now. There is no other",
|
|
559
|
+
"signal that the portrait changed, and no reply to the request that",
|
|
560
|
+
"opened the picker: this message is the outcome."
|
|
551
561
|
],
|
|
552
562
|
"fields": {
|
|
553
563
|
"untrusted": {
|
|
@@ -586,6 +596,30 @@
|
|
|
586
596
|
"optional": true,
|
|
587
597
|
"$comment": "The portrait's THUMBNAIL (gem-api `avatar_icon_url`). Absent means no portrait; a parent never sends an empty string. Structurally validated only — https (or loopback), no userinfo, parsed rather than regex-matched. It is NOT origin-pinned: the validator is a pure function with no configuration, so it cannot know the CDN origin. An earlier version of this comment claimed a pin that the generated code did not emit — a documented control that did not exist. THE CONSUMER MUST PIN THE ORIGIN before putting this in an <img src> — the SDK's `platformIconUrl` does — or a hostile parent supplies a request-time beacon carrying the Referer."
|
|
588
598
|
},
|
|
599
|
+
"avatarIconId": {
|
|
600
|
+
"kind": "string",
|
|
601
|
+
"maxLength": 64,
|
|
602
|
+
"optional": true,
|
|
603
|
+
"$comment": [
|
|
604
|
+
"The item-definition id of the portrait the player WEARS (gem-api",
|
|
605
|
+
"`avatar_icon_id`, a UUID) — the same id a game-room member's",
|
|
606
|
+
"`avatar_icon_id` carries, so a game that draws whole characters as",
|
|
607
|
+
"Arcade avatars (store tag `arcade-avatars`) can map it to its own",
|
|
608
|
+
"model rather than to the thumbnail.",
|
|
609
|
+
"",
|
|
610
|
+
"Platform-assigned, so it sits on the trusted side of the `untrusted`",
|
|
611
|
+
"split, beside `playerId` and the badges' `itemId`. Absent means no",
|
|
612
|
+
"portrait is worn, or an older parent; a parent never sends an empty",
|
|
613
|
+
"string. Sent with `avatarIconUrl` and describing the same item.",
|
|
614
|
+
"",
|
|
615
|
+
"BOUNDED HERE, SHAPE-CHECKED BY THE FRAME. The schema has no pattern",
|
|
616
|
+
"kind, and a format here would cost a frame the WHOLE message for one",
|
|
617
|
+
"bad id — its name and portrait with it. So the validator bounds the",
|
|
618
|
+
"length only, and the frame keeps the value only when it is a UUID",
|
|
619
|
+
"(`8-4-4-4-12` hex, either case), dropping anything else to absent,",
|
|
620
|
+
"the way it drops a non-digit `discriminator`."
|
|
621
|
+
]
|
|
622
|
+
},
|
|
589
623
|
"badges": {
|
|
590
624
|
"kind": "array",
|
|
591
625
|
"maxItems": 2,
|
|
@@ -777,10 +811,18 @@
|
|
|
777
811
|
"A game may ignore it entirely.",
|
|
778
812
|
"",
|
|
779
813
|
"OPTIONAL because it is additive: a parent that predates it sends the",
|
|
780
|
-
"fields above without it, and the SDK reports `null`.
|
|
781
|
-
"come together or the object is absent -- a parent that keeps
|
|
782
|
-
"of them fills the rest from the defaults below rather than
|
|
783
|
-
"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`.",
|
|
784
826
|
"",
|
|
785
827
|
"`muted`, `reducedMotion` and `showFrameStats` stay where they are, at",
|
|
786
828
|
"the top of this message, and keep their meaning: they are the Arcade's",
|
|
@@ -795,8 +837,8 @@
|
|
|
795
837
|
"WIDENING AN ENUM HERE IS NOT FREE. The generated validator refuses the",
|
|
796
838
|
"WHOLE message on any field it rejects, so a value an older frame does",
|
|
797
839
|
"not know would cost it `muted` and `reducedMotion` as well. A new",
|
|
798
|
-
"keyboard layout
|
|
799
|
-
"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."
|
|
800
842
|
],
|
|
801
843
|
"fields": {
|
|
802
844
|
"volume": {
|
|
@@ -822,6 +864,42 @@
|
|
|
822
864
|
"vibration": {
|
|
823
865
|
"kind": "boolean",
|
|
824
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
|
+
]
|
|
825
903
|
}
|
|
826
904
|
}
|
|
827
905
|
}
|
|
@@ -2477,6 +2555,83 @@
|
|
|
2477
2555
|
"rateLimit": { "perMinute": 60 }
|
|
2478
2556
|
},
|
|
2479
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
|
+
|
|
2480
2635
|
{
|
|
2481
2636
|
"type": "gem.menu",
|
|
2482
2637
|
"direction": "frameToParent",
|
|
@@ -2673,6 +2828,64 @@
|
|
|
2673
2828
|
}
|
|
2674
2829
|
},
|
|
2675
2830
|
|
|
2831
|
+
{
|
|
2832
|
+
"type": "gem.requestAvatarPicker",
|
|
2833
|
+
"direction": "frameToParent",
|
|
2834
|
+
"transport": "port",
|
|
2835
|
+
"$comment": [
|
|
2836
|
+
"Ask the arcade to open its own avatar picker over the game, so the",
|
|
2837
|
+
"player can change the portrait they wear without leaving it — the",
|
|
2838
|
+
"\"Change avatar\" button of a game that draws Arcade avatars. Gated by",
|
|
2839
|
+
"the `avatarPicker` capability: sent only to a parent that advertised",
|
|
2840
|
+
"it.",
|
|
2841
|
+
"",
|
|
2842
|
+
"WHY THE PARENT OPENS IT. Wearing a portrait is an account act: the",
|
|
2843
|
+
"account surface refuses the game-scoped token by construction, and the",
|
|
2844
|
+
"list of portraits a player owns is theirs, not the game's. So the game",
|
|
2845
|
+
"ASKS, and the parent, which holds the account session, renders the",
|
|
2846
|
+
"picker in its own chrome and saves the choice.",
|
|
2847
|
+
"",
|
|
2848
|
+
"NO FIELDS, AND NO REPLY. The game names nothing — not an item, not a",
|
|
2849
|
+
"filter, not a line of text: which portraits are offered and what the",
|
|
2850
|
+
"picker says are the parent's. And nothing answers this message. THE",
|
|
2851
|
+
"OUTCOME IS A NEW `gem.player`: when the worn portrait changes, the",
|
|
2852
|
+
"parent re-sends it with the new `avatarIconId` and `avatarIconUrl`, as",
|
|
2853
|
+
"it does for a change the player makes from the parent's menu without",
|
|
2854
|
+
"the game asking. A reply would be a second copy of an identity fact",
|
|
2855
|
+
"(`gem.signInResult`'s argument), and the picker may stay open for as",
|
|
2856
|
+
"long as the player browses, which no wait should be sized for. A",
|
|
2857
|
+
"player who closes it unchanged produces nothing at all.",
|
|
2858
|
+
"",
|
|
2859
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce:",
|
|
2860
|
+
"",
|
|
2861
|
+
" ADVERTISE IT ONLY WHERE IT OPENS. `avatarPicker` is the game's cue to",
|
|
2862
|
+
" show its own Change avatar button, so a parent with no picker on this",
|
|
2863
|
+
" surface does not advertise it. A player who cannot wear a portrait",
|
|
2864
|
+
" yet (no account) is the parent's to explain in its own chrome.",
|
|
2865
|
+
"",
|
|
2866
|
+
" PAUSE THE GAME while the picker is open (`gem.pause`) and lift it when",
|
|
2867
|
+
" the picker closes, returning focus to the frame. When the portrait",
|
|
2868
|
+
" changed, send `gem.player` BEFORE lifting the pause, so the game",
|
|
2869
|
+
" redraws before it runs again.",
|
|
2870
|
+
"",
|
|
2871
|
+
" ONE AT A TIME. An ask arriving while the picker, or any other sheet",
|
|
2872
|
+
" that holds the stage, is open is dropped. A parent may also drop an",
|
|
2873
|
+
" ask that arrives without transient user activation in the frame, or",
|
|
2874
|
+
" too soon after the player closed the last one.",
|
|
2875
|
+
"",
|
|
2876
|
+
" SEND NOTHING BACK for a refusal — no error, no acknowledgement.",
|
|
2877
|
+
"",
|
|
2878
|
+
"NOT AN AUTHORIZATION CONTROL. The player's own choice in the parent's",
|
|
2879
|
+
"picker is the control, and the account-session save is the",
|
|
2880
|
+
"enforcement."
|
|
2881
|
+
],
|
|
2882
|
+
"fields": {},
|
|
2883
|
+
"rateLimit": {
|
|
2884
|
+
"perMinute": 6,
|
|
2885
|
+
"$comment": "Bounds a BUGGY game. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK. What stops a game nagging is the parent's one-at-a-time rule and its freedom to drop an ask."
|
|
2886
|
+
}
|
|
2887
|
+
},
|
|
2888
|
+
|
|
2676
2889
|
{
|
|
2677
2890
|
"type": "gem.vibrate",
|
|
2678
2891
|
"direction": "frameToParent",
|