@genex-ai/cli-demo 0.52.0-dev.116 → 0.53.0-dev.118

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/dist/index.js CHANGED
@@ -1949,13 +1949,99 @@ async function detectUiPhases(cwd = process.cwd()) {
1949
1949
  return true;
1950
1950
  }
1951
1951
  }
1952
+ function ctorArgs(content, ctor) {
1953
+ const out = [];
1954
+ const re = new RegExp(`new\\s+(?:THREE\\.)?${ctor}\\s*\\(`, "g");
1955
+ let m;
1956
+ while (m = re.exec(content)) {
1957
+ let i = re.lastIndex;
1958
+ let depth = 1;
1959
+ while (i < content.length && depth > 0) {
1960
+ const ch = content[i];
1961
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
1962
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
1963
+ i++;
1964
+ }
1965
+ out.push({ text: content.slice(re.lastIndex, i - 1), index: m.index });
1966
+ }
1967
+ return out;
1968
+ }
1969
+ function splitArgs(text) {
1970
+ const out = [];
1971
+ let depth = 0;
1972
+ let start = 0;
1973
+ for (let i = 0; i < text.length; i++) {
1974
+ const ch = text[i];
1975
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
1976
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
1977
+ else if (ch === "," && depth === 0) {
1978
+ out.push(text.slice(start, i).trim());
1979
+ start = i + 1;
1980
+ }
1981
+ }
1982
+ out.push(text.slice(start).trim());
1983
+ return out;
1984
+ }
1985
+ var lineOf = (content, index) => content.slice(0, index).split("\n").length;
1986
+ var DEPTH_RATIO_LIMIT = 1e6;
1987
+ async function detectSurfaceScan(cwd = process.cwd()) {
1988
+ const found = { guessedRepeat: [], squarePoints: [], depthRange: [] };
1989
+ const srcDir = path11.join(cwd, "src");
1990
+ let entries;
1991
+ try {
1992
+ entries = await fs10.readdir(srcDir, { recursive: true });
1993
+ } catch {
1994
+ return found;
1995
+ }
1996
+ for (const rel of entries) {
1997
+ if (rel.includes("node_modules")) continue;
1998
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
1999
+ const raw = await fs10.readFile(path11.join(srcDir, rel), "utf8").catch(() => "");
2000
+ if (!raw) continue;
2001
+ const content = raw.replace(/\/\*[\s\S]*?\*\//g, (m2) => m2.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/gm, (m2, p1) => p1 + " ".repeat(m2.length - p1.length));
2002
+ const repeatRe = /\.repeat\.set\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
2003
+ let m;
2004
+ while (m = repeatRe.exec(content)) {
2005
+ const u = Number(m[1]);
2006
+ const v = Number(m[2]);
2007
+ if (u === v) continue;
2008
+ found.guessedRepeat.push({
2009
+ where: `${rel}:${lineOf(content, m.index)}`,
2010
+ detail: `repeat.set(${m[1]}, ${m[2]})`
2011
+ });
2012
+ }
2013
+ for (const call of ctorArgs(content, "PointsMaterial")) {
2014
+ if (/\b(map|alphaMap)\s*:/.test(call.text)) continue;
2015
+ found.squarePoints.push({
2016
+ where: `${rel}:${lineOf(content, call.index)}`,
2017
+ detail: "PointsMaterial with no map/alphaMap"
2018
+ });
2019
+ }
2020
+ for (const call of ctorArgs(content, "PerspectiveCamera")) {
2021
+ const args = splitArgs(call.text);
2022
+ if (args.length < 4) continue;
2023
+ const near = Number(args[2]);
2024
+ const far = Number(args[3]);
2025
+ if (!isFinite(near) || !isFinite(far) || near <= 0 || far <= near) continue;
2026
+ const ratio = far / near;
2027
+ if (ratio <= DEPTH_RATIO_LIMIT) continue;
2028
+ found.depthRange.push({
2029
+ where: `${rel}:${lineOf(content, call.index)}`,
2030
+ detail: `near ${near} / far ${far}`,
2031
+ ratio
2032
+ });
2033
+ }
2034
+ }
2035
+ return found;
2036
+ }
1952
2037
  async function detectFeatures(log, cwd = process.cwd()) {
1953
2038
  return {
1954
2039
  embedSdkVersion: await detectEmbedSdkVersion(cwd),
1955
2040
  multiplayer: await detectMultiplayer(cwd),
1956
2041
  matchmaking: await detectMatchmaking(log, cwd),
1957
2042
  gameStateUsed: await detectGameStateUsage(cwd),
1958
- uiPhases: await detectUiPhases(cwd)
2043
+ uiPhases: await detectUiPhases(cwd),
2044
+ surfaces: await detectSurfaceScan(cwd)
1959
2045
  };
1960
2046
  }
1961
2047
  function advisoryNudges(log, d) {
@@ -1979,6 +2065,26 @@ function advisoryNudges(log, d) {
1979
2065
  "No menu/loader screens detected (no data-phase/setPhase) \u2014 players get a bare HUD with no title screen and no styled loading. Run the genex-threejs-game-ui plan (loader, menu, screen tiers); a cinematic menu is two commands away (genex-ai-menu)."
1980
2066
  );
1981
2067
  }
2068
+ surfaceNudges(log, d.surfaces);
2069
+ }
2070
+ function surfaceNudges(log, s) {
2071
+ if (s.guessedRepeat.length) {
2072
+ const list = s.guessedRepeat.map((d) => `${d.where} \u2014 ${d.detail}`).join("; ");
2073
+ log.warn(
2074
+ `Texture tiling picked by eye, not derived from the mesh: ${list}. A hand-chosen repeat is right for at most ONE face \u2014 a BoxGeometry gives all six faces 0..1 UVs whatever their world size, and repeat lives on the Texture, so every mesh sharing that texture inherits it. Derive the UVs from world size instead: genex-ai-texture ships worldUV(geometry, TILE_M), after which repeat stays (1, 1) and cannot be wrong.`
2075
+ );
2076
+ }
2077
+ if (s.squarePoints.length) {
2078
+ const list = s.squarePoints.map((d) => d.where).join(", ");
2079
+ log.warn(
2080
+ `Particles will render as hard SQUARES: ${list} builds a PointsMaterial with no map/alphaMap, so every point is an opaque camera-facing quad. Give it a soft sprite (genex image --transparent) or a round gl_PointCoord cutout \u2014 and check the particle belongs there at all: genex-threejs-procedural-vfx decides that per moment.`
2081
+ );
2082
+ }
2083
+ for (const d of s.depthRange) {
2084
+ log.warn(
2085
+ `Camera depth range ${d.detail} (far/near = ${d.ratio.toExponential(1)}) at ${d.where} is past what a 24-bit depth buffer can serve \u2014 touching surfaces will z-fight and flicker, and it reads fine right up until it doesn't. near is the expensive knob (resolvable depth \u2248 z\xB2 / (near \xD7 2^24)), so raise near to the closest the camera can actually get. If the range is genuinely astronomical, that is what WebGLRenderer's logarithmicDepthBuffer is for: genex-threejs-camera-direction.`
2086
+ );
2087
+ }
1982
2088
  }
1983
2089
  async function firstPreviewNudge(log, cwd = process.cwd()) {
1984
2090
  const meta = await readProject(cwd);
@@ -2503,8 +2609,9 @@ async function reportTextureSeam(url, log) {
2503
2609
  `Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
2504
2610
  );
2505
2611
  log.plain(" It will read as a repeating grid on any large surface. Before wiring it in:");
2506
- log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground), or');
2507
- log.plain(" \u2022 lower the mesh's UV repeat so the seam falls off-camera.");
2612
+ log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground).');
2613
+ log.plain(" Do NOT hide it by tiling less: a hand-picked repeat trades a seam you can name for a");
2614
+ log.plain(" stretched texture you can't. Scale belongs in the UVs (worldUV \u2014 genex-ai-texture).");
2508
2615
  log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
2509
2616
  } catch {
2510
2617
  }
@@ -4084,7 +4191,7 @@ async function uiSeams(opts, log) {
4084
4191
  return;
4085
4192
  }
4086
4193
  fail(
4087
- `Visible ${r.worstAxis} tiling seam in ${name}: the tile boundary jumps ${r.worstRatio}\xD7 the texture's own detail (want \u2264 ${tol}\xD7). Regenerate it seamless/tileable, or lower the mesh's UV repeat so the seam falls off-camera.`
4194
+ `Visible ${r.worstAxis} tiling seam in ${name}: the tile boundary jumps ${r.worstRatio}\xD7 the texture's own detail (want \u2264 ${tol}\xD7). Regenerate it seamless/tileable. Don't hide it behind a hand-picked UV repeat \u2014 that trades a seam for a stretched texture (scale belongs in the UVs: worldUV, genex-ai-texture).`
4088
4195
  );
4089
4196
  }
4090
4197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.52.0-dev.116",
3
+ "version": "0.53.0-dev.118",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,10 @@ published game, and remixes alike).
40
40
 
41
41
  ## Apply it (tiling material)
42
42
 
43
+ Load the texture, then **measure the UVs off the geometry** — never set `repeat`
44
+ by eye. `worldUV` (next section) is the whole reason this is two lines and not a
45
+ judgement call.
46
+
43
47
  ```ts
44
48
  import * as THREE from "three";
45
49
 
@@ -48,25 +52,96 @@ const TEXTURE_URL = "https://assets.genex.technology/generations/<id>/texture-ba
48
52
  const map = await new THREE.TextureLoader().loadAsync(TEXTURE_URL);
49
53
  map.colorSpace = THREE.SRGBColorSpace;
50
54
  map.wrapS = map.wrapT = THREE.RepeatWrapping;
51
- map.repeat.set(8, 8); // tile count — raise for large surfaces
52
55
  map.anisotropy = renderer.capabilities.getMaxAnisotropy();
56
+ // NOTE: no map.repeat here, on purpose — worldUV puts the scale in the UVs.
53
57
 
54
- const material = new THREE.MeshStandardMaterial({ map, roughness: 0.9, metalness: 0 });
55
- groundMesh.material = material; // e.g. a PlaneGeometry ground
58
+ const TILE_M = 4; // one tile covers 4×4 metres choose ONCE per material
59
+ const geo = worldUV(new THREE.PlaneGeometry(200, 200), TILE_M);
60
+ const ground = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ map, roughness: 0.9 }));
61
+ ground.rotation.x = -Math.PI / 2;
62
+ scene.add(ground);
63
+ ```
64
+
65
+ Same two lines for a wall, a kerb, a platform, a crate — any shape, any size:
66
+
67
+ ```ts
68
+ const wallGeo = worldUV(new THREE.BoxGeometry(19, 2.4, 1), TILE_M);
56
69
  ```
57
70
 
58
71
  ### Terrain
59
72
 
60
- Generate with `--terrain`, lay a large `PlaneGeometry` (rotated flat), and raise
61
- `repeat` so the texture tiles densely across it:
73
+ Generate with `--terrain` and lay a large `PlaneGeometry` (rotated flat). Scale is
74
+ the same call — `worldUV(geo, TILE_M)`. Pick `TILE_M` by what the material IS: a
75
+ 4 m gravel tile and a 0.6 m tile of floorboards are both right, for different
76
+ materials. What is never right is a different density on two faces of one object.
77
+
78
+ ## Why you cannot pick `repeat` by eye
79
+
80
+ Two facts make a hand-chosen `repeat` wrong on everything but the one face you
81
+ were looking at:
82
+
83
+ 1. **Every `BoxGeometry` face spans UV 0..1 regardless of its world size.** A
84
+ `Box(1, 2.4, 17)` gives its 1×17 m top face the same 0..1 UV square as its
85
+ 17×2.4 m side. One `repeat` therefore cannot serve both.
86
+ 2. **`repeat` lives on the `Texture`, not the `Material`.** Two meshes sharing one
87
+ loaded texture share its `repeat`, `offset` and `wrapS/T`. Setting it for the
88
+ wall silently re-tiles the crates. (If you truly need two densities from one
89
+ image, `map.clone()` — but `worldUV` means you almost never do.)
90
+
91
+ A real game shipped `wallTex.repeat.set(6, 1)` on a four-box wall ring. The number
92
+ was tuned against the long side face and landed there at a near-perfect 1.2:1 —
93
+ and the same number put **1:102** on the wall tops, which read to the player as
94
+ wooden planks rather than the riveted steel the texture actually shows. The
95
+ texture was flawless. The wiring was not.
96
+
97
+ ## `worldUV` — measure the UVs, don't guess them
98
+
99
+ Copy this in. It rewrites a geometry's UVs so one tile covers `tileM × tileM`
100
+ metres on **every** face, whatever the shape. After it, leave `repeat` at (1, 1)
101
+ and it cannot be wrong — there is no number left to pick.
62
102
 
63
103
  ```ts
64
- const ground = new THREE.Mesh(new THREE.PlaneGeometry(200, 200), material);
65
- ground.rotation.x = -Math.PI / 2;
66
- map.repeat.set(64, 64);
67
- scene.add(ground);
104
+ /**
105
+ * Box-project a geometry's UVs in world metres. Call once at build time.
106
+ * Units are the geometry's own, so keep mesh.scale at 1 (or bake it in with
107
+ * geometry.scale(...)) — worldUV cannot see a scale applied to the mesh.
108
+ * Returns a non-indexed geometry: a shared vertex on a box corner belongs to
109
+ * faces pointing different ways and cannot carry one UV for both.
110
+ */
111
+ function worldUV(geometry: THREE.BufferGeometry, tileM: number): THREE.BufferGeometry {
112
+ const g = geometry.index ? geometry.toNonIndexed() : geometry;
113
+ const pos = g.attributes.position;
114
+ const uv = new Float32Array(pos.count * 2);
115
+ const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
116
+ const ab = new THREE.Vector3(), ac = new THREE.Vector3(), n = new THREE.Vector3();
117
+ for (let t = 0; t < pos.count / 3; t++) {
118
+ const i0 = t * 3, i1 = i0 + 1, i2 = i0 + 2;
119
+ a.fromBufferAttribute(pos, i0);
120
+ b.fromBufferAttribute(pos, i1);
121
+ c.fromBufferAttribute(pos, i2);
122
+ n.crossVectors(ab.subVectors(b, a), ac.subVectors(c, a));
123
+ const nx = Math.abs(n.x), ny = Math.abs(n.y), nz = Math.abs(n.z);
124
+ // Drop the face's dominant axis; the other two are the tile plane. One
125
+ // branch exactly — picking each axis independently collapses a 45° face to
126
+ // a zero-area UV.
127
+ const axis = nx >= ny && nx >= nz ? 0 : ny >= nz ? 1 : 2;
128
+ for (const i of [i0, i1, i2]) {
129
+ const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
130
+ const u = axis === 0 ? z : x;
131
+ const v = axis === 1 ? z : y;
132
+ uv[i * 2] = u / tileM;
133
+ uv[i * 2 + 1] = v / tileM;
134
+ }
135
+ }
136
+ g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
137
+ return g;
138
+ }
68
139
  ```
69
140
 
141
+ This is box projection: correct for the primitives games build out of (boxes,
142
+ planes, kerbs, platforms, extrusions). A generated GLB arrives with its own
143
+ authored UVs — leave those alone; `worldUV` is for geometry **you** construct.
144
+
70
145
  ## Tiling is checked FOR you — read the verdict
71
146
 
72
147
  `npx genex texture` measures the wrap seam of every texture it generates and
@@ -77,28 +152,17 @@ prints the verdict right under the URL. A bad one looks like this:
77
152
  ```
78
153
 
79
154
  `--terrain` ASKS the model for seamless; it does **not** guarantee it — a shipped
80
- game seamed at 6.6× with `--terrain` set. So when that line appears, fix it
81
- BEFORE wiring the texture in: **regenerate** with "seamless tiling, no visible
82
- edges" in the prompt, or **lower the `repeat`** so the seam falls off-camera. A
83
- good texture prints `tiles cleanly` instead. To re-check any image (including one
84
- you didn't just generate): `npx genex ui seams --in <png|url>`.
85
-
86
- ## Scale by texel density — never hand-pick `repeat`
87
-
88
- The other half of "the floor looks wrong" is a `repeat` guessed out of the air.
89
- Keep texels SQUARE: derive BOTH axes from the surface's real world size and ONE
90
- chosen tile size.
91
-
92
- ```ts
93
- const TILE_M = 4; // one tile covers 4×4 metres — choose once
94
- map.repeat.set(width / TILE_M, depth / TILE_M); // a 40×400 m lane → (10, 100)
95
- ```
96
-
97
- A non-uniform guess like `repeat.set(2, 40)` on a long lane stretches the texture
98
- 20:1 along it: the pattern smears one way and crowds the other. That reads as
99
- "wrong scale / glued together" even when the texture itself is flawless — and it
100
- is exactly what shipped in a real game. Same rule for walls and platforms: derive
101
- from that surface's own width/height, not from the floor's numbers.
155
+ game seamed at 6.6× with `--terrain` set. When that line appears, **regenerate**
156
+ with "seamless tiling, no visible edges" in the prompt before wiring it in. Do
157
+ not try to hide a seam by tiling less: that trades a visible defect you can name
158
+ for a stretched one you can't, and a texture that only survives at `repeat` 1 on
159
+ one axis is a texture that failed. To re-check any image (including one you
160
+ didn't just generate): `npx genex ui seams --in <png|url>`.
161
+
162
+ Note the two checks see different things and you need both to pass: the seam
163
+ check judges the **image**, `worldUV` fixes the **wiring**. A flawless image
164
+ wired at 1:102 and a seamed image tiled perfectly are both defects, and the
165
+ first one is the one a screenshot of the whole arena will not show you.
102
166
 
103
167
  ## Publish checklist
104
168
 
@@ -323,6 +323,17 @@ near 0.2
323
323
  far 3.0e7
324
324
  ```
325
325
 
326
+ **Do not copy those numbers into an ordinary game.** That range only works
327
+ because a planet renderer pairs it with `WebGLRenderer({ logarithmicDepthBuffer:
328
+ true })`; on a stock 24-bit depth buffer it throws the precision away and any two
329
+ surfaces that touch will fight and flicker. The expensive knob is `near`, not
330
+ `far`: resolvable depth at distance z is roughly `z² / (near × 2^24)`, so every
331
+ halving of `near` halves precision at every distance. Set `near` to the closest
332
+ the camera can genuinely get — for a third-person or top-down game that is
333
+ metres, not centimetres — and `far` to the far edge of the world, and the ratio
334
+ takes care of itself. `genex preview` warns when a camera's range is past what a
335
+ plain depth buffer can serve.
336
+
326
337
  It prewarms pipelines by temporarily aiming at representative bodies, then
327
338
  restores both position and quaternion in `finally`.
328
339
 
@@ -77,7 +77,11 @@ hits, layer two or three of:
77
77
  With the bundled physics pack this is built in — `physics.paused = true` /
78
78
  `physics.timeScale = 0.2` (see the physics skill's pause/slow-mo section) plus
79
79
  `anims.setPaused(true)` / `anims.setTimeScale(0.2)` for the character's
80
- animations. Don't hand-roll a second clock.
80
+ animations. Don't hand-roll a second clock. **In multiplayer, hitstop is local
81
+ presentation feedback:** never pause the network pump, reconnect/quorum timers,
82
+ remote interpolation, or another player's/host's simulation. Slow/freeze only
83
+ your locally owned gameplay clock and visuals; the authoritative hit is still
84
+ deduped and applied once through the multiplayer skill's normal event path.
81
85
 
82
86
  One layer per small event, three for the biggest — uniform intensity flattens
83
87
  everything back out.
@@ -99,4 +103,6 @@ everything back out.
99
103
  - Restart requires reloading the page (kills the retry loop — and reloads
100
104
  re-run auth and asset loading).
101
105
  - Shake/flash spam with no cause — feedback inflation reads as noise.
106
+ - Multiplayer hitstop pauses networking or host simulation, turning impact feedback
107
+ into packet bursts, quorum lag, or a freeze for players who were not hit.
102
108
  - Feel constants scattered through the code where nobody dares touch them.
@@ -29,20 +29,23 @@ whole reason this skill exists.
29
29
  Read [references/realtime-patterns.md](references/realtime-patterns.md) for the complete movement
30
30
  example, the shared-object/ball code, rotation, and host usage. Read
31
31
  [references/genre-recipes.md](references/genre-recipes.md) for ready-made per-genre setups
32
- (sports/ball, shooter, co-op with host-simulated enemies) — pick the one matching the game.
32
+ (sports/ball, shooter, co-op with host-simulated enemies) — pick the one matching the game. Before
33
+ calling multiplayer done, run the mandatory
34
+ [netcode feel gate](references/genex-netcode-feel-checklist.md).
33
35
 
34
36
  ## Install
35
37
 
36
38
  ```bash
37
- npm i @genex-ai/multiplayer@^0.10.1
39
+ npm i @genex-ai/multiplayer@^0.10.2
38
40
  ```
39
41
 
40
- > Pin `@^0.10.1` (not a bare `npm i`): live connected-player presence, supplier-form `connect()`
42
+ > Pin `@^0.10.2` (not a bare `npm i`): unowned object writes now warn instead of failing silently;
43
+ > live connected-player presence, supplier-form `connect()`
41
44
  > auth, regional relay selection (`getColyseusUrls()` + `urls`)
42
45
  > landed in 0.10; confirmed object controls, snaps, host-tick teardown, and reconnect rebasing
43
46
  > in 0.9. An older resolve does not have those.
44
47
 
45
- This skill targets `@genex-ai/multiplayer` **≥ 0.10.1** (`objects`/`host` since 0.4;
48
+ This skill targets `@genex-ai/multiplayer` **≥ 0.10.2** (`objects`/`host` since 0.4;
46
49
  `matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
47
50
  since 0.8; soft ownership handoff since 0.8.4; confirmed controls, snap epochs, and host-tick
48
51
  lifecycle guarantees since 0.9; regional relay selection via `getColyseusUrls()` since 0.10).
@@ -56,18 +59,30 @@ ownership, match seating/adjudication, and rate/size caps — but a modified cli
56
59
  lie about its own position or score. Great for friends and casual lobbies; don't promise
57
60
  ranked-grade fairness.
58
61
 
59
- ## Choose one net model first
62
+ ## Choose one net model from the player experience
60
63
 
61
- - **`connect()` shared world:** everyone for this slug shares a room. Use for
62
- persistent/co-op spaces where the game may remain valid with one player.
63
- Pass auth as a FUNCTION so each explicit connect attempt reads fresh; after
64
- a terminal disconnect the game starts a new connect flow.
65
- - **`matchmake()` — capped matches:** the queue seats players into separate
66
- rooms. Use for duels, races, teams, and finite arenas. It requires
67
- `genex.matchmaking` in `package.json`; auth is a FUNCTION; the handle may
68
- replace `mm.session`, so the game polls and rebinds it.
64
+ Infer this yourself when the experience is clear. Do **not** make the player choose an SDK API,
65
+ preset, or config. Ask one plain-language question only when the design genuinely supports both
66
+ models and the answer changes the experience for example: *"Should this be one ongoing arena
67
+ people drop into, or a fresh fair match that waits for everyone and then starts together?"*
69
68
 
70
- Do not combine the requirements or silently convert one model into the other.
69
+ | Player experience | Model | Why |
70
+ | --- | --- | --- |
71
+ | One ongoing drop-in world; late joiners enter what is already happening; solo play remains valid | `connect()` | One shared room, no queue or round formation |
72
+ | Fresh bounded match/mission; quorum, fair start, capacity, teams, or parallel sessions matter | `matchmake()` | Server forms capped rooms and exposes queue/waiting state |
73
+
74
+ Genre names do not decide this. A sumo game may be an always-online drop-in ring (`connect()`) or a
75
+ fair-start bout (`matchmake()`). A co-op game may be an ongoing shared space (`connect()`) or a
76
+ bounded dungeon run (`matchmake()`). Write one plan line before coding:
77
+
78
+ > Net model: `<connect|matchmake>` — `<player-experience reason>`; start/quorum: `<rule>`;
79
+ > late join/backfill: `<rule>`; below-quorum/end tail: `<rule>`.
80
+
81
+ Use `connect()` when the game is honestly one always-online world. Use `matchmake()` when it promises
82
+ a match, run, race, mission, teams, a waiting count, or a synchronized start. If the brief says
83
+ "100 players in the same world," or mixes a shared hub with instanced matches, do not guess: surface
84
+ the platform/capacity mismatch or ask which experience matters. Do not combine the requirements or
85
+ silently convert one model into the other.
71
86
 
72
87
  ## Matchmaking (competitive presets — server-owned)
73
88
 
@@ -372,6 +387,25 @@ const lobby = await joinPrivate<MyState>(code, { urls, room: slug, auth: () => g
372
387
  Pick your own per-player state shape (any JSON). `room` is the **project slug**
373
388
  (printed by `genex init`) — same id = same room, different ids are fully isolated.
374
389
 
390
+ ### WHEN to call `connect()` — make the screen tell the truth (MANDATORY)
391
+
392
+ `connect()` immediately joins the shared world and makes the player present. Two lifecycles are
393
+ valid; pick exactly one:
394
+
395
+ - **Always-online world:** there is no Play/Online commitment screen. After identity is ready,
396
+ connect and spawn immediately. This is correct for a drop-in social space or an ongoing sumo ring
397
+ where loading the game already means joining it. A lightweight loading/reconnecting overlay is
398
+ honest; a Play button that appears to delay entry is not.
399
+ - **Menu before online:** if the game shows **Play**, **Play Online**, **Join Arena**, or offers
400
+ Local/Bots, that click is the commitment point. Boot the menu/offline world with no relay contact,
401
+ call `connect()` only inside the online handler, and spawn the network player only after it
402
+ resolves. Local/Bots never connect. Leaving online calls `room.leave()`, disables terminal rejoin,
403
+ removes the online avatar, and returns to the pre-online state.
404
+
405
+ Never auto-connect/spawn behind a title menu and then ask the player to press Play. The API is not
406
+ the bug in that case; the lifecycle is. Likewise, do not add a fake queue/finding screen to an
407
+ always-online `connect()` world — it has no match formation to report.
408
+
375
409
  **Joining requires the SDK's player identity — the relay rejects tokenless
376
410
  joins, but accepts guests** (accountless players named like `Guest-1234`).
377
411
  Load the `genex-threejs-embed-auth` skill first (it sets up `initEmbed(...)`),
@@ -587,6 +621,19 @@ is the newest value with no smoothing. **Draw** from `state`; **test** against `
587
621
  detection, "am I close enough to kick", pickups, and any discrete number (hp, ammo, animation id,
588
622
  a 0/1 flag) that must not arrive fractional. This holds for both players and objects.
589
623
 
624
+ **HARD RULE — `state` is for RENDER ONLY.** Every GAMEPLAY read — hit tests, deflection/catch/
625
+ return windows, physics seeding after adoption, distance and reach checks — uses `stateRaw`.
626
+ The smoothed view is ~100–150 ms in the past; at projectile or ball speeds the REAL object has
627
+ already passed where the ghost still is. Two field-verified failures caused by breaking this rule:
628
+ - A pong-style game read the incoming ball via `state` for its deflection window — at top speed
629
+ the real ball crossed the paddle plane before the smoothed one arrived; returns were literally
630
+ impossible online while feeling fine in solo testing.
631
+ - A dodgeball game aimed at opponents drawn from `state` — a strafing target's real position was
632
+ already elsewhere, so "direct hits" never registered damage.
633
+ Corollary: **cap top speeds against the network, not just the physics** — an object's arena/table
634
+ crossing time should stay above ~2× the smoothing delay (~0.25 s), or receivers are reacting to
635
+ history no matter how correct the code is.
636
+
590
637
  ## Shared objects (the ball, the NPC) — use `objects`, never `shared`
591
638
 
592
639
  A ball belongs to no player. Put it on `objects`: exactly one client owns it at a time (the SDK +
@@ -611,6 +658,67 @@ contact remains valid. Keep object state flat. For Rapier pushables, install the
611
658
  with `genex controller networked-physics`; see
612
659
  [references/host-physics.md](references/host-physics.md).
613
660
 
661
+ **OWNERSHIP INVARIANT — a host-simulated object MUST be claimed before it publishes.**
662
+ `objects.set()` / `objects.snap()` on an object you do not own are ignored; SDK ≥0.10.2 warns once
663
+ per object/operation instead of failing silently.
664
+ The field-verified symptom is unmistakable — *the object moves on the host's screen and sits
665
+ frozen at spawn for everyone else* (each client falls back to whatever local body it has; only
666
+ the host's is simulated). If the host simulates an object (a crate, an NPC, a puck), it must:
667
+
668
+ ```ts
669
+ let hostReady: Promise<boolean> | null = null;
670
+ function ensureHostObjects(room: Session<S>) {
671
+ if (!room.isHost) return Promise.resolve(false);
672
+ if (hostReady) return hostReady; // one adoption flight, never per tick
673
+ hostReady = (async () => {
674
+ for (const id of HOST_OBJECT_IDS) {
675
+ const before = room.objects.get(id)?.stateRaw; // last published truth, before claiming
676
+ const res = await room.objects.claimConfirmed(id, { authority: "host" });
677
+ if (!res.accepted) return false;
678
+ seedPhysicsFromRaw(id, before); // pose + velocity/cooldowns, never zero
679
+ }
680
+ return true;
681
+ })();
682
+ return hostReady;
683
+ }
684
+
685
+ room.on("host", () => { hostReady = null; void ensureHostObjects(room); });
686
+ room.onHostTick(30, async () => {
687
+ if (!(await ensureHostObjects(room))) return; // no step/publish before adoption
688
+ stepAndPublishHostPhysics();
689
+ });
690
+ ```
691
+
692
+ Do not assume `onHostTick` firing means you own anything — host *election* and object
693
+ *ownership* are separate systems. Claim explicitly, check `accepted`, re-claim on migration, and
694
+ do not step or publish until the whole host-owned set is ready.
695
+
696
+ **RENDER SPLIT — the host draws its own authority, only NON-hosts read the wire.** Once the
697
+ host owns the object and publishes, `objects.get(id).state` becomes defined on EVERY client
698
+ including the host — and if the host now renders from that networked `state` instead of its
699
+ own sim body, a subtle trap bites: a freshly-claimed object's smoothed `state` can briefly be
700
+ a DEGENERATE transform (a zero/NaN quaternion, or a position mid-interpolation from origin),
701
+ which renders the mesh to NaN and it vanishes ON EVERY SCREEN. Field-verified: enabling the
702
+ claim above without this split made a crate invisible for everyone. The fix:
703
+
704
+ ```ts
705
+ // Host renders its own authoritative sim; non-hosts render the published truth, GUARDED.
706
+ const cs = room.isHost ? null : room.objects.get(id)?.state;
707
+ const ok = cs && Number.isFinite(cs.x) && Number.isFinite(cs.y) && Number.isFinite(cs.z);
708
+ if (ok) {
709
+ mesh.position.set(cs.x, cs.y, cs.z);
710
+ if (Array.isArray(cs.q) && cs.q.length === 4) { // normalize — a bad sample must not vanish the mesh
711
+ const n = Math.hypot(cs.q[0], cs.q[1], cs.q[2], cs.q[3]);
712
+ if (n > 1e-3) mesh.quaternion.set(cs.q[0]/n, cs.q[1]/n, cs.q[2]/n, cs.q[3]/n);
713
+ }
714
+ } else {
715
+ mesh.position.copy(localBodyPos); // host, offline, or non-host awaiting first valid sample
716
+ }
717
+ ```
718
+ Prefer syncing the MINIMAL transform a slide/roll needs (a puck on a plane is `{x,z}` + a
719
+ constant y — no quaternion at all); every field you don't send is a field that can't arrive
720
+ degenerate. The netcode-park reference does exactly this.
721
+
614
722
  ## Host authority (scores, rounds, enemies)
615
723
 
616
724
  One client is the `host`. Let *only* the host write agreed state and simulate shared enemies, so
@@ -776,11 +884,16 @@ host-driven saving works as long as ANY account is in the room.
776
884
 
777
885
  ## Checklist
778
886
 
779
- - [ ] `npm i @genex-ai/multiplayer@^0.10.1` (connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks); config wired into the build.
887
+ - [ ] `npm i @genex-ai/multiplayer@^0.10.2` (connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks, unowned-write warnings); config wired into the build.
888
+ - [ ] The plan names one net model, the player-experience reason, start/quorum, late-join/backfill,
889
+ and below-quorum/end behavior. The agent inferred it unless the experience was genuinely ambiguous.
780
890
  - [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
781
891
  - [ ] `connect()` terminal `disconnect` starts a guarded backoff rejoin that reruns
782
892
  `waitForPlayer()` and reads fresh auth on every attempt; deliberate leave stops it;
783
893
  replacement code `4409` NEVER auto-rejoins.
894
+ - [ ] `connect()` lifecycle is honest: an always-online world has no fake Play screen and may
895
+ connect/spawn after identity; if a Play/Online/Local/Bots menu exists, connect and spawn happen
896
+ only after the online click, Local/Bots stay offline, and leaving calls `room.leave()`.
784
897
  - [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
785
898
  - [ ] Pushable/ownable objects (ball, box, prop) use claim-on-touch + a Rapier proxy (the soft handoff glides the handoff); only a genuine simultaneous tug-of-war (sumo) uses the host-authoritative pattern. See host-physics.md.
786
899
  - [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
@@ -830,6 +943,8 @@ host-driven saving works as long as ANY account is in the room.
830
943
  per-client, never `mm.matchmaking.teams`, never a default for the unassigned — and you
831
944
  WATCHED two browsers land on OPPOSITE teams.
832
945
  - [ ] Picked the matching recipe from [references/genre-recipes.md](references/genre-recipes.md).
946
+ - [ ] Passed the [netcode feel gate](references/genex-netcode-feel-checklist.md), including the two-
947
+ identity real-input proof for the chosen model.
833
948
 
834
949
  ## Troubleshooting auth
835
950
 
@@ -0,0 +1,61 @@
1
+ # Genex multiplayer netcode feel gate
2
+
3
+ Run this gate before calling any multiplayer loop playable. A build, screenshot, or one local client
4
+ does not prove networking. Keep the check proportional: one focused two-client pass, not a new test
5
+ suite for the game.
6
+
7
+ ## Before coding
8
+
9
+ - Write the net-model line: `connect` or `matchmake`, the player-experience reason, start/quorum,
10
+ late-join/backfill, and below-quorum/end behavior.
11
+ - Infer the model from the experience. Ask the user only if both an ongoing drop-in world and fresh
12
+ bounded sessions are plausible and the brief does not choose between them.
13
+ - State authority per thing: local player, remote player, shared object, score/round, projectile/hit,
14
+ and host-simulated entity.
15
+ - Budget the fastest interaction. Rendered remote state is delayed for smoothness; gameplay tests use
16
+ `stateRaw`, sweep between raw samples, and cap speeds so reaction windows remain humanly possible.
17
+
18
+ ## Lifecycle
19
+
20
+ - `connect` always-online world: no fake Play/finding screen; identity may be followed by immediate
21
+ join/spawn. Leaving is explicit and stops terminal rejoin.
22
+ - `connect` behind Play/Online/Local/Bots: no relay contact or network spawn before the online click;
23
+ Local/Bots stay offline; leaving online calls `room.leave()`.
24
+ - `matchmake`: create the handle only on Play/Find Match; show searching/waiting only after that
25
+ commitment; leaving calls `mm.cancel()`; bind every replacement `mm.session`.
26
+ - Quorum-required games leave `playing` when connected quorum falls below the declared minimum.
27
+ Ongoing shared worlds may continue solo only when that was the stated design.
28
+
29
+ ## Authority and interaction
30
+
31
+ - Self movement and immediate reversible feedback happen locally on the input frame.
32
+ - Remote players and non-owned objects render `state` directly, with no second interpolator.
33
+ - Every gameplay read uses `stateRaw`: hit, return/deflect/catch, reach, pickup, goal, physics adoption,
34
+ hp/ammo/flags, and target selection.
35
+ - Fast bodies/projectiles use a segment or swept-volume test from previous raw position to current raw
36
+ position; point samples are not enough.
37
+ - Irreversible shared results wait for confirmed authority. `objects.set`/`snap` happen only after an
38
+ accepted claim; host simulation waits for single-flight adoption and seeds from last raw truth.
39
+ - Every travel-time PvP projectile has attacker-side raw detection plus victim-side/self detection
40
+ where appropriate, both feeding one projectile-id-deduped hit application.
41
+ - Hitstop never pauses the networking pump or another player's simulation. Freeze/slow the local
42
+ presentation and locally owned gameplay clock only; keep sends, receives, reconnects, and quorum
43
+ checks running.
44
+
45
+ ## One focused proof with two distinct identities
46
+
47
+ Use regular + incognito guest identities, two accounts/devices, or ask the user to perform the exact
48
+ sequence. Two tabs sharing one identity are one enforced seat.
49
+
50
+ 1. Enter online using real clicks. Then press Space/Enter/the main action using real key input; confirm
51
+ focus did not re-trigger Play/Leave and both sessions remain live.
52
+ 2. Confirm reciprocal presence and movement: A sees B move and B sees A move.
53
+ 3. Exercise the signature interaction at full intended speed: kick/return, projectile hit, shared
54
+ object claim, enemy hit, or vehicle seat. Confirm immediate local feedback and exactly one result.
55
+ 4. Exercise one authority transition: object ownership, seat, or host migration. Confirm no freeze,
56
+ teleport-to-origin, duplicate point/damage, or host-only visual divergence.
57
+ 5. For `matchmake`, drop one player, observe quorum/waiting behavior, then join a new identity and
58
+ confirm re-seat/backfill. For `connect`, verify the declared leave/solo/host-migration behavior.
59
+
60
+ If this proof was not possible, say exactly which items remain unverified. Never substitute a
61
+ screenshot or local-test mode for multiplayer evidence.
@@ -34,9 +34,18 @@ The genre where a single contested object is the whole game.
34
34
  everyone else) and writes the score to `shared`; everyone reads `shared.get("score_a")` in the HUD.
35
35
  - **A player quitting mid-match doesn't kill the ball** — if the owner leaves, the ball is
36
36
  reassigned to the host automatically and play continues.
37
+ - **The receiver's interaction window reads `stateRaw`, never `state`** — return/deflect/catch
38
+ checks against the smoothed ball test a ghost ~120 ms in the past; at rally speeds the REAL
39
+ ball has already crossed your paddle/goal line before the ghost arrives (field-verified: it
40
+ made a pong-style game unreturnable online while feeling perfect in solo play). Render the
41
+ smoothed ball, but run the window on the raw one, sweeping raw-sample→raw-sample.
42
+ - **Cap top ball speed against the network:** keep the arena/table crossing time above ~2× the
43
+ smoothing delay (≳0.25 s). Faster than that, no human can respond to what they're shown —
44
+ no amount of correct code fixes reacting to history.
37
45
 
38
46
  **Acceptance feel:** the kicker sees the ball respond the same frame; everyone else sees it glide;
39
- a contested kick settles on one owner within a snapshot; the owner leaving doesn't freeze the ball.
47
+ a contested kick settles on one owner within a snapshot; the owner leaving doesn't freeze the ball;
48
+ a full-speed shot is still humanly returnable by the receiving player.
40
49
 
41
50
  ---
42
51
 
@@ -94,6 +103,25 @@ slow physical projectiles, all against the same damage/defeat rules.)
94
103
  scores exactly one point even if the host changes mid-fight; the scoreboard survives a reload; one
95
104
  player on a throttled/backgrounded tab doesn't drag others.
96
105
 
106
+ ### Every travel-time projectile vs moving targets — the "visual hit, no damage" trap
107
+
108
+ Any projectile that spends time moving through the world — object-owned, host-simulated, or
109
+ deterministically replayed from a throw event (fireball, rocket, dodgeball) — inherits BOTH classic
110
+ netcode failures, and the symptom is always the same: *the projectile visibly hits a strafing player
111
+ and nothing happens.* Do not limit these rules to one implementation style:
112
+
113
+ 1. **Detection must run on the ATTACKER too, against `stateRaw`.** Target-only self-detection
114
+ ("each player owns their own hp, so only I test hits on me") silently fails against movers:
115
+ the thrower aimed at the smoothed ghost (~120 ms old), the deterministic projectile flies to
116
+ where the target WAS, and the target's self-test against its own REAL position never fires.
117
+ Run the attacker-side test each frame vs every remote's `stateRaw`, then send the damage
118
+ event the victim always honors (dedupe by projectile id so self-detection can coexist).
119
+ 2. **Sweep, never point-sample.** A projectile at 20 m/s moves ~0.33 m per 60 Hz frame — more
120
+ than most hit radii. Track `prev` each frame and test segment(prev→pos) vs the target
121
+ sphere, on both the attacker's and the target's tests.
122
+ 3. Both channels apply damage through ONE deduped `applyHit(projectileId)` on the victim, so a
123
+ ball registered by both sides still counts once.
124
+
97
125
  ---
98
126
 
99
127
  ## Recipe 3 — Co-op vs enemies (horde, tower defense, dungeon)
@@ -178,17 +206,22 @@ the short-handed side.
178
206
 
179
207
  Almost every game opens on a menu: **Play Online**, **Local / Single-player**, **Bots**. The rule
180
208
  that keeps online matches clean: **the menu is pre-multiplayer — there is no room, no queue, no
181
- server contact until the player commits to online.** `matchmake()` IS the "Play Online" button. Call
182
- it anywhere earlier (page load, boot code, beside `waitForPlayer()`) and a player who picks Bots is
183
- still parked in an online room, counting toward `minPlayers` while three real players wait for a
184
- fourth who never comes.
209
+ server contact until the player commits to online.** The chosen online API — `matchmake()` for fresh
210
+ bounded sessions, `connect()` for one ongoing shared world IS the "Play Online" button. Call either
211
+ one earlier (page load, boot code, beside `waitForPlayer()`) and a player who picks Bots is already
212
+ present online. With matchmaking they contaminate quorum; with a shared world they spawn an avatar
213
+ for someone who never chose to enter it.
214
+
215
+ Exception: a truly always-online game may call `connect()` after identity and spawn immediately,
216
+ but then loading the game already means "join" — it has no Local/Bots choice and no fake Play screen.
217
+ An ongoing drop-in sumo ring can use that shape; a sumo title menu cannot auto-spawn behind Play.
185
218
 
186
219
  | Moment | What runs | Relay contact |
187
220
  | --- | --- | --- |
188
221
  | Page load → menu | An **offline world** generated locally, menu overlay on top | NONE. Not `connect()`, not `matchmake()`. `waitForPlayer()` may run (mints identity only, seats nobody). |
189
222
  | "Bots" / "Local" | The same offline world + local AI / single-player | NONE, ever. |
190
- | "Play Online" | `matchmake()` → queue → the server seats you | FIRST contact. Only now are you a counted participant. |
191
- | Leaving online (back to menu / quit / switch to Bots after being seated) | Tear down the online view, return to the offline menu | `mm.cancel()` frees the seat so you stop counting toward `minPlayers`. |
223
+ | "Play Online" | `matchmake()` → queue/seat, or `connect()` ongoing shared world | FIRST contact. Only now are you an online participant. |
224
+ | Leaving online (back to menu / quit / switch to Bots after joining) | Tear down the online view, return to the offline menu | `mm.cancel()` for matchmaking; intentional `room.leave()` for connect. |
192
225
 
193
226
  **Decisions:**
194
227
  - **Every menu action blurs its button before acting.** Otherwise the first gameplay Space/Enter
@@ -196,6 +229,9 @@ fourth who never comes.
196
229
  menu/lobby phases (see the game-ui skill).
197
230
  - **`matchmake()` is created lazily, on the click — not held from boot.** Keep the handle in a
198
231
  variable so you can `cancel()` it; create it inside the "Play Online" handler, not at module load.
232
+ - **`connect()` follows the same commitment rule when this menu exists.** Create the session and
233
+ network avatar inside the online handler; intentional leave disables the rejoin loop, calls
234
+ `room.leave()`, removes the online avatar, and returns to the offline world.
199
235
  - **Bots/Local touch nothing networked.** They run the exact offline world the menu already booted.
200
236
  A player can sit in Bots forever and the online queue never knows they exist — which is the point.
201
237
  - **The waiting screen is an online-only, post-commit overlay.** Show it only when
@@ -181,17 +181,27 @@ room.inputs.on((fromId, payload) => {
181
181
  if (p?.obj && Array.isArray(p.push)) pending.push(p as { obj: string; push: number[] });
182
182
  });
183
183
 
184
- room.onHostTick(30, async (dtMs) => {
185
- // 1) First tick after election: adopt the objects + seed the physics world from the
186
- // last published truth (stateRaw) NEVER from zero, or the world teleports.
187
- for (const id of CONTESTED_IDS) {
188
- const view = room.objects.get(id);
189
- if (!view?.isMine) {
184
+ let hostReady: Promise<boolean> | null = null;
185
+ function ensureHostReady() {
186
+ if (!room.isHost) return Promise.resolve(false);
187
+ if (hostReady) return hostReady; // single flight: async host ticks never overlap adoption
188
+ hostReady = (async () => {
189
+ for (const id of CONTESTED_IDS) {
190
+ const raw = room.objects.get(id)?.stateRaw; // capture BEFORE claim changes local ownership view
190
191
  const result = await room.objects.claimConfirmed(id, { authority: "host" });
191
- if (!result.accepted) continue;
192
- seedRapierBody(id, view?.stateRaw); // position/rotation/velocity from the wire
192
+ if (!result.accepted) return false;
193
+ seedRapierBody(id, raw); // position/rotation/velocity from the wire, NEVER zero
193
194
  }
194
- }
195
+ pending.length = 0; // discard intent queued for the previous host/timeline
196
+ return true;
197
+ })();
198
+ return hostReady;
199
+ }
200
+ room.on("host", () => { hostReady = null; void ensureHostReady(); });
201
+
202
+ room.onHostTick(30, async (dtMs) => {
203
+ // 1) No physics or publishing until the whole host-owned set is adopted and seeded.
204
+ if (!(await ensureHostReady())) return;
195
205
  // 2) Apply everyone's inputs to the ONE authoritative Rapier world.
196
206
  for (const { obj, push } of pending.splice(0)) applyImpulse(obj, push);
197
207
  // 3) Step and publish (flat state: numbers + one [x,y,z,w] quaternion).
@@ -206,10 +216,13 @@ room.onHostTick(30, async (dtMs) => {
206
216
  });
207
217
  const r2 = (v: number) => Math.round(v * 100) / 100; // quantize — floats are JSON bloat
208
218
 
209
- // ---- rendering: identical on every client, host included ----
219
+ // ---- rendering: host draws its live simulation; followers draw the guarded smooth stream ----
210
220
  for (const id of CONTESTED_IDS) {
221
+ if (room.isHost) { drawFromBody(meshOf(id), bodyOf(id)); continue; }
211
222
  const view = room.objects.get(id);
212
- if (view) meshOf(id).position.set(view.state.x, view.state.y, view.state.z); // auto-smoothed
223
+ if (view && Number.isFinite(view.state.x) && Number.isFinite(view.state.y) && Number.isFinite(view.state.z)) {
224
+ meshOf(id).position.set(view.state.x, view.state.y, view.state.z); // auto-smoothed
225
+ }
213
226
  }
214
227
  ```
215
228
 
@@ -218,6 +231,8 @@ Rules that make it correct:
218
231
  - `onHostTick` pauses during reconnect and stops on demotion/leave/terminal disconnect. Bound the input
219
232
  queue, attribute input from the callback's authenticated `fromId` (never payload `from`), and discard
220
233
  stale pre-failover inputs when a new host adopts raw state.
234
+ - Host election and object ownership are separate. Adoption is one async flight per host term; never
235
+ issue claims every tick, and never step/publish until every required claim was accepted.
221
236
 
222
237
  - **Sim internals that must survive migration** (velocities, cooldowns, aggro) either live in
223
238
  the published object state or get mirrored at low rate into a dedicated object
@@ -1,23 +1,146 @@
1
1
  ---
2
2
  name: genex-threejs-procedural-vfx
3
- description: Author procedural real-time VFX for Genex Three.js games. Use for particles, trails, plasma, sparks, shockwaves, impacts, dissolving debris, ability effects, reentry wakes, event-timed visuals, effect pools, HDR emission hierarchy, and gameplay-readable spectacle.
3
+ description: Author procedural real-time VFX for Genex Three.js games, and decide WHERE effects belong — walk the game's moments one by one, the way the visual-direction gate walks its surfaces. Use for particles, trails, plasma, sparks, shockwaves, impacts, dissolving debris, ability effects, reentry wakes, event-timed visuals, effect pools, HDR emission hierarchy, and gameplay-readable spectacle.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Procedural VFX
7
7
 
8
8
  Build effects from an event envelope, motion field, geometry representation, and shading response. Avoid independent particle emitters that happen to share a color.
9
9
 
10
- ## Effect graph
10
+ ## The moment gate — run this BEFORE writing any effect code
11
11
 
12
- ```text
13
- ship/event state
14
- effect-specific geometry or instance attributes
15
- → flow-facing masks or analytic age
16
- material response
17
- pool/lifetime ownership
18
- HDR and bloom contribution
12
+ The visual-direction gate walks **surfaces** and asks "texture or shader?" for
13
+ each. It cannot see effects, because effects don't live on surfaces — they live
14
+ on **moments**. So walk the moments the same way, and decide each one.
15
+
16
+ List every moment the game's own rules fire. Take them from the game's controls
17
+ and contract, not from imagination — a bomb detonates, a crate breaks, a pickup
18
+ is collected, a player dies, a round starts, a shot lands. Then judge each:
19
+
20
+ > **Does the world change here in a way the player should SEE, and does the
21
+ > existing feedback already say it?**
22
+
23
+ Three answers, all legitimate, one forbidden:
24
+
25
+ - **Effect earns it** — the moment has a cause the player made and a consequence
26
+ they must read, and nothing else is carrying it. Build it.
27
+ - **Already covered** — a flash, a sound, hitstop, a decal, or an animation
28
+ already lands the beat. Adding particles buys nothing but fill rate. **Say so
29
+ in one line and move on.**
30
+ - **Deliberately bare** — the look wants restraint. Valid. **Say so in one line.**
31
+ - **Not deciding** — the only wrong answer. A game where bombs detonate and the
32
+ world does not react is not a style choice, it's an unfinished list.
33
+
34
+ Then check the inverse, because the failure runs both ways: **an effect on a
35
+ moment the player didn't cause and can't read is noise.** Ambient particles are
36
+ the usual offender — they sell mood, not information. Budget **one** ambient
37
+ layer for the whole scene, and only if the scene is visibly dead at rest.
38
+
39
+ **Shape is part of this gate.** For a moment made of energy — fire, a blast, a
40
+ shockwave — the question is never "what texture goes on this box?" It is "what
41
+ is this energy shaped like?" A shipped game reasoned "the flame is deliberately
42
+ untextured — it is pure emissive energy" and left a **cube** standing in for
43
+ fire. That sentence answers the surface gate correctly and the moment gate not at
44
+ all. If a primitive is standing in for an effect, it is a placeholder, whatever
45
+ the comment says.
46
+
47
+ ## Particles do not render round by default
48
+
49
+ `new THREE.PointsMaterial({ size, color })` draws every point as a **hard-edged
50
+ camera-facing square**. There is no soft falloff, no roundness, no fade — those
51
+ are things you add. Shipping the default is the "square flying things" look, and
52
+ it is the single most common particle defect in real builds.
53
+
54
+ The fix costs eight lines and no generation call:
55
+
56
+ ```ts
57
+ /** A soft round dot drawn into a canvas — no network, no asset, works offline. */
58
+ function dotTexture(size = 64): THREE.Texture {
59
+ const c = document.createElement("canvas");
60
+ c.width = c.height = size;
61
+ const ctx = c.getContext("2d")!;
62
+ const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
63
+ g.addColorStop(0, "rgba(255,255,255,1)");
64
+ g.addColorStop(0.4, "rgba(255,255,255,0.5)");
65
+ g.addColorStop(1, "rgba(255,255,255,0)");
66
+ ctx.fillStyle = g;
67
+ ctx.fillRect(0, 0, size, size);
68
+ const t = new THREE.Texture(c);
69
+ t.needsUpdate = true;
70
+ return t;
71
+ }
72
+
73
+ const material = new THREE.PointsMaterial({
74
+ map: dotTexture(), // ← without this every mote is a square
75
+ color: 0xffb98a,
76
+ size: 0.06,
77
+ sizeAttenuation: true, // near motes bigger than far ones
78
+ transparent: true,
79
+ depthWrite: false, // motes must not occlude each other
80
+ blending: THREE.AdditiveBlending,
81
+ });
82
+ ```
83
+
84
+ For a mote with real art (embers with structure, snowflakes, leaves), generate the
85
+ sprite instead — `npx genex image --transparent "single soft round ember, black
86
+ background"` — and use it as `map`. The canvas dot is the right default for
87
+ anything that is just light.
88
+
89
+ `depthWrite: false` turns off the depth **write**, not the depth **test**: a
90
+ particle still gets rejected by geometry in front of it. That's what you want —
91
+ but it also means a mote sitting fractionally below the floor vanishes. Spawn
92
+ above the surface, not on it.
93
+
94
+ ## Bursts are pooled, never allocated
95
+
96
+ A burst that runs `new Points(...)` per explosion allocates during the exact
97
+ frame the player is watching. Pre-build one pool at load, take from it, return on
98
+ death — the same shape the rest of the scene uses for bombs and flames.
99
+
100
+ ```ts
101
+ const N = 240;
102
+ const pos = new Float32Array(N * 3);
103
+ const vel = new Float32Array(N * 3);
104
+ const life = new Float32Array(N); // seconds remaining; 0 = free
105
+ const geo = new THREE.BufferGeometry();
106
+ geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
107
+ const points = new THREE.Points(geo, material);
108
+ points.frustumCulled = false; // positions move; the bounds don't follow
109
+
110
+ function burst(x: number, y: number, z: number, n = 24, speed = 3): void {
111
+ let spawned = 0;
112
+ for (let i = 0; i < N && spawned < n; i++) {
113
+ if (life[i] > 0) continue;
114
+ const th = Math.random() * Math.PI * 2;
115
+ const ph = Math.acos(2 * Math.random() - 1);
116
+ const s = speed * (0.5 + Math.random() * 0.5);
117
+ vel[i * 3] = Math.sin(ph) * Math.cos(th) * s;
118
+ vel[i * 3 + 1] = Math.abs(Math.cos(ph)) * s; // bias up — debris arcs
119
+ vel[i * 3 + 2] = Math.sin(ph) * Math.sin(th) * s;
120
+ pos[i * 3] = x; pos[i * 3 + 1] = y; pos[i * 3 + 2] = z;
121
+ life[i] = 0.5 + Math.random() * 0.4;
122
+ spawned++;
123
+ }
124
+ }
125
+
126
+ function step(dt: number): void {
127
+ for (let i = 0; i < N; i++) {
128
+ if (life[i] <= 0) continue;
129
+ life[i] -= dt;
130
+ if (life[i] <= 0) { pos[i * 3 + 1] = -1000; continue; } // park it offscreen
131
+ vel[i * 3 + 1] -= 9.8 * dt; // gravity
132
+ pos[i * 3] += vel[i * 3] * dt;
133
+ pos[i * 3 + 1] += vel[i * 3 + 1] * dt;
134
+ pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
135
+ }
136
+ geo.attributes.position.needsUpdate = true;
137
+ }
19
138
  ```
20
139
 
140
+ Call `burst()` from the same place the game already plays the explosion sound —
141
+ that call site IS the moment, and it is the proof the effect is event-driven
142
+ rather than ambient decoration.
143
+
21
144
  Read [references/vfx-systems.md](references/vfx-systems.md)
22
145
  for ship-conforming reentry shells, capsule wakes, dense instanced
23
146
  spark/debris pools, HDR hierarchy, and implementation limits.
@@ -31,11 +154,14 @@ spark/debris pools, HDR hierarchy, and implementation limits.
31
154
  - Pool instances and trails; do not allocate per burst.
32
155
  - Expose spawn, simulation, overdraw, and luminance debug views.
33
156
  - Include a non-bloom baseline that remains legible.
157
+ - Never ship a `PointsMaterial` without a `map` or `alphaMap` — see above.
34
158
 
35
159
  ## Routing boundary
36
160
 
37
161
  Use `$genex-threejs-temporal-surfaces` only for the screen-space
38
162
  frost/touch-history pipeline. Use `$genex-threejs-precipitation-surfaces` for
39
163
  falling rain or snow, splash flipbooks, and weather events that alter ground
40
- materials. Keep ship-space plasma, generated wakes, sparks,
164
+ materials. Impact **residue** on a surface (scorch, bullet holes) is a decal —
165
+ `$genex-ai-image` owns that; the spark that throws the residue is this skill, and
166
+ a hit usually wants both. Keep ship-space plasma, generated wakes, sparks,
41
167
  and pooled debris in this skill.
@@ -90,12 +90,16 @@ execution order.
90
90
  **Multiplayer is mandatory routing:** if the game has 2+ players sharing a world, loading
91
91
  `$genex-threejs-multiplayer` is **required** before any networking code — the SDK auto-smooths
92
92
  remote players **and shared objects**, and gives you server-enforced object ownership (a ball) and
93
- a room `host` (scores, enemies). Choose the net model before coding: `connect()` is one shared-world
94
- room; `matchmake()` creates capped queued rooms. Only `matchmake()` requires a server-owned
95
- `genex.matchmaking` block in `package.json`, reported by preview/publish. Both models accept a fresh
96
- auth supplier, but their terminal-recovery duties differ, so follow the chosen model's section rather than
97
- mixing the two. The skill also covers the rules that keep it smooth and its per-genre recipes
98
- (sports/ball, shooter, co-op).
93
+ a room `host` (scores, enemies). Choose the net model from the player experience before coding:
94
+ `connect()` is one ongoing drop-in world; `matchmake()` forms capped rooms for fresh matches or
95
+ missions with quorum/fair-start/backfill rules. Infer it when clear; ask one plain-language
96
+ ongoing-world-vs-fresh-session question only when both experiences genuinely fit. A Play/Online
97
+ button is always the commitment point for either API; only a truly always-online `connect()` world
98
+ may join/spawn immediately, and then it must not show a fake Play screen. Only `matchmake()` requires
99
+ a server-owned `genex.matchmaking` block in `package.json`, reported by preview/publish. Both models
100
+ accept a fresh auth supplier, but their terminal-recovery duties differ, so follow the chosen model's
101
+ section rather than mixing the two. Run that skill's netcode feel gate before handoff. The skill also
102
+ covers the rules that keep it smooth and its per-genre recipes (sports/ball, shooter, co-op).
99
103
 
100
104
  ## Real (AI-generated) assets — `npx genex` commands
101
105
 
@@ -64,7 +64,10 @@ Three.js release or branch, and do not blindly copy demo architecture.
64
64
  the scene alive at rest — an emissive pulse along edges, heat shimmer,
65
65
  drifting dust, a slowly flowing texture. Shader/procedural, zero
66
66
  generations, built with the scene — a world that is perfectly still
67
- reads as a screenshot, not a place;
67
+ reads as a screenshot, not a place. ONE is the budget, not the floor.
68
+ If the answer is particles, `$genex-threejs-procedural-vfx` carries the
69
+ recipe — a bare `PointsMaterial` renders hard squares, and picking this
70
+ bullet without that skill is how "drifting dust" ships as flying boxes;
68
71
  - **every primitive surface — texture it or shade it, decided one by one**:
69
72
  the ground is never the only surface. Walk the walls, barriers, kerbs,
70
73
  platforms and props the game BUILDS out of primitives, and give each a real
@@ -79,7 +82,24 @@ Three.js release or branch, and do not blindly copy demo architecture.
79
82
  ships when this bullet is skipped. `$genex-threejs-procedural-materials`
80
83
  and `$genex-threejs-procedural-vfx` own the craft. Whatever you apply, SEE
81
84
  it in the running game before calling it done — an unverified shader
82
- disfigures as easily as it delights;
85
+ disfigures as easily as it delights. Scale is not a judgement call: derive
86
+ the UVs from world size (`worldUV`, `$genex-ai-texture`) and never hand-pick
87
+ a `repeat` — one `repeat` cannot be right for six box faces of different
88
+ sizes, and a shipped game put 1:102 on a wall top that way;
89
+ - **every moment the rules fire — decide the effect one by one**: the
90
+ surfaces above are only half the scene. Walk the moments the GAME
91
+ CONTRACT already names — a hit lands, a crate breaks, a pickup is taken, a
92
+ player dies, a round starts — and for each ask whether the world changes
93
+ in a way the player should see, and whether existing feedback already says
94
+ it. "A flash and a sound already carry this" is a real answer; so is "bare
95
+ on purpose". Say either in one line. Not deciding is the only wrong answer,
96
+ and it is why games ship where bombs detonate and nothing reacts. Then
97
+ check the inverse — an effect on a moment the player didn't cause and can't
98
+ read is noise, and ambient particles are the usual offender. For a moment
99
+ made of ENERGY (fire, a blast, a shockwave) the question is not "what
100
+ texture goes on this box" but "what shape is this energy": a primitive
101
+ standing in for an effect is a placeholder no matter how deliberate the
102
+ comment says it is. `$genex-threejs-procedural-vfx` owns this gate;
83
103
  - **lighting/atmosphere mood** from the SAME shared style brief the UI gate
84
104
  wrote — one art direction across scene and UI.
85
105
  Planning is not building: effects still land LAST (steps 10–11); this step
@@ -223,4 +243,11 @@ claim-on-touch + a Rapier proxy — the soft handoff glides the ownership change
223
243
  **simultaneous** contest (two players pushing one crate against each other — sumo, tug-of-war)
224
244
  uses the host-authoritative pattern (`inputs` + `onHostTick`). Both are in that skill's
225
245
  host-physics reference.
246
+ Choose the net model from player experience: one ongoing drop-in world uses `connect()`; a fresh
247
+ bounded match/mission with quorum, fair start, teams, backfill, or parallel sessions uses
248
+ `matchmake()`. Infer this when the brief is clear. Ask one experience-level question only when both
249
+ are genuinely plausible — never ask the user to select an SDK API or preset. For either model, a
250
+ Play/Online button is the first relay contact; immediate `connect()` + spawn is valid only when
251
+ loading the game already means joining the always-online world and there is no fake Play screen.
252
+ Before handoff, run the multiplayer skill's netcode feel gate with two distinct identities.
226
253
  Use only the APIs that skill documents — do not invent transport methods.
@@ -66,6 +66,117 @@ everything twice.
66
66
  "click to aim/resume" cue appears. Headless caveat: `requestPointerLock`
67
67
  throws in headless Chromium — assert the wiring and the unlocked cue in a
68
68
  screenshot, and say plainly that the lock itself needs one manual click.
69
+ 7. **Ask the scene the two things the screenshot cannot answer** (below). Run it
70
+ once, in the same browser you already have open.
71
+
72
+ ### The screenshot has two blind spots — query them
73
+
74
+ A capture of the whole arena is taken from one camera at one distance. Two real
75
+ defect classes are invisible in it and both shipped to players:
76
+
77
+ - **Texture scale on faces you aren't looking at.** A shipped game's wall tops
78
+ were 1:102 — the steel texture read as wooden planks — while the wall *sides*,
79
+ the faces at eye level, were a near-perfect 1.2:1. Nothing in the screenshot
80
+ says which is which; the numbers do.
81
+ - **Coplanar surfaces.** Two boxes overlapping with faces at the same height
82
+ z-fight. It may look stable in a still and flicker the moment the camera
83
+ moves, so a screenshot is the one tool guaranteed to miss it.
84
+
85
+ Expose the scene in dev (`if (import.meta.env.DEV) (window as any).__scene = scene;`),
86
+ then paste this into the browser console once and read the verdict. It needs no
87
+ imports — pass it straight to your JS-eval tool.
88
+
89
+ ```js
90
+ (() => {
91
+ const S = window.__scene, MIN_M2 = 1.0; // judge real faces, not GLB slivers
92
+ const out = { stretched: [], zfight: [] };
93
+ const xf = (e,x,y,z) => [e[0]*x+e[4]*y+e[8]*z+e[12], e[1]*x+e[5]*y+e[9]*z+e[13], e[2]*x+e[6]*y+e[10]*z+e[14]];
94
+ const sub = (a,b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
95
+ const cross = (a,b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
96
+ const len = a => Math.hypot(a[0],a[1],a[2]);
97
+ const comb = (a,s,b,t) => [a[0]*s+b[0]*t, a[1]*s+b[1]*t, a[2]*s+b[2]*t];
98
+
99
+ // metres-per-tile along U and V, from each triangle's UV Jacobian. Geometry
100
+ // agnostic — a box, a plane and a GLB all measure the same way — and it reads
101
+ // texture.repeat, so it sees what the GPU will actually draw.
102
+ S.traverse(o => {
103
+ if (!o.isMesh) return;
104
+ const m = Array.isArray(o.material) ? o.material[0] : o.material;
105
+ const g = o.geometry, uv = g.attributes && g.attributes.uv;
106
+ if (!m || !m.map || !uv) return;
107
+ const pos = g.attributes.position, idx = g.index, e = o.matrixWorld.elements;
108
+ const rx = m.map.repeat.x, ry = m.map.repeat.y, faces = new Map();
109
+ for (let t = 0; t < (idx ? idx.count : pos.count) / 3; t++) {
110
+ const ix = [0,1,2].map(k => idx ? idx.getX(t*3+k) : t*3+k);
111
+ const p = ix.map(i => xf(e, pos.getX(i), pos.getY(i), pos.getZ(i)));
112
+ const e1 = sub(p[1],p[0]), e2 = sub(p[2],p[0]);
113
+ const nc = cross(e1,e2), area = len(nc)/2;
114
+ if (area < 1e-6) continue;
115
+ const nn = nc.map(v => v/(area*2));
116
+ const U = ix.map(i => uv.getX(i)*rx), V = ix.map(i => uv.getY(i)*ry);
117
+ const du1 = U[1]-U[0], dv1 = V[1]-V[0], du2 = U[2]-U[0], dv2 = V[2]-V[0];
118
+ const det = du1*dv2 - du2*dv1; if (Math.abs(det) < 1e-12) continue;
119
+ const a = len(comb(e1, dv2/det, e2, -dv1/det));
120
+ const b = len(comb(e2, du1/det, e1, -du2/det));
121
+ const k = nn.map(v => v.toFixed(1)).join(",");
122
+ const f = faces.get(k) || { area:0, u:0, v:0 };
123
+ f.area += area; f.u += a*area; f.v += b*area; faces.set(k, f);
124
+ }
125
+ for (const [k,f] of faces) {
126
+ if (f.area < MIN_M2) continue;
127
+ const mu = f.u/f.area, mv = f.v/f.area, asp = Math.max(mu/mv, mv/mu);
128
+ if (asp > 1.5) out.stretched.push({ mesh:o.name||o.type, face:k, aspect:+asp.toFixed(1),
129
+ m2:+f.area.toFixed(1), mPerTile:`${mu.toFixed(2)}x${mv.toFixed(2)}` });
130
+ }
131
+ });
132
+ out.stretched.sort((a,b) => b.m2 - a.m2); // biggest surfaces first
133
+
134
+ // opaque depth-writing meshes that interpenetrate AND share a face plane
135
+ const solid = [];
136
+ S.traverse(o => {
137
+ const m = Array.isArray(o.material) ? o.material[0] : o.material;
138
+ if (!o.isMesh || o.isInstancedMesh || !m || m.transparent || m.depthWrite === false) return;
139
+ if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
140
+ const bb = o.geometry.boundingBox, e = o.matrixWorld.elements;
141
+ const lo = [Infinity,Infinity,Infinity], hi = [-Infinity,-Infinity,-Infinity];
142
+ for (const cx of [bb.min.x,bb.max.x]) for (const cy of [bb.min.y,bb.max.y]) for (const cz of [bb.min.z,bb.max.z]) {
143
+ const w = xf(e,cx,cy,cz);
144
+ for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i],w[i]); hi[i] = Math.max(hi[i],w[i]); }
145
+ }
146
+ solid.push({ o, lo, hi });
147
+ });
148
+ const E = 1e-4;
149
+ for (let i = 0; i < solid.length; i++) for (let j = i+1; j < solid.length; j++) {
150
+ const A = solid[i], B = solid[j];
151
+ if ([0,1,2].some(k => Math.min(A.hi[k],B.hi[k]) - Math.max(A.lo[k],B.lo[k]) <= E)) continue;
152
+ const shared = [];
153
+ for (const k of [0,1,2]) {
154
+ if (Math.abs(A.lo[k]-B.lo[k]) < E) shared.push("min."+"xyz"[k]+"="+A.lo[k].toFixed(2));
155
+ if (Math.abs(A.hi[k]-B.hi[k]) < E) shared.push("max."+"xyz"[k]+"="+A.hi[k].toFixed(2));
156
+ }
157
+ if (shared.length) out.zfight.push({ a:A.o.name||"mesh", b:B.o.name||"mesh", coplanar:shared });
158
+ }
159
+ return out;
160
+ })()
161
+ ```
162
+
163
+ Read it as: **`stretched` should be empty.** Every entry is a face over a square
164
+ metre whose texels aren't square; `mPerTile` gives you the actual numbers, and
165
+ `m2` tells you whether it's a kerb or a wall. `MIN_M2` is there because a
166
+ generated GLB's authored UVs produce hundreds of tiny-triangle readings that
167
+ drown the real finding — raise it if a model still floods the list, and note in
168
+ the handoff that you did.
169
+
170
+ **`zfight` should be empty.** Every entry is two solids that interpenetrate AND
171
+ share a face plane, which will fight and flicker the moment the camera moves.
172
+
173
+ Both are wiring defects, so fix the wiring: `worldUV` (`$genex-ai-texture`) for
174
+ the first; for the second, stop the solids overlapping — butt the spans end to
175
+ end rather than crossing them at the corners.
176
+
177
+ Run against the shipped BomberDome build, this printed `aspect: 102, m2: 17,
178
+ mPerTile: "0.17x17.00"` for the wall tops and four coplanar pairs at
179
+ `max.y=2.40`. Both had been in front of the agent for an hour of screenshots.
69
180
 
70
181
  Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
71
182
  visual-system work — the sequence above.