@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 +111 -4
- package/package.json +1 -1
- package/templates/skills/genex-ai-texture/SKILL.md +95 -31
- package/templates/skills/genex-threejs-camera-direction/references/camera-rigs.md +11 -0
- package/templates/skills/genex-threejs-game-feel/SKILL.md +7 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +130 -15
- package/templates/skills/genex-threejs-multiplayer/references/genex-netcode-feel-checklist.md +61 -0
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +43 -7
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +26 -11
- package/templates/skills/genex-threejs-procedural-vfx/SKILL.md +136 -10
- package/templates/skills/genex-threejs-skill-router/SKILL.md +10 -6
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +29 -2
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +111 -0
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)
|
|
2507
|
-
log.plain("
|
|
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
|
|
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
|
@@ -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
|
|
55
|
-
|
|
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
|
|
61
|
-
`
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
39
|
+
npm i @genex-ai/multiplayer@^0.10.2
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
> Pin `@^0.10.
|
|
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.
|
|
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
|
|
62
|
+
## Choose one net model from the player experience
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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()`
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 →
|
|
191
|
-
| Leaving online (back to menu / quit / switch to Bots after
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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)
|
|
192
|
-
seedRapierBody(id,
|
|
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:
|
|
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
|
|
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
|
-
##
|
|
10
|
+
## The moment gate — run this BEFORE writing any effect code
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|