@genex-ai/cli-demo 0.11.0 → 0.14.2
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 +126 -54
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
- 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,24 +1,31 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: genex-threejs-embed-auth
|
|
3
|
-
description: Wire up
|
|
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); saving progress requires sign-in, and multiplayer requires the SDK's token either way.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Genex Three.js Embed Auth
|
|
7
7
|
|
|
8
8
|
`@genex-ai/embed-sdk` is how a Genex game learns **who is playing it**. Every
|
|
9
|
-
game needs it
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
game needs it. Published games are playable by **guests** (no account — the
|
|
10
|
+
SDK mints a temporary identity like `Guest-1234` automatically), while
|
|
11
|
+
**signing in** unlocks saving/loading progress; multiplayer works for both,
|
|
12
|
+
using the SDK's token. The SDK handles every context with one
|
|
13
|
+
`initEmbed(...)` call:
|
|
14
|
+
|
|
15
|
+
- **Embedded in the Genex dashboard (an iframe):** a silent handshake signs
|
|
16
|
+
the viewer in within a couple of seconds — or drops a signed-out viewer
|
|
17
|
+
straight into guest play. No login screen either way.
|
|
18
|
+
- **Standalone (someone opens the game's link directly):** the visit bounces
|
|
19
|
+
once through Genex (at the CDN edge in production, so the game only loads
|
|
20
|
+
AFTER identity is resolved; the SDK does the same bounce itself in local
|
|
21
|
+
dev) — signed-in visitors arrive already authenticated on ANY game with
|
|
22
|
+
zero clicks; everyone else arrives as a guest with a small dismissible
|
|
23
|
+
"sign in to save progress" popover (rendered by the SDK — don't build your
|
|
24
|
+
own). Nobody ever hits a login wall on a published game.
|
|
25
|
+
|
|
26
|
+
While identity is resolving (or blocked) the SDK shows its own full-screen
|
|
21
27
|
overlay over the game, so never build a separate "connecting" screen for auth.
|
|
28
|
+
Guest sessions have **no** overlay — the game just plays.
|
|
22
29
|
|
|
23
30
|
## Install
|
|
24
31
|
|
|
@@ -50,42 +57,69 @@ initEmbed({
|
|
|
50
57
|
});
|
|
51
58
|
```
|
|
52
59
|
|
|
60
|
+
Also give `<body>` a dark background in `index.html` (e.g.
|
|
61
|
+
`<body style="margin:0;background:#080a14">`) — it makes the pre-boot frame
|
|
62
|
+
(before any JS runs) match the SDK's own loading overlay instead of flashing
|
|
63
|
+
white, and every Genex surface assumes a dark canvas anyway.
|
|
64
|
+
|
|
53
65
|
Scene setup and asset loading may continue immediately after this call — auth
|
|
54
|
-
never blocks rendering.
|
|
55
|
-
|
|
66
|
+
never blocks rendering. There are **two gates**, and picking the right one
|
|
67
|
+
matters:
|
|
56
68
|
|
|
57
69
|
```ts
|
|
58
|
-
import { waitForAuth, getColyseusAuth, getEmbedToken } from "@genex-ai/embed-sdk";
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
70
|
+
import { waitForPlayer, waitForAuth, getColyseusAuth, getEmbedToken } from "@genex-ai/embed-sdk";
|
|
71
|
+
|
|
72
|
+
// PLAYER gate — resolves for guests AND signed-in players. Use for
|
|
73
|
+
// multiplayer connect() and player-name UI. This is the gate almost
|
|
74
|
+
// everything wants.
|
|
75
|
+
const { user, guest } = await waitForPlayer();
|
|
76
|
+
// user.id / user.name — real account identity, or guest:<id> / "Guest-1234"
|
|
77
|
+
|
|
78
|
+
// ACCOUNT gate — resolves ONLY for signed-in players (stays pending for
|
|
79
|
+
// guests; resolves later if they sign in mid-game). Use ONLY for /state
|
|
80
|
+
// saving/loading and other account-bound features.
|
|
81
|
+
const { user: account } = await waitForAuth();
|
|
62
82
|
```
|
|
63
83
|
|
|
84
|
+
**NEVER gate scene boot or `connect()` on `waitForAuth()`** — for a guest it
|
|
85
|
+
stays pending forever and your game would sit empty. `waitForPlayer()` is the
|
|
86
|
+
boot-path gate; `waitForAuth()` guards saves only.
|
|
87
|
+
|
|
64
88
|
## API surface (exact — do not invent methods)
|
|
65
89
|
|
|
66
90
|
- `initEmbed({ slug, apiUrl, dashboardOrigins })` — call once, first. All three
|
|
67
91
|
fields required (from `genex.config.ts`).
|
|
68
|
-
- `
|
|
69
|
-
|
|
70
|
-
|
|
92
|
+
- `waitForPlayer()` → `Promise<{ user, guest }>` — THE gate for `connect()`
|
|
93
|
+
and player-name UI; resolves for guests and accounts alike, rejects only if
|
|
94
|
+
the session ends up blocked.
|
|
95
|
+
- `waitForAuth()` → `Promise<{ user }>` — the gate for `/state` calls and
|
|
96
|
+
account-bound features. Stays PENDING for guests (resolves live if they
|
|
97
|
+
sign in); rejects if blocked. Catch rejections and let the SDK's overlay
|
|
98
|
+
handle the UX (don't build your own sign-in UI).
|
|
71
99
|
- `isEmbedded()` → `boolean` — structural "is in an iframe" check; NOT the same
|
|
72
100
|
question as "is signed in".
|
|
73
|
-
- `getAuthState()` → `"pending" | "authenticated" | "blocked"` —
|
|
74
|
-
|
|
101
|
+
- `getAuthState()` → `"pending" | "authenticated" | "guest" | "blocked"` —
|
|
102
|
+
synchronous.
|
|
103
|
+
- `getUser()` → `{ id, name, image? } | null` — non-null once authenticated OR
|
|
104
|
+
guest. Guest ids are prefixed `guest:`.
|
|
75
105
|
- `getEmbedToken()` → `string | undefined` — for `Authorization: Bearer` on
|
|
76
|
-
`GET`/`PUT ${GENEX.apiUrl}/api/projects/${GENEX.slug}/state
|
|
106
|
+
`GET`/`PUT ${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`. Defined for
|
|
107
|
+
guests too, but `/state` answers guests with `403 { "error": "guest_no_save" }`
|
|
108
|
+
— that's why saves gate on `waitForAuth()`, not on the token existing.
|
|
77
109
|
- `getColyseusAuth()` → `{ embedToken } | undefined` — pass as `connect()`'s
|
|
78
|
-
`auth` option (REQUIRED — the relay rejects tokenless joins
|
|
79
|
-
at every `connect()` call; tokens rotate
|
|
80
|
-
|
|
81
|
-
|
|
110
|
+
`auth` option (REQUIRED — the relay rejects tokenless joins; guest tokens
|
|
111
|
+
are accepted). Read it fresh at every `connect()` call; tokens rotate
|
|
112
|
+
automatically (~every 10 minutes).
|
|
113
|
+
- `on(event, cb)` → unsubscribe fn. Events: `"authenticated"`, `"guest"`,
|
|
114
|
+
`"blocked"`, `"error"`. A mid-game sign-in fires `"authenticated"` after
|
|
115
|
+
`"guest"` — progress saving can start right then, no reload.
|
|
82
116
|
|
|
83
117
|
From `@genex-ai/embed-sdk/sentry` (crash reporting; exactly these two):
|
|
84
118
|
|
|
85
119
|
- `initGameSentry({ slug, dsn?, environment? })` — call once, BEFORE
|
|
86
120
|
`initEmbed()`. Only `slug` is required; the shared Genex Sentry project DSN
|
|
87
|
-
is built in. Errors, tracing, and session replay all start here; the
|
|
88
|
-
|
|
121
|
+
is built in. Errors, tracing, and session replay all start here; the current
|
|
122
|
+
player (account or guest) is attached automatically (no code needed).
|
|
89
123
|
- `sentryCanvasSnapshot(canvas)` — session replay records the DOM, not the 3D
|
|
90
124
|
canvas; call this once per frame at the END of the render loop so replays
|
|
91
125
|
show actual gameplay. Works for BOTH WebGL and WebGPU renderers; internally
|
|
@@ -100,6 +134,31 @@ function animate() {
|
|
|
100
134
|
}
|
|
101
135
|
```
|
|
102
136
|
|
|
137
|
+
## Saving progress with guests around
|
|
138
|
+
|
|
139
|
+
Guests play but cannot save — design the save path accordingly:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// Fire-and-forget save that is simply OFF for guests:
|
|
143
|
+
async function saveProgress(data: unknown) {
|
|
144
|
+
const token = getEmbedToken();
|
|
145
|
+
if (getAuthState() !== "authenticated" || !token) return; // guest: skip silently
|
|
146
|
+
await fetch(`${GENEX.apiUrl}/api/projects/${GENEX.slug}/state`, {
|
|
147
|
+
method: "PUT",
|
|
148
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
149
|
+
body: JSON.stringify(data),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// And start loading/saving the moment a guest upgrades mid-game:
|
|
154
|
+
waitForAuth().then(({ user }) => {
|
|
155
|
+
// signed in (possibly after starting as a guest) — load their save now
|
|
156
|
+
}).catch(() => { /* blocked — SDK overlay owns the UX */ });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The SDK already tells guests to sign in (its popover / the dashboard's card) —
|
|
160
|
+
don't add another prompt.
|
|
161
|
+
|
|
103
162
|
## Crash reporting rules
|
|
104
163
|
|
|
105
164
|
- `initGameSentry` has token scrubbing built in (the sign-in return-trip pass
|
|
@@ -136,13 +195,17 @@ If `src/genex.config.ts` is somehow missing (e.g. it was deleted), re-run
|
|
|
136
195
|
|
|
137
196
|
## Standalone behavior (what to expect, not something to code)
|
|
138
197
|
|
|
139
|
-
Opening
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
198
|
+
Opening a published game's link directly (not from the dashboard) shows a
|
|
199
|
+
brief "Loading…" overlay while the SDK round-trips through Genex once, then
|
|
200
|
+
the game starts: already-signed-in visitors come back authenticated (zero
|
|
201
|
+
clicks — on every game), everyone else comes back playing as a guest with the
|
|
202
|
+
SDK's own top-right "sign in to save progress" popover. The return trip
|
|
203
|
+
carries a one-time pass (or an inert guest marker) in the URL that the SDK
|
|
204
|
+
consumes and removes immediately. Unpublished drafts are the exception:
|
|
205
|
+
strangers can't play them, so a draft link shows the SDK's sign-in gate
|
|
206
|
+
instead. Don't code around any of this: no `?`/`#` URL params of yours will
|
|
207
|
+
be affected, and `isEmbedded()` / the return-trip handling are internal SDK
|
|
208
|
+
concerns.
|
|
146
209
|
|
|
147
210
|
## Checklist
|
|
148
211
|
|
|
@@ -151,24 +214,33 @@ internal SDK concerns.
|
|
|
151
214
|
- [ ] `sentryCanvasSnapshot(renderer.domElement)` runs after `renderer.render()`
|
|
152
215
|
in the main loop (WebGL and WebGPU alike).
|
|
153
216
|
- [ ] `genex.config.ts` includes `dashboardOrigins` (from `.genex/project.json`).
|
|
154
|
-
- [ ]
|
|
155
|
-
|
|
156
|
-
- [ ] `/state`
|
|
217
|
+
- [ ] Multiplayer `connect()` and player-name UI await `waitForPlayer()` —
|
|
218
|
+
NEVER `waitForAuth()` (guests would hang forever).
|
|
219
|
+
- [ ] `/state` load/save gates on `waitForAuth()` / `getAuthState() ===
|
|
220
|
+
"authenticated"` and sends `Authorization: Bearer ${getEmbedToken()}`;
|
|
221
|
+
guests skip saves silently (the server answers them `403 guest_no_save`).
|
|
157
222
|
- [ ] No token value is ever logged or sent to analytics.
|
|
158
|
-
- [ ] No custom sign-in
|
|
223
|
+
- [ ] No custom sign-in prompt, guest badge, or auth overlay — the SDK popover/
|
|
224
|
+
overlay and the dashboard own all of that UX.
|
|
159
225
|
|
|
160
226
|
## Troubleshooting
|
|
161
227
|
|
|
162
|
-
- **Game loads then immediately navigates away (standalone/local dev)** —
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
- **`waitForAuth()`
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
228
|
+
- **Game loads then immediately navigates away (standalone/local dev)** — the
|
|
229
|
+
identity bounce, working as designed for EVERY standalone visit. It returns
|
|
230
|
+
to the game automatically (signed-in or as a guest) within a second.
|
|
231
|
+
- **`waitForAuth()` never resolves** — the player is a GUEST; that's the
|
|
232
|
+
designed behavior. Anything that must run for guests belongs behind
|
|
233
|
+
`waitForPlayer()` instead.
|
|
234
|
+
- **State is `"blocked"` / `waitForPlayer()` rejects** — an unpublished draft
|
|
235
|
+
opened by a non-owner, or auth infrastructure was unreachable. The SDK
|
|
236
|
+
overlay (or the dashboard, when embedded) shows the sign-in prompt; the game
|
|
237
|
+
just stays paused behind it. Don't retry in a loop.
|
|
169
238
|
- **Multiplayer join rejected with 401** — `connect()` ran before
|
|
170
|
-
`
|
|
171
|
-
cached token on reconnect (read it fresh each call).
|
|
172
|
-
-
|
|
173
|
-
|
|
174
|
-
|
|
239
|
+
`waitForPlayer()` resolved, without `auth: getColyseusAuth()!`, or with a
|
|
240
|
+
stale cached token on reconnect (read it fresh each call).
|
|
241
|
+
- **Multiplayer join rejected with 403 "guest capacity"** — the room is at its
|
|
242
|
+
guest limit; only signing in gets the player a seat right now. Surface the
|
|
243
|
+
relay's message as-is.
|
|
244
|
+
- **`/state` returns 401/403** — missing `Authorization` header (401), a guest
|
|
245
|
+
token (403 `guest_no_save` — expected, skip saves for guests), or the token
|
|
246
|
+
belongs to a different game (403): `GENEX.slug` doesn't match this project.
|
|
@@ -44,17 +44,19 @@ npm i @genex-ai/multiplayer
|
|
|
44
44
|
Pick your own per-player state shape (any JSON). `room` is the **project slug**
|
|
45
45
|
(printed by `genex init`) — same id = same room, different ids are fully isolated.
|
|
46
46
|
|
|
47
|
-
**Joining requires
|
|
47
|
+
**Joining requires the SDK's player identity — the relay rejects tokenless
|
|
48
|
+
joins, but accepts guests** (accountless players named like `Guest-1234`).
|
|
48
49
|
Load the `genex-threejs-embed-auth` skill first (it sets up `initEmbed(...)`),
|
|
49
|
-
then gate `connect()` on `waitForAuth()
|
|
50
|
+
then gate `connect()` on `waitForPlayer()` — NOT `waitForAuth()`, which stays
|
|
51
|
+
pending for guests and would keep them out of multiplayer forever:
|
|
50
52
|
|
|
51
53
|
```ts
|
|
52
54
|
import { connect } from "@genex-ai/multiplayer";
|
|
53
|
-
import {
|
|
55
|
+
import { waitForPlayer, getColyseusAuth } from "@genex-ai/embed-sdk";
|
|
54
56
|
|
|
55
57
|
type State = { x: number; z: number; q: number[] }; // YOUR per-player state (rotation as quaternion)
|
|
56
58
|
|
|
57
|
-
const { user } = await
|
|
59
|
+
const { user } = await waitForPlayer(); // player gate (guest OR signed-in) — rejects only if blocked
|
|
58
60
|
const room = await connect<State>({
|
|
59
61
|
url: GENEX.colyseusUrl, // e.g. "wss://demo-colyseus.glotech.world" — see config wiring below
|
|
60
62
|
room: GENEX.slug, // the project slug — everyone with this id shares a room
|
|
@@ -207,9 +209,11 @@ file layout.
|
|
|
207
209
|
The relay is in-memory: room state is gone when everyone leaves or the server restarts. For a world
|
|
208
210
|
that persists, save/load one JSON blob keyed by the project slug, from **one authority** (the host):
|
|
209
211
|
|
|
210
|
-
Both calls **require
|
|
211
|
-
`getEmbedToken()`) —
|
|
212
|
-
|
|
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):
|
|
213
217
|
|
|
214
218
|
```ts
|
|
215
219
|
import { getEmbedToken } from "@genex-ai/embed-sdk";
|
|
@@ -239,8 +243,9 @@ single writer.
|
|
|
239
243
|
## Checklist
|
|
240
244
|
|
|
241
245
|
- [ ] `npm i @genex-ai/multiplayer` (≥ 0.4.0 for objects/host); config wired into the build.
|
|
242
|
-
- [ ] `connect()` runs AFTER `await
|
|
243
|
-
(the relay rejects tokenless joins —
|
|
246
|
+
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
247
|
+
hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
|
|
248
|
+
see `genex-threejs-embed-auth`).
|
|
244
249
|
- [ ] `room` is the **project slug**.
|
|
245
250
|
- [ ] `me.set` on a fixed **10–20 Hz** tick; full object each time.
|
|
246
251
|
- [ ] Skip yourself in `room.players` (`id === room.id`).
|
|
@@ -255,6 +260,7 @@ single writer.
|
|
|
255
260
|
## Troubleshooting auth
|
|
256
261
|
|
|
257
262
|
- **`connect()` rejects with 401/403** — 401 "auth required"/"invalid token": you joined
|
|
258
|
-
without `auth` or before `
|
|
263
|
+
without `auth` or before `waitForPlayer()` resolved (or the token expired mid-reconnect —
|
|
259
264
|
read `getColyseusAuth()` fresh at every connect). 403 "wrong game": the `room` value
|
|
260
|
-
doesn't match this game's own slug.
|
|
265
|
+
doesn't match this game's own slug. 403 "guest capacity": the room is at its guest
|
|
266
|
+
limit — signing in gets the player a seat; surface the message as-is.
|
|
@@ -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.
|