@genex-ai/cli-demo 0.12.1 → 0.15.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/README.md +1 -0
- package/dist/index.js +203 -4
- package/package.json +7 -2
- package/templates/controllers/NOTICE.md +65 -0
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/character.glb +0 -0
- package/templates/controllers/assets/default-avatar.vrm +0 -0
- package/templates/controllers/character/character-animations.ts +682 -0
- package/templates/controllers/character/character-controller.ts +1636 -0
- package/templates/controllers/character/follow-camera.ts +644 -0
- package/templates/controllers/character/keyboard-input.ts +277 -0
- package/templates/controllers/character/presets.ts +176 -0
- package/templates/controllers/character/touch-joystick.ts +387 -0
- package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
- package/templates/controllers/character/vrm/foot-ik.ts +341 -0
- package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
- package/templates/controllers/drone/drone-controller.ts +1073 -0
- package/templates/controllers/drone/presets.ts +225 -0
- package/templates/controllers/interact/enter-exit.ts +502 -0
- package/templates/controllers/shared/colliders.ts +456 -0
- package/templates/controllers/shared/math.ts +230 -0
- package/templates/controllers/shared/physics-world.ts +622 -0
- package/templates/controllers/vehicle/presets.ts +297 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
- package/templates/controllers/vehicle/wheel.ts +1200 -0
- package/templates/skills/genex-getting-started/SKILL.md +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
- package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +93 -34
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +30 -26
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +35 -20
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
- package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Wiring the character controller
|
|
2
|
+
|
|
3
|
+
The full path from an empty scene to a playable character:
|
|
4
|
+
world → `CharacterController` → `FollowCamera` → `KeyboardInput` /
|
|
5
|
+
`TouchJoystick` → `CharacterAnimations`. Every class and option name below is
|
|
6
|
+
the vendored code's real API — copy from here, not from memory.
|
|
7
|
+
|
|
8
|
+
## 1. Physics world
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
|
|
12
|
+
|
|
13
|
+
const physics = await PhysicsWorld.create(); // awaits RAPIER.init() (embedded WASM)
|
|
14
|
+
const world = physics.world; // the raw Rapier world controllers consume
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Nothing may construct any RAPIER object before `create()` resolves. Options if
|
|
18
|
+
you need them: `gravity` (default `[0, -9.81, 0]`), `timeStep` (default
|
|
19
|
+
`1/60`), `maxDelta`, `interpolate`. `physics.paused` and `physics.timeScale`
|
|
20
|
+
give you pause and slow motion for free.
|
|
21
|
+
|
|
22
|
+
## 2. Level colliders
|
|
23
|
+
|
|
24
|
+
Every walkable surface needs a rigid body + collider. For authored boxes:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { cuboidCollider } from "./controllers/shared/colliders.ts";
|
|
28
|
+
|
|
29
|
+
const ground = new THREE.Mesh(new THREE.BoxGeometry(90, 0.5, 90), groundMat);
|
|
30
|
+
scene.add(ground);
|
|
31
|
+
const groundBody = physics.createBody({ type: "fixed", position: [0, -0.25, 0] }, ground);
|
|
32
|
+
cuboidCollider(world, groundBody, [45, 0.25, 45], { friction: 1 }); // HALF extents
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For loaded GLB level pieces, `collidersFromObject(world, body, gltf.scene, "trimesh")`
|
|
36
|
+
auto-generates colliders per mesh (`"hull"` for dynamic props from
|
|
37
|
+
`$genex-ai-model`; `"trimesh"` only on static geometry). `cuboidCollider`
|
|
38
|
+
takes **half** extents — do not halve twice.
|
|
39
|
+
|
|
40
|
+
## 3. Character
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { CharacterController } from "./controllers/character/character-controller.ts";
|
|
44
|
+
import { characterPresets } from "./controllers/character/presets.ts";
|
|
45
|
+
|
|
46
|
+
const character = new CharacterController(world, camera, {
|
|
47
|
+
...characterPresets["default"].options,
|
|
48
|
+
position: { x: 0, y: 2, z: 0 },
|
|
49
|
+
userData: { controller: { excludeVehicleRay: true } },
|
|
50
|
+
});
|
|
51
|
+
scene.add(character.root);
|
|
52
|
+
physics.registerBody(character.body, character.root);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- The constructor creates the dynamic body + capsule collider immediately.
|
|
56
|
+
- `character.root` is the visual anchor; `registerBody` makes it follow the
|
|
57
|
+
body with interpolation. Register scene-root-level groups and add them to the
|
|
58
|
+
scene **first** — `registerBody` captures the parent's world matrix once and
|
|
59
|
+
never refreshes it.
|
|
60
|
+
- `userData: { controller: { excludeVehicleRay: true } }` keeps car wheels
|
|
61
|
+
from treating the on-foot player as drivable ground. Related keys (set on
|
|
62
|
+
OTHER bodies): `excludeRay` (ignored by all ground queries),
|
|
63
|
+
`excludeCharacterRay` (ignored by the character's ground query only).
|
|
64
|
+
- The camera argument is how movement becomes camera-relative ("forward" = away
|
|
65
|
+
from camera). Pass the same camera the `FollowCamera` drives.
|
|
66
|
+
|
|
67
|
+
### Place the model so its feet touch the ground
|
|
68
|
+
|
|
69
|
+
The capsule floats: in root space the ground is at
|
|
70
|
+
`-(capsuleHalfHeight + capsuleRadius + floatHeight)` — **-0.8** with the
|
|
71
|
+
defaults (0.3 + 0.3 + 0.2). Normalize any loaded model to ~1.35 units tall and
|
|
72
|
+
drop its bounding-box bottom to that line:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const bbox = new THREE.Box3().setFromObject(model);
|
|
76
|
+
const scale = 1.35 / Math.max(bbox.max.y - bbox.min.y, 1e-3);
|
|
77
|
+
model.scale.setScalar(scale);
|
|
78
|
+
model.position.y = -0.8 - bbox.min.y * scale;
|
|
79
|
+
character.root.add(model);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A model hovering above the floor or knee-deep in it means this offset is wrong
|
|
83
|
+
— it is the single most common wiring bug.
|
|
84
|
+
|
|
85
|
+
## 4. Input
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
|
|
89
|
+
|
|
90
|
+
const kb = new KeyboardInput(); // WASD/arrows move, Shift run, Space jump, F interact
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Event-driven — nothing to poll per frame. Read `kb.getCharacterMovement()`
|
|
94
|
+
inside the before-step callback and pass the **complete** intent object every
|
|
95
|
+
time: the controller merges defined fields, so a stale partial would leave old
|
|
96
|
+
`true`s behind. Merge touch controls caller-side (see the SKILL's mobile
|
|
97
|
+
section). `kb.onInteract(cb)` is the rising-edge F-key hook (for enter/exit).
|
|
98
|
+
It already clears all keys on window blur / tab hide, so no stuck-key handling
|
|
99
|
+
is needed.
|
|
100
|
+
|
|
101
|
+
## 5. Follow camera
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { FollowCamera } from "./controllers/character/follow-camera.ts";
|
|
105
|
+
|
|
106
|
+
const followCam = new FollowCamera(camera, {
|
|
107
|
+
domElement: renderer.domElement,
|
|
108
|
+
colliderMeshes: staticWallMeshes,
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Drag orbits, wheel/pinch zooms, and rays pull the camera in front of walls.
|
|
113
|
+
Rules that matter:
|
|
114
|
+
|
|
115
|
+
- `colliderMeshes` takes static **leaf** meshes only (the ray test is
|
|
116
|
+
non-recursive) and must never include the character or vehicle meshes — the
|
|
117
|
+
rays start at the character's head and would hit them every frame. The array
|
|
118
|
+
is public — mutate it after level loads.
|
|
119
|
+
- `minDistance` defaults to 0.02, so zooming all the way in is effectively
|
|
120
|
+
first-person; clamp with `minDistance`/`maxDistance` for your scale.
|
|
121
|
+
- Feel: `smoothTime` (0.05 snappy → 0.25 cinematic, default 0.1),
|
|
122
|
+
`initialDistance` (default 4), `initialAzimuthAngle` (default `Math.PI` —
|
|
123
|
+
camera starts behind a +Z-facing character).
|
|
124
|
+
- v1 limits (by design): no pointer-lock mode, no truck/pan, and the orbit
|
|
125
|
+
space assumes the up axis stays roughly world +Y — far-from-Y custom gravity
|
|
126
|
+
will misbehave.
|
|
127
|
+
|
|
128
|
+
## 6. The loop — exact order
|
|
129
|
+
|
|
130
|
+
Per fixed **substep** (inside `physics.onBeforeStep`), in this order:
|
|
131
|
+
|
|
132
|
+
1. Drive moving platforms (`setNextKinematicTranslation` / `setNextKinematicRotation`).
|
|
133
|
+
2. `EnterExitManager.update(dt)` — only if vehicles exist (vehicle skill).
|
|
134
|
+
3. `character.setMovement(...)` then `character.update()` — skip while
|
|
135
|
+
`character.isParked`.
|
|
136
|
+
4. (Vehicle/drone `update()` calls, if any.)
|
|
137
|
+
5. `world.step()` happens automatically right after your callback returns.
|
|
138
|
+
|
|
139
|
+
Per **render** frame (inside `renderer.setAnimationLoop`), in this order:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const delta = clock.getDelta();
|
|
143
|
+
physics.step(delta); // 1. fixed substeps + mesh sync
|
|
144
|
+
|
|
145
|
+
pivot.copy(character.currPos).addScaledVector(character.bodyYAxis, 0.5);
|
|
146
|
+
followCam.moveTo(pivot.x, pivot.y, pivot.z, true); // 2. feed the camera
|
|
147
|
+
followCam.setUp(character.upAxis);
|
|
148
|
+
if (physics.stepsLastFrame > 0 && character.isOnPlatform) {
|
|
149
|
+
followCam.applyPlatformTurn(character.turnOnYQuat);
|
|
150
|
+
}
|
|
151
|
+
followCam.update(delta); // 3. damp + place the camera
|
|
152
|
+
|
|
153
|
+
anims.update(character, delta); // 4. animations (render delta)
|
|
154
|
+
renderer.render(scene, camera); // 5. draw
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Non-negotiables:
|
|
158
|
+
|
|
159
|
+
- `update()` takes an optional dt **and ignores it** — all controller time
|
|
160
|
+
terms use the fixed `world.timestep`. Never call controller updates from the
|
|
161
|
+
render loop directly.
|
|
162
|
+
- `applyPlatformTurn` consumes a per-physics-step yaw delta: gate it on
|
|
163
|
+
`physics.stepsLastFrame > 0`, or high-refresh displays re-apply a stale delta
|
|
164
|
+
and the camera over-rotates on platforms.
|
|
165
|
+
- Camera and animation updates take the **render** delta, never
|
|
166
|
+
`physics.timeStep`.
|
|
167
|
+
|
|
168
|
+
## 7. Reading state (for game logic)
|
|
169
|
+
|
|
170
|
+
All getters are live internal objects — `.clone()` if you keep them:
|
|
171
|
+
`currPos`, `currQuat`, `currLinVel`, `isOnGround`, `isFalling`, `isMoving`,
|
|
172
|
+
`isOnPlatform`, `moveSpeed`, `verticalSpeed`, `runActive`, `jumpActive`,
|
|
173
|
+
`slopeAngle`, `actualSlopeAngle`, `standCollider` (the rigid body under the
|
|
174
|
+
character), `standPoint`, `standNormal`, `upAxis`, `bodyZAxis` (facing).
|
|
175
|
+
Useful methods beyond `setMovement`: `setLockForward(true)` for strafe mode,
|
|
176
|
+
`setGroundDetection("rayCast" | "shapeCast")`, `park()` / `unpark(pos, euler)`
|
|
177
|
+
for vehicle boarding, `syncRoot()` if you skip `registerBody`.
|
|
178
|
+
|
|
179
|
+
## 8. Debug and teardown
|
|
180
|
+
|
|
181
|
+
- `physics.enableDebug(scene)` draws every collider as wireframe lines
|
|
182
|
+
(disable in shipped games); `{ debug: true }` + a `debugScene` constructor
|
|
183
|
+
arg adds per-controller indicators.
|
|
184
|
+
- Teardown: `character.dispose()`, `kb.dispose()`, `followCam.dispose()`,
|
|
185
|
+
joystick/button `.dispose()`, then `physics.dispose()` last.
|
|
186
|
+
|
|
187
|
+
## Troubleshooting
|
|
188
|
+
|
|
189
|
+
- **Character slides down gentle ramps or climbs cliffs** — check
|
|
190
|
+
`slopeMaxAngle` (default 72°); see the tuning reference.
|
|
191
|
+
- **Stuck bouncing at spawn** — spawn `position.y` too low; the capsule center
|
|
192
|
+
must start above `capsuleHalfHeight + capsuleRadius + floatHeight`.
|
|
193
|
+
- **Model drifts away from the capsule** — the registered root's parent moved
|
|
194
|
+
or scaled after `registerBody`; register scene-root groups only.
|
|
195
|
+
- **Movement ignores the camera** — you passed a different camera to the
|
|
196
|
+
controller than the one being rendered.
|
|
197
|
+
- **`using deprecated parameters for the initialization function` in the
|
|
198
|
+
console** — harmless one-time Rapier WASM-loader message; ignore it.
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: genex-threejs-embed-auth
|
|
3
|
-
description: Wire up player identity for a Genex game via @genex-ai/embed-sdk. Load this UNCONDITIONALLY for every game, multiplayer or not, BEFORE writing any boot code — every player gets an identity (signed-in account or guest);
|
|
3
|
+
description: Wire up player identity AND durable game state for a Genex game via @genex-ai/embed-sdk. Load this UNCONDITIONALLY for every game, multiplayer or not, BEFORE writing any boot code — every player gets an identity (signed-in account or guest); per-player saves, shared world state, and leaderboards are one-line SDK calls; multiplayer requires the SDK's token either way.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Genex Three.js Embed Auth
|
|
7
7
|
|
|
8
|
-
`@genex-ai/embed-sdk` is how a Genex game learns **who is playing it
|
|
9
|
-
game needs it. Published games are playable by
|
|
10
|
-
SDK mints a temporary identity like `Guest-1234`
|
|
11
|
-
**signing in** unlocks saving/loading progress
|
|
12
|
-
|
|
8
|
+
`@genex-ai/embed-sdk` is how a Genex game learns **who is playing it** — and
|
|
9
|
+
how it **saves**. Every game needs it. Published games are playable by
|
|
10
|
+
**guests** (no account — the SDK mints a temporary identity like `Guest-1234`
|
|
11
|
+
automatically), while **signing in** unlocks saving/loading progress (each
|
|
12
|
+
player gets their own save slot) and leaderboard entries; multiplayer works
|
|
13
|
+
for both, using the SDK's token. The SDK handles every context with one
|
|
13
14
|
`initEmbed(...)` call:
|
|
14
15
|
|
|
15
16
|
- **Embedded in the Genex dashboard (an iframe):** a silent handshake signs
|
|
@@ -102,10 +103,10 @@ boot-path gate; `waitForAuth()` guards saves only.
|
|
|
102
103
|
synchronous.
|
|
103
104
|
- `getUser()` → `{ id, name, image? } | null` — non-null once authenticated OR
|
|
104
105
|
guest. Guest ids are prefixed `guest:`.
|
|
105
|
-
- `getEmbedToken()` → `string | undefined` — for
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
- `getEmbedToken()` → `string | undefined` — the raw token, for the RARE
|
|
107
|
+
advanced case of calling the Genex API by hand. The state/leaderboard
|
|
108
|
+
helpers below attach it automatically — prefer them; never hand-roll fetch
|
|
109
|
+
calls to `/state` endpoints.
|
|
109
110
|
- `getColyseusAuth()` → `{ embedToken } | undefined` — pass as `connect()`'s
|
|
110
111
|
`auth` option (REQUIRED — the relay rejects tokenless joins; guest tokens
|
|
111
112
|
are accepted). Read it fresh at every `connect()` call; tokens rotate
|
|
@@ -114,6 +115,31 @@ boot-path gate; `waitForAuth()` guards saves only.
|
|
|
114
115
|
`"blocked"`, `"error"`. A mid-game sign-in fires `"authenticated"` after
|
|
115
116
|
`"guest"` — progress saving can start right then, no reload.
|
|
116
117
|
|
|
118
|
+
Durable state + leaderboards (all six are safe to call from boot — they wait
|
|
119
|
+
for identity internally, never throw for guests, and reject only when the
|
|
120
|
+
session is blocked):
|
|
121
|
+
|
|
122
|
+
- `loadPlayerState()` → `Promise<{ data, version, guest? }>` — THIS player's
|
|
123
|
+
own save (per-player, per-game; other players can never read or overwrite
|
|
124
|
+
it). `{ data: null, version: 0 }` when they never saved.
|
|
125
|
+
- `savePlayerState(data, { ifVersion? }?)` → `Promise<{ saved, version?,
|
|
126
|
+
conflict?, guest?, queued? }>` — save any JSON ≤ 256KB to their slot. For
|
|
127
|
+
guests the value is QUEUED in memory and auto-flushed if they sign in
|
|
128
|
+
mid-game — no extra code. Small saves survive tab close automatically.
|
|
129
|
+
- `loadWorldState()` / `saveWorldState(data, { ifVersion? }?)` — the game's
|
|
130
|
+
ONE shared world slot (≤ 1MB, every player reads/writes the same blob) —
|
|
131
|
+
level layouts, persistent-world object positions. Multiplayer games: only
|
|
132
|
+
the host writes it (see the multiplayer skill). Always pass `ifVersion`
|
|
133
|
+
here (shared slot = real races); a losing write resolves
|
|
134
|
+
`{ conflict: true, version }` — reload, merge, retry.
|
|
135
|
+
- `submitScore(score, { board?, mode? }?)` → `Promise<{ submitted, best?,
|
|
136
|
+
improved?, guest?, queued? }>` — keep-best leaderboard submit (`mode:
|
|
137
|
+
"min"` for lap-time boards). Guests: best value queues, flushes on sign-in.
|
|
138
|
+
- `getLeaderboard({ board?, limit?, order? }?)` → `Promise<{ items, me }>` —
|
|
139
|
+
top entries (verified display names — never trust client-side name input
|
|
140
|
+
for this) + the signed-in player's own `{ rank, score }`. Works for guests
|
|
141
|
+
too (`me: null`).
|
|
142
|
+
|
|
117
143
|
From `@genex-ai/embed-sdk/sentry` (crash reporting; exactly these two):
|
|
118
144
|
|
|
119
145
|
- `initGameSentry({ slug, dsn?, environment? })` — call once, BEFORE
|
|
@@ -134,30 +160,53 @@ function animate() {
|
|
|
134
160
|
}
|
|
135
161
|
```
|
|
136
162
|
|
|
137
|
-
## Saving progress
|
|
163
|
+
## Saving progress (per-player — every player has their own slot)
|
|
138
164
|
|
|
139
|
-
|
|
165
|
+
Use the SDK helpers; never hand-roll fetch calls to the state API. Progression,
|
|
166
|
+
inventory, unlocks — anything about ONE player — goes in their player slot:
|
|
140
167
|
|
|
141
168
|
```ts
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
169
|
+
import { loadPlayerState, savePlayerState } from "@genex-ai/embed-sdk";
|
|
170
|
+
|
|
171
|
+
// boot: load whatever this player saved last time (fine for guests — resolves
|
|
172
|
+
// { data: null, guest: true } instead of failing)
|
|
173
|
+
const { data } = await loadPlayerState();
|
|
174
|
+
applyProgress(data ?? defaultProgress());
|
|
175
|
+
|
|
176
|
+
// checkpoints / level-ups: fire-and-forget, DEBOUNCED (not per frame)
|
|
177
|
+
void savePlayerState(progress);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Guests need zero special handling: their saves queue in memory and auto-flush
|
|
181
|
+
the moment they sign in mid-game (so a guest's progress follows them into
|
|
182
|
+
their new account), and `waitForAuth()` resolving is the signal that a real
|
|
183
|
+
account now exists. The SDK already tells guests to sign in (its popover / the
|
|
184
|
+
dashboard's card) — don't add another prompt.
|
|
185
|
+
|
|
186
|
+
**Per-player vs world:** `savePlayerState` is each player's own progress;
|
|
187
|
+
`saveWorldState` is the game's ONE shared world (persistent-world layouts —
|
|
188
|
+
see the multiplayer skill's persistence section for the host-writes pattern).
|
|
189
|
+
Never store per-player progression in the world slot: every player of the
|
|
190
|
+
game shares that single blob.
|
|
191
|
+
|
|
192
|
+
## Leaderboards
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { submitScore, getLeaderboard } from "@genex-ai/embed-sdk";
|
|
196
|
+
|
|
197
|
+
// on game over / lap complete — keep-best, so just submit every run
|
|
198
|
+
void submitScore(finalScore); // higher is better
|
|
199
|
+
void submitScore(lapMs, { board: "laps", mode: "min" }); // lower is better
|
|
152
200
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
// signed in (possibly after starting as a guest) — load their save now
|
|
156
|
-
}).catch(() => { /* blocked — SDK overlay owns the UX */ });
|
|
201
|
+
// render a top-10 + the player's own rank
|
|
202
|
+
const { items, me } = await getLeaderboard({ limit: 10 });
|
|
157
203
|
```
|
|
158
204
|
|
|
159
|
-
|
|
160
|
-
|
|
205
|
+
Scores are per-account (one row per player per board, real display names from
|
|
206
|
+
their Genex account) and keep-best — submitting a worse score changes nothing
|
|
207
|
+
(`improved: false`). Guests can READ leaderboards; their submits queue and
|
|
208
|
+
post when they sign in. Send a consistent `mode` per board. Scores are
|
|
209
|
+
client-reported (arcade-style trust) — don't present them as anti-cheat.
|
|
161
210
|
|
|
162
211
|
## Crash reporting rules
|
|
163
212
|
|
|
@@ -216,9 +265,13 @@ concerns.
|
|
|
216
265
|
- [ ] `genex.config.ts` includes `dashboardOrigins` (from `.genex/project.json`).
|
|
217
266
|
- [ ] Multiplayer `connect()` and player-name UI await `waitForPlayer()` —
|
|
218
267
|
NEVER `waitForAuth()` (guests would hang forever).
|
|
219
|
-
- [ ]
|
|
220
|
-
|
|
221
|
-
|
|
268
|
+
- [ ] Saves/loads use the SDK helpers (`savePlayerState`/`loadPlayerState` for
|
|
269
|
+
per-player progress, `saveWorldState`/`loadWorldState` for the shared
|
|
270
|
+
world, `submitScore`/`getLeaderboard` for scores) — no hand-rolled fetch
|
|
271
|
+
to `/state` endpoints, no manual guest gating (the helpers own it).
|
|
272
|
+
- [ ] Per-player progression lives in the PLAYER slot, never in the shared
|
|
273
|
+
world slot.
|
|
274
|
+
- [ ] Saves are debounced (checkpoints/level-ups), not per-frame.
|
|
222
275
|
- [ ] No token value is ever logged or sent to analytics.
|
|
223
276
|
- [ ] No custom sign-in prompt, guest badge, or auth overlay — the SDK popover/
|
|
224
277
|
overlay and the dashboard own all of that UX.
|
|
@@ -241,6 +294,12 @@ concerns.
|
|
|
241
294
|
- **Multiplayer join rejected with 403 "guest capacity"** — the room is at its
|
|
242
295
|
guest limit; only signing in gets the player a seat right now. Surface the
|
|
243
296
|
relay's message as-is.
|
|
244
|
-
- **`/state` returns 401/403** — missing `Authorization`
|
|
245
|
-
token (403 `guest_no_save`
|
|
246
|
-
|
|
297
|
+
- **`/state` returns 401/403 (hand-rolled fetch)** — missing `Authorization`
|
|
298
|
+
header (401), a guest token (403 `guest_no_save`), or the token belongs to a
|
|
299
|
+
different game (403). All three mean the code bypassed the SDK helpers —
|
|
300
|
+
switch to `savePlayerState`/`saveWorldState`, which handle every case.
|
|
301
|
+
- **`saveWorldState` resolves `{ conflict: true }`** — another player wrote the
|
|
302
|
+
shared slot since your last read. Expected under concurrency: reload with
|
|
303
|
+
`loadWorldState()`, merge, retry with the fresh `version`. If it happens
|
|
304
|
+
constantly, more than one client is acting as the writer — in multiplayer,
|
|
305
|
+
only the host should save the world.
|
|
@@ -207,38 +207,42 @@ file layout.
|
|
|
207
207
|
## Persistent worlds (optional — survives restarts)
|
|
208
208
|
|
|
209
209
|
The relay is in-memory: room state is gone when everyone leaves or the server restarts. For a world
|
|
210
|
-
that persists
|
|
211
|
-
|
|
212
|
-
Both calls **require a signed-in identity** (`Authorization: Bearer` with the token
|
|
213
|
-
from `getEmbedToken()`) — guests play multiplayer but cannot read or write saves
|
|
214
|
-
(the server answers their token `403 guest_no_save`). Gate them on
|
|
215
|
-
`await waitForAuth()` — the ACCOUNT gate, deliberately stricter than the
|
|
216
|
-
`waitForPlayer()` gate `connect()` uses (see the `genex-threejs-embed-auth` skill):
|
|
210
|
+
that persists (a driven car stays where it was parked, built structures survive), save/load the
|
|
211
|
+
game's shared world slot via the embed SDK, from **one authority** (the host):
|
|
217
212
|
|
|
218
213
|
```ts
|
|
219
|
-
import {
|
|
214
|
+
import { loadWorldState, saveWorldState } from "@genex-ai/embed-sdk";
|
|
220
215
|
|
|
221
|
-
// load on boot (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
216
|
+
// load on boot (safe to call immediately — waits for identity internally;
|
|
217
|
+
// guests resolve { data: null, guest: true } and receive the live world
|
|
218
|
+
// through the room instead)
|
|
219
|
+
const { data, version } = await loadWorldState();
|
|
225
220
|
initWorld(data ?? defaultWorld());
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
221
|
+
let worldVersion = version;
|
|
222
|
+
|
|
223
|
+
// save from ONE authority — the elected host — debounced, WITH ifVersion so a
|
|
224
|
+
// stale host handoff loses loudly instead of silently clobbering:
|
|
225
|
+
async function persistWorld(world: unknown) {
|
|
226
|
+
if (!room.isHost) return;
|
|
227
|
+
const res = await saveWorldState(world, { ifVersion: worldVersion });
|
|
228
|
+
if (res.saved) worldVersion = res.version!;
|
|
229
|
+
else if (res.conflict) {
|
|
230
|
+
// someone else wrote since our read (host migration race) — resync
|
|
231
|
+
const fresh = await loadWorldState();
|
|
232
|
+
worldVersion = fresh.version;
|
|
233
|
+
mergeWorld(fresh.data);
|
|
234
|
+
}
|
|
235
|
+
// res.guest: this host is a guest (guest-only room) — saving is off until a
|
|
236
|
+
// signed-in player joins; the relay prefers signed-in hosts automatically.
|
|
237
|
+
}
|
|
236
238
|
```
|
|
237
239
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
The slot is one JSON blob per game (≤ 1 MB) shared by every player — world layout only,
|
|
241
|
+
NEVER per-player progression (that belongs in each player's own `savePlayerState()` slot —
|
|
242
|
+
see the embed-auth skill). Don't save every frame: debounce (~1/sec), and also flush on
|
|
243
|
+
`document.visibilitychange === "hidden"` so the last edits survive the host closing the tab.
|
|
244
|
+
Guests can't write it, but the relay elects a signed-in host whenever one is present, so
|
|
245
|
+
host-driven saving works as long as ANY account is in the room.
|
|
242
246
|
|
|
243
247
|
## Checklist
|
|
244
248
|
|
|
@@ -183,35 +183,50 @@ const px = t.stateRaw.x, pz = t.stateRaw.z; // test against the raw latest
|
|
|
183
183
|
## Persistence helper
|
|
184
184
|
|
|
185
185
|
```ts
|
|
186
|
-
// persistence.ts —
|
|
187
|
-
//
|
|
188
|
-
|
|
186
|
+
// persistence.ts — the embed SDK owns tokens, guest handling, and conflict
|
|
187
|
+
// decoding (see the genex-threejs-embed-auth skill). World slot = shared
|
|
188
|
+
// layout only; per-player progression goes in savePlayerState() instead.
|
|
189
|
+
import { loadWorldState, saveWorldState } from "@genex-ai/embed-sdk";
|
|
190
|
+
|
|
191
|
+
let worldVersion = 0;
|
|
189
192
|
|
|
190
193
|
export async function loadWorld<T>(fallback: T): Promise<T> {
|
|
191
194
|
try {
|
|
192
|
-
const { data } = await
|
|
193
|
-
|
|
194
|
-
}).then(r => r.json());
|
|
195
|
+
const { data, version } = await loadWorldState(); // waits for identity itself
|
|
196
|
+
worldVersion = version;
|
|
195
197
|
return (data as T) ?? fallback;
|
|
196
198
|
} catch { return fallback; }
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
202
|
+
let latest: unknown;
|
|
200
203
|
export function saveWorld(world: unknown) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}).catch(() => {});
|
|
212
|
-
}, 1000);
|
|
204
|
+
latest = world; // debounce: at most one write/sec, host only
|
|
205
|
+
if (saveTimer) return;
|
|
206
|
+
saveTimer = setTimeout(() => { saveTimer = null; void flushWorld(); }, 1000);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function flushWorld() {
|
|
210
|
+
const res = await saveWorldState(latest, { ifVersion: worldVersion }).catch(() => null);
|
|
211
|
+
if (!res) return;
|
|
212
|
+
if (res.saved) worldVersion = res.version!;
|
|
213
|
+
else if (res.conflict) worldVersion = res.version!; // stale after a host race — next save wins
|
|
213
214
|
}
|
|
215
|
+
|
|
216
|
+
// The host closing their tab must not lose the last edits: flush immediately
|
|
217
|
+
// when the page goes hidden (savePlayerState/saveWorldState small writes ride
|
|
218
|
+
// fetch keepalive, so this completes even mid-unload).
|
|
219
|
+
document.addEventListener("visibilitychange", () => {
|
|
220
|
+
if (document.visibilityState === "hidden" && saveTimer) {
|
|
221
|
+
clearTimeout(saveTimer);
|
|
222
|
+
saveTimer = null;
|
|
223
|
+
void flushWorld();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
214
226
|
```
|
|
215
227
|
|
|
216
|
-
State is one JSON blob per project, max 1 MB,
|
|
217
|
-
(`room.isHost`) so concurrent writers don't clobber each other
|
|
228
|
+
State is one JSON blob per project, max 1 MB, shared by every player. Save from a single
|
|
229
|
+
authority (`room.isHost`) so concurrent writers don't clobber each other; `ifVersion` turns
|
|
230
|
+
any remaining race into a visible `conflict` instead of silent data loss. Guests can't
|
|
231
|
+
write it — the relay prefers signed-in players as host, so saving works whenever any
|
|
232
|
+
account is in the room (guest-only rooms simply don't persist until one joins).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-threejs-physics-rapier
|
|
3
|
+
description: Set up Rapier physics for Genex Three.js games with the vendored PhysicsWorld glue — async WASM init, fixed-timestep world, body-to-mesh sync, collision events — and pick the right collider per asset (cuboid, hull, trimesh, including genex model GLBs). Use whenever anything falls, collides, gets pushed, or stands on moving ground; load before writing any physics code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Three.js Physics (Rapier)
|
|
7
|
+
|
|
8
|
+
Genex games run physics on Rapier (`@dimforge/rapier3d-compat`) through a small
|
|
9
|
+
vendored glue layer: `PhysicsWorld` owns the world, the fixed-timestep loop,
|
|
10
|
+
the rigid-body ↔ `Object3D` sync, and collision events; `shared/colliders.ts`
|
|
11
|
+
turns meshes and GLBs into colliders. You do not install this from a skill —
|
|
12
|
+
a CLI command drops the code into the game.
|
|
13
|
+
|
|
14
|
+
## Get the code (one command, not hand-rolled)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx genex controller character # or car / drone — every kind installs shared/
|
|
18
|
+
npm i @dimforge/rapier3d-compat
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The command copies `src/controllers/shared/physics-world.ts`,
|
|
22
|
+
`shared/colliders.ts`, and `shared/math.ts` into the game (plus the controller
|
|
23
|
+
you picked). Even a game that only needs loose physics — falling crates, a
|
|
24
|
+
rolling ball — should run it once for the `shared/` layer; existing files are
|
|
25
|
+
never overwritten without `--force`.
|
|
26
|
+
|
|
27
|
+
**Never hand-write character or vehicle physics.** A capsule you push around
|
|
28
|
+
with raw impulses will clip slopes, stutter on stairs, and slide off moving
|
|
29
|
+
platforms — this is the single most common way physics games go wrong. Tuned,
|
|
30
|
+
tested controllers already exist: load `$genex-threejs-character-controller`
|
|
31
|
+
for walking/running/jumping and `$genex-threejs-vehicle-controllers` for cars
|
|
32
|
+
and drones, each installed by the same `npx genex controller` command.
|
|
33
|
+
|
|
34
|
+
## Minimal working setup
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import * as THREE from "three";
|
|
38
|
+
import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
|
|
39
|
+
import { cuboidCollider } from "./controllers/shared/colliders.ts";
|
|
40
|
+
|
|
41
|
+
const physics = await PhysicsWorld.create(); // awaits RAPIER.init() (embedded WASM)
|
|
42
|
+
const world = physics.world; // the raw Rapier world
|
|
43
|
+
|
|
44
|
+
// Static ground: a box whose top face is at y = 0.
|
|
45
|
+
const ground = new THREE.Mesh(new THREE.BoxGeometry(100, 0.5, 100), groundMat);
|
|
46
|
+
scene.add(ground);
|
|
47
|
+
const groundBody = physics.createBody({ type: "fixed", position: [0, -0.25, 0] }, ground);
|
|
48
|
+
cuboidCollider(world, groundBody, [50, 0.25, 50], { friction: 1 });
|
|
49
|
+
|
|
50
|
+
// Dynamic crate: passing the mesh to createBody registers it — the mesh
|
|
51
|
+
// follows the body automatically every frame, interpolated between steps.
|
|
52
|
+
const crate = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), crateMat);
|
|
53
|
+
scene.add(crate);
|
|
54
|
+
const crateBody = physics.createBody({ type: "dynamic", position: [0, 3, 0] }, crate);
|
|
55
|
+
cuboidCollider(world, crateBody, [0.5, 0.5, 0.5], { friction: 0.6, density: 1 });
|
|
56
|
+
|
|
57
|
+
// Anything that pushes bodies runs once per fixed substep, before the step:
|
|
58
|
+
physics.onBeforeStep(() => {
|
|
59
|
+
// controller.update(), impulses, kinematic platform poses ...
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const clock = new THREE.Clock();
|
|
63
|
+
renderer.setAnimationLoop(() => {
|
|
64
|
+
physics.step(clock.getDelta()); // fixed substeps + mesh sync + events
|
|
65
|
+
// camera follow, animation mixers — render-delta work stays OUT here
|
|
66
|
+
renderer.render(scene, camera);
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Note `cuboidCollider` takes **half** extents: `[0.5, 0.5, 0.5]` is a 1×1×1 box.
|
|
71
|
+
|
|
72
|
+
## The update ordering contract
|
|
73
|
+
|
|
74
|
+
Physics code never sees the render framerate. Keep this order or controllers
|
|
75
|
+
misbehave in ways that look like tuning problems:
|
|
76
|
+
|
|
77
|
+
1. **Inside `physics.onBeforeStep(...)`** — runs once per fixed substep, in
|
|
78
|
+
this order: kinematic platform poses (`setNextKinematicTranslation` /
|
|
79
|
+
`setNextKinematicRotation`), then `EnterExitManager.update(dt)` if the game
|
|
80
|
+
has enter/exit vehicles, then each controller's `update()`. Controllers
|
|
81
|
+
ignore any dt argument and read `world.timestep` internally — never feed
|
|
82
|
+
them the render delta.
|
|
83
|
+
2. **`physics.step(delta)`** — once per `requestAnimationFrame`, with the
|
|
84
|
+
render clock delta in **seconds**. It runs zero or more fixed substeps,
|
|
85
|
+
syncs registered meshes (interpolated), and drains collision events.
|
|
86
|
+
3. **After `step`** — cameras, animation mixers, HUD: render-delta work. Gate
|
|
87
|
+
once-per-physics-step camera corrections on `physics.stepsLastFrame > 0`.
|
|
88
|
+
|
|
89
|
+
Read [references/physics-setup.md](references/physics-setup.md) for world
|
|
90
|
+
options, the body registry and its caveats, kinematic platforms, collision and
|
|
91
|
+
sensor events, sleeping bodies, CCD/tunneling, pause/slow-mo, and debug
|
|
92
|
+
rendering.
|
|
93
|
+
|
|
94
|
+
Read [references/colliders-from-assets.md](references/colliders-from-assets.md)
|
|
95
|
+
for the collider decision table per asset type — `genex model` GLBs, level
|
|
96
|
+
geometry, primitives — hull vs trimesh tradeoffs, and the scale pitfalls.
|
|
97
|
+
|
|
98
|
+
## Benign boot warning — do not chase it
|
|
99
|
+
|
|
100
|
+
`@dimforge/rapier3d-compat` 0.19.3 logs once at startup:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
using deprecated parameters for the initialization function; pass a single object instead
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This comes from the library's own embedded WASM loader, cannot be fixed from
|
|
107
|
+
game code, and is harmless. Ignore it; do not refactor init code trying to
|
|
108
|
+
silence it.
|
|
109
|
+
|
|
110
|
+
## Failure conditions
|
|
111
|
+
|
|
112
|
+
- physics stepped with the raw render delta instead of `physics.step`'s fixed
|
|
113
|
+
accumulator (speed varies with framerate);
|
|
114
|
+
- a controller updated outside `onBeforeStep`, or fed the render delta;
|
|
115
|
+
- a dynamic body repositioned with `setTranslation` every frame instead of
|
|
116
|
+
being kinematic;
|
|
117
|
+
- a trimesh collider on a fast dynamic body (falls through the floor);
|
|
118
|
+
- colliders built from a model whose scale changes afterwards;
|
|
119
|
+
- a mesh registered to a body after its parent moved or scaled (silent desync);
|
|
120
|
+
- hand-written character/vehicle physics instead of `npx genex controller`.
|
|
121
|
+
|
|
122
|
+
## Routing boundary
|
|
123
|
+
|
|
124
|
+
Player movement, jumping, slopes, and animation state belong to
|
|
125
|
+
`$genex-threejs-character-controller`. Cars, drones, and enter/exit flow belong
|
|
126
|
+
to `$genex-threejs-vehicle-controllers`. Chase/orbit camera design beyond the
|
|
127
|
+
bundled follow camera is `$genex-threejs-camera-direction`. Meshes for props
|
|
128
|
+
come from `$genex-ai-model`; this skill only decides how they collide.
|