@genex-ai/cli-demo 0.88.0-dev.219 → 0.91.0-dev.228

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: genex-threejs-character-controller
3
- description: Add Genex's tuned ECCTRL-derived physics character controller with `npx genex controller character`: dynamic-capsule movement, follow camera, touch input, personal VRM + UAL animation, or an exact same-rig Meshy character. Use for every on-foot player or third-person movement request.
3
+ description: Add Genex's tuned ECCTRL-derived physics character controller with `npx genex controller character`: dynamic-capsule movement, follow camera, touch input, and one loader that resolves the player's body — the game's own generated character by default, the profile VRM avatar as the fallback. Use for every on-foot player or third-person movement request.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Character Controller
@@ -19,24 +19,47 @@ animation retargeting, capsule auto-fit, foot IK) and the 12-clip core
19
19
  ~1.3 MB) into `public/assets/`. Need more — swords, pistols, magic, climbing,
20
20
  swimming, emotes? Install exactly what the game uses with
21
21
  `npx genex controller anims <tags|clip names…>` (see Animations below). The
22
- default command also writes a fallback avatar to `public/assets/avatar.vrm`
23
- **your** avatar when you're signed in,
24
- otherwise a bundled CC0 default (attribution in `src/controllers/NOTICE.md`).
25
- At runtime the character plays as the **visiting player's own** picked avatar
26
- (`user.avatarUrl` from the embed identity — see the wiring below); the baked
27
- file is only the fallback for local dev and load failures. The copied files are then owned by the game —
22
+ copied files are then owned by the game
28
23
  edit them freely; re-running skips existing files unless `--force`. Do not write
29
24
  a character controller from scratch and do not swap in a kinematic-controller
30
25
  tutorial: this one is a real dynamic body that pushes crates, rides moving
31
26
  platforms, climbs stairs and slides on too-steep slopes out of the box.
32
27
 
33
- Use the VRM + UAL lane above by default. When the game needs a custom generated
34
- humanoid or an action unavailable in UAL, load `$genex-ai-character`, search
35
- Meshy's library first, and follow the reviewed image-first generation flow.
28
+ ## The player's body: the game's own character
29
+
30
+ **The player wears the character this game generated for itself.** That is the
31
+ default for any game where a human body appears on screen — third-person
32
+ obviously, and first-person too the moment remotes, a look-down body, a shadow,
33
+ a death cam, or a menu portrait shows one. Load `$genex-ai-character` and start
34
+ it EARLY, with your first art actions, so it lands around the v0 preview
35
+ instead of after it.
36
+
37
+ The profile VRM avatar is the **fallback**, and the command bakes a neutral CC0
38
+ copy to `public/assets/avatar.vrm` (attribution in `src/controllers/NOTICE.md`)
39
+ so a fresh game always has a working body. It is used when:
40
+
41
+ - the generated character hasn't landed yet — say in one plain line that this
42
+ body is temporary; or
43
+ - it genuinely could not be made (out of credits, generation failed, email
44
+ unverified) — say so in one line, record `Player character: VRM — <reason>`
45
+ in DESIGN.md, and keep building.
46
+
47
+ At runtime the fallback lane loads the **visiting player's own** picked avatar
48
+ (`user.avatarUrl` from the embed identity); the baked file covers local dev and
49
+ load failures. Games whose player is not a person at all — a car, a ship, an
50
+ RTS cursor, a board — generate that object with `npx genex model` instead and
51
+ never enter this lane.
52
+
53
+ **You never write two boot paths for this.** `loadPlayerCharacter()` decides at
54
+ runtime from `public/assets/meshy-character.json` and returns one shape either
55
+ way, so the character arriving mid-build is a file drop
56
+ (`npx genex controller character --character <id>`) and not an edit to your
57
+ most load-bearing code. Wire the block under "Minimal wiring" once, at hour 0.
58
+
36
59
  For a full custom LOCOMOTION SET beyond the UAL packs (a weapon-hold 8-way
37
- shooter kit, a signature gait), see that skill's motion section: `npx genex
38
- motion install --set rifle` drops the proven rifle set + its own runtime, and
39
- `genex motion gen` builds new sets from text.
60
+ shooter kit, a signature gait), see `$genex-ai-character`'s motion section:
61
+ `npx genex motion install --set rifle` drops the proven rifle set + its own
62
+ runtime, and `genex motion gen` builds new sets from text.
40
63
 
41
64
  Before generating a Meshy character, discuss two or three visual directions.
42
65
  When the user names a visual reference, inspect references before writing the
@@ -64,12 +87,12 @@ that model in R2. Before rigging, ask the user to approve a separate
64
87
  rigged and animated. (For these approvals, use your question tool when you
65
88
  have one; if you have none, a short numbered list in chat.)
66
89
 
67
- That is a separate, **same-rig Meshy-native lane**. Its animation-only GLBs are
68
- accepted only when their skeleton signature matches the active character
69
- revision; this lane does no runtime retargeting. The shared ECCTRL-derived
70
- dynamic controller stays authoritative for collision, grounding, facing, and
71
- world translation. The animation layer poses the visual rig; it never
72
- translates the visual root.
90
+ The generated character is a **same-rig Meshy-native lane**. Its animation-only
91
+ GLBs are accepted only when their skeleton signature matches the active
92
+ character revision; this lane does no runtime retargeting. The shared
93
+ ECCTRL-derived dynamic controller stays authoritative for collision, grounding,
94
+ facing, and world translation identical in both lanes. The animation layer
95
+ poses the visual rig; it never translates the visual root.
73
96
 
74
97
  Meshy manifests bypass browser cache, so newly installed actions must work
75
98
  after preview without asking the player to disable cache. `playOneShot()`
@@ -101,10 +124,11 @@ fork as a migration strategy. Install a fresh copy elsewhere and port only the n
101
124
  | `character/character-animations.ts` | `CharacterAnimations` | animation state machine, directional profiles, speed-matched cadence, `playOneShot`, procedural fallback |
102
125
  | `character/animation-packs.ts` | `loadCharacterClips` | loads the core + installed UAL packs and retargets them to the active VRM |
103
126
  | `character/meshy/meshy-loader.ts` | `loadMeshyCharacter` | loads a Meshy manifest, exact-signature model/clips, locomotion slots, and fallbacks |
127
+ | `character/player-character.ts` | `loadPlayerCharacter`, `loadRemotePlayerCharacter` | **the player's body** — the game's generated character when it has one, the avatar fallback when it doesn't, one shape either way |
104
128
  | `character/motion-actions.ts` | `MotionActionDriver` | applies only validated planar trajectories through the physics controller, never the visual root |
105
- | `character/vrm/*` | `loadVrm`, `retargetClips`, `capsuleFromModel`, `FootIK` | load the VRM avatar, retarget library clips onto its humanoid rig, auto-fit the capsule, ground the feet |
129
+ | `character/vrm/*` | `loadVrm`, `retargetClips`, `capsuleFromModel`, `FootIK` | the FALLBACK lane's parts: load a VRM avatar, retarget library clips onto its humanoid rig, auto-fit the capsule (works on both lanes), ground the feet |
106
130
 
107
- ## Minimal wiring: personal VRM lane
131
+ ## Minimal wiring
108
132
 
109
133
  ```ts
110
134
  import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
@@ -115,25 +139,27 @@ import { characterPresets } from "./controllers/character/presets.ts";
115
139
  import { FollowCamera } from "./controllers/character/follow-camera.ts";
116
140
  import { createAimCue } from "./controllers/character/aim-cue.ts";
117
141
  import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
118
- import { loadVrm } from "./controllers/character/vrm/vrm-loader.ts";
142
+ import { loadPlayerCharacter } from "./controllers/character/player-character.ts";
119
143
  import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
120
144
  import { waitForPlayer } from "@genex-ai/embed-sdk";
121
145
 
122
146
  const physics = await PhysicsWorld.create(); // nothing RAPIER-related may run before this resolves
123
147
 
124
- // Load the PLAYING user's avatarevery visitor plays as the avatar THEY
125
- // picked on their genex profile (guests get a per-session one), not the
126
- // creator's. `user.avatarUrl` comes from the embed identity
127
- // ($genex-threejs-embed-auth boots before this); the baked
128
- // `./assets/avatar.vrm` is the fallback for local dev, old APIs, and load
129
- // failures. Then load every animation the game has (the bundled core library +
130
- // any packs installed by `genex controller anims`), retargeted onto the VRM.
148
+ // The player's bodyONE call, both lanes. It plays as the character THIS
149
+ // GAME generated whenever `public/assets/meshy-character.json` is present
150
+ // (with that character's exact-rig clips and locomotion profile), and falls
151
+ // back to the visiting player's own profile avatar when it isn't — retargeting
152
+ // the bundled core library plus any packs installed by `genex controller
153
+ // anims`. `user.avatarUrl` comes from the embed identity
154
+ // ($genex-threejs-embed-auth boots before this) and is used only in that
155
+ // fallback lane; the baked `./assets/avatar.vrm` covers local dev and load
156
+ // failures. WRITE THIS ONCE: when the generated character lands mid-build,
157
+ // `genex controller character --character <id>` drops the manifest in and the
158
+ // next reload swaps the body. Nothing below changes.
131
159
  const { user } = await waitForPlayer(); // from "@genex-ai/embed-sdk"
132
- const { scene: avatar, vrm } = await loadVrm(user.avatarUrl ?? "./assets/avatar.vrm")
133
- .catch(() => loadVrm("./assets/avatar.vrm")); // network failure → bundled fallback
134
- const clips = await loadCharacterClips(vrm);
160
+ const player = await loadPlayerCharacter({ avatarUrl: user.avatarUrl });
135
161
 
136
- const fit = capsuleFromModel(avatar); // collider fits THIS avatar's bounds
162
+ const fit = capsuleFromModel(player.scene); // collider fits THIS body's bounds
137
163
  const character = new CharacterController(physics.world, camera, {
138
164
  ...characterPresets["default"].options,
139
165
  ...fit,
@@ -141,11 +167,15 @@ const character = new CharacterController(physics.world, camera, {
141
167
  userData: { controller: { excludeVehicleRay: true } }, // car wheels must never drive on the player
142
168
  });
143
169
  scene.add(character.root);
144
- character.root.add(avatar); // parent the avatar under the character root
145
- avatar.position.y = fit.modelOffsetY; // root = capsule CENTER; drop the model so feet touch the floor
170
+ character.root.add(player.scene); // parent the body under the character root
171
+ player.scene.position.y = fit.modelOffsetY; // root = capsule CENTER; drop the model so feet touch the floor
146
172
  physics.registerBody(character.body, character.root); // root now follows the body, interpolated
147
173
 
148
- const anims = new CharacterAnimations(avatar, clips);
174
+ // locomotionProfile is present in the generated lane and undefined in the
175
+ // fallback — pass it either way; CharacterAnimations handles both.
176
+ const anims = new CharacterAnimations(player.scene, player.clips, {
177
+ locomotionProfile: player.locomotionProfile,
178
+ });
149
179
  addEventListener("pointerdown", () => anims.playOneShot("Punch_Jab")); // punch on click
150
180
 
151
181
  const kb = new KeyboardInput();
@@ -184,7 +214,10 @@ renderer.setAnimationLoop(() => {
184
214
  followCam.update(delta);
185
215
 
186
216
  anims.update(character, delta); // see the animations reference
187
- vrm.update(delta); // REQUIRED — ticks the humanoid rig + spring bones
217
+ player.update(delta); // REQUIRED — ticks the VRM humanoid + spring bones
218
+ // in the fallback lane, a no-op for native rigs.
219
+ // Always call it: that is why the lane can change
220
+ // under you without this loop changing.
188
221
  renderer.render(scene, camera);
189
222
  });
190
223
  ```
@@ -312,20 +345,39 @@ and the style-matching rules live in `$genex-threejs-touch-controls`.
312
345
 
313
346
  **The local player is physics-authoritative; remote players are interpolated
314
347
  visuals only.** Exactly one `CharacterController` exists — yours. For every
315
- remote player: load **that player's own avatar** —
316
- `loadVrm(p.avatarUrl || "./assets/avatar.vrm")` (the multiplayer SDK's
317
- `player.avatarUrl` is their verified profile pick; fall back to the baked file
318
- when it's empty or fails to load, and `VRMUtils.deepDispose` the model when
319
- they leave) — move it with the interpolator from `$genex-threejs-multiplayer`,
320
- and **never** create a rigid body, a `CharacterController`, or any physics for
321
- it. Simulating remote players' physics locally guarantees divergence — every
322
- client would compute a different world. Never render every remote with your
323
- own avatar file: players picked their looks, show them. And a remote player's
324
- body is **never hand-built primitives** (no capsule-plus-cone "person"): in a
325
- themed game remotes wear the SAME themed character model the local player
326
- uses; otherwise they wear their `p.avatarUrl` VRM. First-person games are not
327
- exempt — the local player may be invisible to themselves
328
- (`setFirstPersonBody`), but every remote is a full character on screen.
348
+ remote player:
349
+
350
+ ```ts
351
+ import { loadRemotePlayerCharacter } from "./controllers/character/player-character.ts";
352
+
353
+ const remote = await loadRemotePlayerCharacter({ avatarUrl: p.avatarUrl });
354
+ scene.add(remote.scene);
355
+ const remoteAnims = new CharacterAnimations(remote.scene, remote.clips, {
356
+ locomotionProfile: remote.locomotionProfile,
357
+ });
358
+ // on 'leave': remote.dispose()
359
+ ```
360
+
361
+ Same rule as the local player, and the same one call: **in a game with its own
362
+ generated character, every remote wears it** — the game should look like the
363
+ game that was designed, and a mixed roster (one themed knight plus three stock
364
+ avatars) is the same incoherence as capsule-and-cone remotes. Only when this
365
+ game has NO generated character does each remote wear their own
366
+ `p.avatarUrl` VRM, which is then the right look. Several generated characters
367
+ (per class, per team, a picker) are fine when the player asks for them — the
368
+ rule is a coherent themed cast, not exactly one model. Name tags distinguish
369
+ players; their bodies don't have to.
370
+
371
+ The loader shares one parsed base across every remote (N remotes ≈ 1 body of
372
+ GPU memory) and `remote.dispose()` detaches the clone without touching those
373
+ shared resources. Move it with the interpolator from
374
+ `$genex-threejs-multiplayer`, and **never** create a rigid body, a
375
+ `CharacterController`, or any physics for it. Simulating remote players'
376
+ physics locally guarantees divergence — every client would compute a different
377
+ world. And a remote player's body is **never hand-built primitives** (no
378
+ capsule-plus-cone "person"). First-person games are not exempt — the local
379
+ player may be invisible to themselves (`setFirstPersonBody`), but every remote
380
+ is a full character on screen.
329
381
 
330
382
  - **Publish `character.netState()`** on the fixed 10–20 Hz tick (never per frame):
331
383
  `room.me.set(character.netState())`. It bundles the network-safe position, a four-number
@@ -8,12 +8,16 @@ clips only on the exact matching generated rig revision.
8
8
 
9
9
  ## The bundled assets + animation packs
10
10
 
11
- `npx genex controller character` sets the game up to play as a **VRM avatar**:
12
-
13
- - `public/assets/avatar.vrm` — the FALLBACK avatar (yours when signed in, else a
14
- bundled CC0 default). Always present; always one path. At runtime the game
15
- loads the visiting player's own picked avatar instead (`user.avatarUrl` from
16
- the embed identity) this file covers local dev and load failures.
11
+ `npx genex controller character` installs both lanes of the player's body the
12
+ game's own generated character (the default) and the VRM avatar fallback:
13
+
14
+ - `public/assets/meshy-character.json` written by
15
+ `npx genex controller character --character <id>` once the game's character
16
+ has been generated. Its presence is what `loadPlayerCharacter` routes on.
17
+ - `public/assets/avatar.vrm` — the FALLBACK body (the bundled CC0 default).
18
+ Always present; always one path. At runtime the fallback lane loads the
19
+ visiting player's own picked avatar instead (`user.avatarUrl` from the embed
20
+ identity) — this file covers local dev and load failures.
17
21
  - `public/assets/animation-library.glb` (~1.3 MB) — the 12-clip core
18
22
  (idle/walk/jog/sprint, the jump trio, crouch idle+move, hit, death, interact)
19
23
  on a shared Quaternius rig (provenance in `src/controllers/NOTICE.md`).
@@ -26,38 +30,39 @@ clips only on the exact matching generated rig revision.
26
30
  VRM helpers live in `src/controllers/character/vrm/`. Install three-vrm once:
27
31
  `npm i @pixiv/three-vrm`.
28
32
 
29
- Load the avatar, then EVERYTHING the game has via `loadCharacterClips` core
30
- library + installed packs, each retargeted onto the avatar's humanoid rig —
31
- all with **relative** paths so the published game works under its subpath:
33
+ `loadPlayerCharacter` loads the body and everything the game can play on it in
34
+ one call the generated character's exact-rig clips, or the core library plus
35
+ installed packs retargeted onto the avatar's humanoid rig. All with
36
+ **relative** paths so the published game works under its subpath:
32
37
 
33
38
  ```ts
34
- import { loadVrm } from "./controllers/character/vrm/vrm-loader.ts";
35
- import { loadCharacterClips } from "./controllers/character/animation-packs.ts";
39
+ import { loadPlayerCharacter } from "./controllers/character/player-character.ts";
36
40
  import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
37
41
  import { CharacterController } from "./controllers/character/character-controller.ts";
38
42
  import { CharacterAnimations } from "./controllers/character/character-animations.ts";
39
43
  import { characterPresets } from "./controllers/character/presets.ts";
40
44
  import { waitForPlayer } from "@genex-ai/embed-sdk";
41
45
 
42
- // The playing user's OWN avatar (their profile pick; per-session for guests) —
43
- // the baked file is only the local-dev / failure fallback.
46
+ // This game's own generated character when it has one; otherwise the playing
47
+ // user's own avatar (their profile pick; per-session for guests), with the
48
+ // baked file as the local-dev / failure fallback.
44
49
  const { user } = await waitForPlayer();
45
- const { scene, vrm } = await loadVrm(user.avatarUrl ?? "./assets/avatar.vrm")
46
- .catch(() => loadVrm("./assets/avatar.vrm"));
47
- const clips = await loadCharacterClips(vrm); // core + every installed pack, retargeted
50
+ const player = await loadPlayerCharacter({ avatarUrl: user.avatarUrl });
48
51
 
49
- // capsuleFromModel derives the collider from the avatar's bounds — no manual
50
- // per-avatar tuning even as heights/proportions vary across the library.
51
- const fit = capsuleFromModel(scene);
52
+ // capsuleFromModel derives the collider from the body's bounds — no manual
53
+ // per-model tuning even as heights and proportions vary between lanes.
54
+ const fit = capsuleFromModel(player.scene);
52
55
  const character = new CharacterController(physics.world, camera, {
53
56
  ...characterPresets["default"].options,
54
57
  ...fit,
55
58
  position: { x: 0, y: 2, z: 0 },
56
59
  });
57
- character.root.add(scene);
58
- scene.position.y = fit.modelOffsetY; // root = capsule CENTER; drop the model so feet touch the floor
60
+ character.root.add(player.scene);
61
+ player.scene.position.y = fit.modelOffsetY; // root = capsule CENTER; drop the model so feet touch the floor
59
62
 
60
- const anims = new CharacterAnimations(scene, clips);
63
+ const anims = new CharacterAnimations(player.scene, player.clips, {
64
+ locomotionProfile: player.locomotionProfile, // undefined in the fallback lane — fine
65
+ });
61
66
  ```
62
67
 
63
68
  (Advanced: to load a single GLB by hand, `retargetClips(vrm, gltf.scene,
@@ -68,12 +73,15 @@ Per render frame, **after** `physics.step(delta)`:
68
73
 
69
74
  ```ts
70
75
  anims.update(character, delta); // the controller itself satisfies the snapshot type
71
- vrm.update(delta); // REQUIRED — ticks the humanoid rig + spring bones
76
+ player.update(delta); // REQUIRED — ticks the humanoid rig + spring bones
72
77
  ```
73
78
 
74
- `vrm.update(delta)` MUST run every frame, AFTER `anims.update`: it applies the
75
- animated normalized pose onto the render mesh and advances spring bones (hair,
76
- cloth). `anims.update` takes the RAW render delta pause/slow-motion go through
79
+ `player.update(delta)` MUST run every frame, AFTER `anims.update`. In the
80
+ fallback (VRM) lane it applies the animated normalized pose onto the render
81
+ mesh and advances spring bones (hair, cloth); native generated rigs need no
82
+ such pass, so it costs nothing there. Call it unconditionally — that is what
83
+ lets the body change lanes without this loop changing.
84
+ `anims.update` takes the RAW render delta — pause/slow-motion go through
77
85
  `anims.setPaused(true)` / `anims.setTimeScale(0.5)` (fade durations stretch with
78
86
  the time scale so slow motion doesn't pop).
79
87
 
@@ -107,26 +115,25 @@ estimated cost. Use a returned ID; do not guess from a name. Search is free and
107
115
  local. Character and animation generation print a Genex-credit quote before
108
116
  enqueueing, and successful outputs are copied to permanent R2 URLs.
109
117
 
110
- The install command writes `public/assets/meshy-character.json`. Wire that
111
- manifest through the **same** physics controller and camera:
118
+ The install command writes `public/assets/meshy-character.json`. **There is
119
+ nothing to wire** — the boot block above already routes on that file, so the
120
+ next reload plays the generated character with its exact-rig clips and
121
+ locomotion profile, through the same physics controller and camera.
122
+
123
+ `loadMeshyCharacter` is the direct handle underneath, for the rare case that
124
+ needs the manifest itself (reading `rigSignature`, a second character in the
125
+ scene):
112
126
 
113
127
  ```ts
114
128
  import { loadMeshyCharacter } from "./controllers/character/meshy/meshy-loader.ts";
115
129
 
116
130
  const native = await loadMeshyCharacter("./assets/meshy-character.json");
117
- const fit = capsuleFromModel(native.scene);
118
- const character = new CharacterController(physics.world, camera, {
119
- ...characterPresets["default"].options,
120
- ...fit,
121
- position: { x: 0, y: 2, z: 0 },
122
- });
123
- character.root.add(native.scene);
124
- native.scene.position.y = fit.modelOffsetY;
125
- const anims = new CharacterAnimations(native.scene, native.clips, {
126
- locomotionProfile: native.locomotionProfile,
127
- });
131
+ // native.scene / native.clips / native.locomotionProfile / native.manifest
128
132
  ```
129
133
 
134
+ Never make it the player's boot path: that is the two-lane split
135
+ `loadPlayerCharacter` exists to remove.
136
+
130
137
  During Meshy validation, record the action ID actually bound to every slot.
131
138
  A public preview is not evidence when the game is playing a different clip or
132
139
  a rig-basic fallback. Meshy limb rotations play unchanged. Never freeze hand
@@ -210,15 +217,19 @@ const footIK = new FootIK(vrm, (foot) => {
210
217
  });
211
218
  ```
212
219
 
213
- ORDER IS LOAD-BEARING foot IK poses the VRM's *normalized* bones, which
214
- `vrm.update()` then copies onto the render mesh. Run it BETWEEN the two (after
215
- `vrm.update` it has no visible effect at all — the next frame's mixer tick
220
+ Foot IK is a VRM-lane feature (it poses a VRM's normalized bones), so it
221
+ applies only when the player is on the fallback body check
222
+ `player.kind === "avatar"` before creating it.
223
+
224
+ ORDER IS LOAD-BEARING — foot IK poses the VRM's *normalized* bones, which the
225
+ VRM's own update then copies onto the render mesh. Run it BETWEEN the two
226
+ (after that copy it has no visible effect at all — the next frame's mixer tick
216
227
  overwrites it before the copy):
217
228
 
218
229
  ```ts
219
230
  anims.update(character, delta); // 1. mixer poses the normalized rig
220
- footIK.update(delta); // 2. plant the feet on that pose
221
- vrm.update(delta); // 3. copy normalized -> render mesh + spring bones
231
+ footIK?.update(delta); // 2. plant the feet on that pose (fallback lane only)
232
+ player.update(delta); // 3. copy normalized -> render mesh + spring bones
222
233
  ```
223
234
 
224
235
  ## States and default clips
@@ -409,12 +420,13 @@ remoteAnims.update(remoteState.flags, delta);
409
420
  resolves correctly (a missing flag reads as not-crouched).
410
421
 
411
422
  The mixer crossfades exactly as it does locally, so remote players animate
412
- correctly without simulating anything. Use the same visual lane as the owner:
413
- for a VRM game load **that remote player's own avatar** —
414
- `loadVrm(p.avatarUrl || "./assets/avatar.vrm")` (the multiplayer SDK's verified
415
- per-player field; empty means fall back) + retargeted UAL clips, call
416
- `vrm.update(delta)` per remote, and `VRMUtils.deepDispose` the model on leave
417
- or `loadMeshyCharacter` for a Meshy game. Relay one-shot events (punch,
423
+ correctly without simulating anything. Build every remote's body with
424
+ `loadRemotePlayerCharacter({ avatarUrl: p.avatarUrl })` the same routing the
425
+ local player gets, so in a game with a generated character everyone wears it,
426
+ and only a game without one falls back to per-player avatars (`p.avatarUrl` is
427
+ the multiplayer SDK's verified per-player field; empty means fall back). It
428
+ clones a shared parsed base, so N remotes cost about one body of GPU memory;
429
+ call `remote.dispose()` on leave. Relay one-shot events (punch,
418
430
  hit, validated planar-action start) alongside the flags and call the matching
419
431
  `remoteAnims.playOneShot(...)` on receipt. Never run `MotionActionDriver` for a
420
432
  remote: its smoothed owner-authored transform is the sole movement authority.
@@ -72,9 +72,11 @@ import { waitForPlayer, waitForAuth, getColyseusAuth, getEmbedToken } from "@gen
72
72
  const { user, guest } = await waitForPlayer();
73
73
  // user.id / user.name — real account identity, or guest:<id> / "Guest-1234"
74
74
  // user.avatarUrl — the player's OWN VRM avatar (profile pick; per-session for
75
- // guests). Load it for the local player's visual in a VRM-lane game, with the
76
- // baked ./assets/avatar.vrm as the fallback (absent on old APIs / local test
77
- // mode). Peers see each other's via the multiplayer SDK's player.avatarUrl.
75
+ // guests). It is the FALLBACK body, not the game's character: pass it to
76
+ // loadPlayerCharacter({ avatarUrl: user.avatarUrl }) and it is used only when
77
+ // this game has no generated character of its own, with the baked
78
+ // ./assets/avatar.vrm behind it (absent on old APIs / local test mode).
79
+ // Peers resolve the same way via the multiplayer SDK's player.avatarUrl.
78
80
 
79
81
  // ACCOUNT gate — resolves ONLY for signed-in players (stays pending for
80
82
  // guests; resolves later if they sign in mid-game). Use ONLY for /state
@@ -537,14 +537,16 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
537
537
  `{ id, name, avatarUrl, connected, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you);
538
538
  `stateRaw` is the raw latest (hit-tests, discrete values). A reconnect-grace seat remains in this
539
539
  map with `connected: false`. `avatarUrl` is that player's verified VRM pick (server-set, `''`
540
- when unknown) — in a VRM-lane game render each remote with
541
- `loadVrmClone(p.avatarUrl || "./assets/avatar.vrm")` (the vendored loader's shared-GPU clone:
542
- N remotes share one set of geometry/textures instead of re-parsing per player — retarget
543
- clips once on `loadVrmClone.base(url)` and play them on each clone's own mixer); never
544
- publish avatar URLs through `me.set`. **A remote player's body is NEVER hand-built
545
- primitives** (no capsule + cone "person" the single most common shipped co-op defect):
546
- a themed game renders remotes with the SAME themed character model the local player
547
- uses; everything else renders `p.avatarUrl` VRMs. This applies with full force to
540
+ when unknown) — render each remote with
541
+ `loadRemotePlayerCharacter({ avatarUrl: p.avatarUrl })` (the vendored controller kit's
542
+ loader) and `remote.dispose()` on leave; never publish avatar URLs through `me.set`.
543
+ That one call carries the whole rule: **in a game with its own generated character,
544
+ every remote wears it** `p.avatarUrl` is used only when this game has no generated
545
+ character, which is exactly when per-player looks are the right answer. It shares one
546
+ parsed base across remotes (N remotes 1 body of geometry/textures instead of a parse
547
+ per player) and clips are retargeted once on that base. **A remote player's body is
548
+ NEVER hand-built primitives** (no capsule + cone "person" — the single most common
549
+ shipped co-op defect). This applies with full force to
548
550
  first-person games — the local player may be invisible to themselves, but every
549
551
  remote hunter/partner is a full character on screen. On phone tiers, animate and fully draw only the
550
552
  nearest `tier.remoteAvatarCap` remotes (`$genex-threejs-adaptive-quality`) — freeze the
@@ -273,13 +273,14 @@ room.me.set({
273
273
  r: character.runActive, j: character.jumpActive, c: character.crouchActive,
274
274
  });
275
275
 
276
- // Remote players: a VISUAL-ONLY avatar — NO Rapier body, NO controller instance for remotes.
277
- // Build each remote's visual from THEIR OWN model: in a VRM-lane game that is
278
- // loadVrmClone(pl.avatarUrl || "./assets/avatar.vrm") the verified per-player pick the
279
- // relay replicates ('' = unknown → fall back; deepDispose the clone on 'leave'). The clone
280
- // shares parsed GPU geometry/textures across remotes with the same file (most rooms: all of
281
- // them) retarget clips once against loadVrmClone.base(url).vrm and drive each clone's own
282
- // AnimationMixer. Never reuse your own avatar OBJECT for a remote (clones, not references).
276
+ // Remote players: a VISUAL-ONLY body — NO Rapier body, NO controller instance for remotes.
277
+ // Build each remote with loadRemotePlayerCharacter({ avatarUrl: pl.avatarUrl }) in a game
278
+ // with its own generated character everyone wears it, and pl.avatarUrl (the verified
279
+ // per-player pick the relay replicates; '' = unknown → fall back) is used only when this
280
+ // game has no generated character. It clones a shared parsed base, so remotes share GPU
281
+ // geometry/textures instead of re-parsing per player, and retargets clips once on that
282
+ // base; drive each clone's own AnimationMixer and call remote.dispose() on 'leave'.
283
+ // Never reuse your own body OBJECT for a remote (clones, not references).
283
284
  // Phone tiers animate only the nearest tier.remoteAvatarCap remotes (adaptive-quality skill). Position/rotation from smoothed state; animation from the
284
285
  // synced flags via the avatar's own update(flags, dt). The character-controller skill's
285
286
  // animations reference owns the flag set.