@genex-ai/cli-demo 0.51.0-dev.99 → 0.52.0-dev.112
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 +166 -78
- package/package.json +2 -2
- package/templates/skills/genex-ai-texture/SKILL.md +28 -13
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +10 -8
- package/templates/skills/genex-threejs-game-ui/SKILL.md +39 -7
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +192 -32
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +25 -2
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +1 -1
- package/templates/skills/genex-threejs-skill-router/SKILL.md +21 -7
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +28 -7
package/dist/index.js
CHANGED
|
@@ -1652,7 +1652,13 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1652
1652
|
async function pushSource(cwd, ctx, log) {
|
|
1653
1653
|
const target = await fetchPushUrl(ctx, log);
|
|
1654
1654
|
if (!target) return false;
|
|
1655
|
-
|
|
1655
|
+
if (await pushWorktree(cwd, target.pushUrl, target.managed, log)) return true;
|
|
1656
|
+
if (!target.managed) return false;
|
|
1657
|
+
log.info("Retrying the source push\u2026");
|
|
1658
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
1659
|
+
const fresh = await fetchPushUrl(ctx, log);
|
|
1660
|
+
if (!fresh) return false;
|
|
1661
|
+
return pushWorktree(cwd, fresh.pushUrl, fresh.managed, log);
|
|
1656
1662
|
}
|
|
1657
1663
|
async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
1658
1664
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
@@ -2119,6 +2125,104 @@ async function runPreview(opts) {
|
|
|
2119
2125
|
}
|
|
2120
2126
|
}
|
|
2121
2127
|
|
|
2128
|
+
// src/commands/generate.ts
|
|
2129
|
+
import { PNG } from "pngjs";
|
|
2130
|
+
|
|
2131
|
+
// src/lib/open.ts
|
|
2132
|
+
import { spawn as spawn4 } from "child_process";
|
|
2133
|
+
function tokenize(cmd) {
|
|
2134
|
+
return cmd.trim().split(/\s+/).filter(Boolean);
|
|
2135
|
+
}
|
|
2136
|
+
function openUrl(url) {
|
|
2137
|
+
let command;
|
|
2138
|
+
let args;
|
|
2139
|
+
const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
|
|
2140
|
+
const customParts = custom ? tokenize(custom) : [];
|
|
2141
|
+
if (customParts.length > 0) {
|
|
2142
|
+
command = customParts[0];
|
|
2143
|
+
args = [...customParts.slice(1), url];
|
|
2144
|
+
} else {
|
|
2145
|
+
switch (process.platform) {
|
|
2146
|
+
case "darwin":
|
|
2147
|
+
command = "open";
|
|
2148
|
+
args = [url];
|
|
2149
|
+
break;
|
|
2150
|
+
case "win32":
|
|
2151
|
+
command = "cmd";
|
|
2152
|
+
args = ["/c", "start", "", url];
|
|
2153
|
+
break;
|
|
2154
|
+
default:
|
|
2155
|
+
command = "xdg-open";
|
|
2156
|
+
args = [url];
|
|
2157
|
+
break;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
try {
|
|
2161
|
+
const child = spawn4(command, args, { stdio: "ignore", detached: true });
|
|
2162
|
+
child.on("error", () => {
|
|
2163
|
+
});
|
|
2164
|
+
child.unref();
|
|
2165
|
+
} catch {
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// src/lib/seams.ts
|
|
2170
|
+
var DEFAULT_SEAM_TOLERANCE = 3;
|
|
2171
|
+
function measureSeam(png, tolerance = DEFAULT_SEAM_TOLERANCE) {
|
|
2172
|
+
const { width: w, height: h, data } = png;
|
|
2173
|
+
const chan = (x, y, ch) => data[(y * w + x) * 4 + ch] ?? 0;
|
|
2174
|
+
const colDiff = (a, b) => {
|
|
2175
|
+
let s = 0;
|
|
2176
|
+
for (let y = 0; y < h; y++) {
|
|
2177
|
+
s += Math.abs(chan(a, y, 0) - chan(b, y, 0));
|
|
2178
|
+
s += Math.abs(chan(a, y, 1) - chan(b, y, 1));
|
|
2179
|
+
s += Math.abs(chan(a, y, 2) - chan(b, y, 2));
|
|
2180
|
+
}
|
|
2181
|
+
return s / (h * 3);
|
|
2182
|
+
};
|
|
2183
|
+
const rowDiff = (a, b) => {
|
|
2184
|
+
let s = 0;
|
|
2185
|
+
for (let x = 0; x < w; x++) {
|
|
2186
|
+
s += Math.abs(chan(x, a, 0) - chan(x, b, 0));
|
|
2187
|
+
s += Math.abs(chan(x, a, 1) - chan(x, b, 1));
|
|
2188
|
+
s += Math.abs(chan(x, a, 2) - chan(x, b, 2));
|
|
2189
|
+
}
|
|
2190
|
+
return s / (w * 3);
|
|
2191
|
+
};
|
|
2192
|
+
const seamH = colDiff(0, w - 1);
|
|
2193
|
+
const seamV = rowDiff(0, h - 1);
|
|
2194
|
+
const stepX = Math.max(1, Math.floor(w / 64));
|
|
2195
|
+
const stepY = Math.max(1, Math.floor(h / 64));
|
|
2196
|
+
let baseH = 0;
|
|
2197
|
+
let nH = 0;
|
|
2198
|
+
for (let x = 1; x < w - 1; x += stepX) {
|
|
2199
|
+
baseH += colDiff(x, x + 1);
|
|
2200
|
+
nH++;
|
|
2201
|
+
}
|
|
2202
|
+
let baseV = 0;
|
|
2203
|
+
let nV = 0;
|
|
2204
|
+
for (let y = 1; y < h - 1; y += stepY) {
|
|
2205
|
+
baseV += rowDiff(y, y + 1);
|
|
2206
|
+
nV++;
|
|
2207
|
+
}
|
|
2208
|
+
baseH = Math.max(baseH / Math.max(1, nH), 1e-6);
|
|
2209
|
+
baseV = Math.max(baseV / Math.max(1, nV), 1e-6);
|
|
2210
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
2211
|
+
const ratioHorizontal = round(seamH / baseH);
|
|
2212
|
+
const ratioVertical = round(seamV / baseV);
|
|
2213
|
+
const worstRatio = Math.max(ratioHorizontal, ratioVertical);
|
|
2214
|
+
return {
|
|
2215
|
+
width: w,
|
|
2216
|
+
height: h,
|
|
2217
|
+
ratioHorizontal,
|
|
2218
|
+
ratioVertical,
|
|
2219
|
+
worstRatio,
|
|
2220
|
+
worstAxis: ratioHorizontal >= ratioVertical ? "horizontal" : "vertical",
|
|
2221
|
+
tolerance,
|
|
2222
|
+
seamless: worstRatio <= tolerance
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2122
2226
|
// src/lib/sse.ts
|
|
2123
2227
|
async function* readSSE(body) {
|
|
2124
2228
|
const decoder = new TextDecoder();
|
|
@@ -2255,9 +2359,9 @@ async function runGenerate(kind, opts) {
|
|
|
2255
2359
|
log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
|
|
2256
2360
|
return;
|
|
2257
2361
|
}
|
|
2258
|
-
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2362
|
+
await awaitAndReport(apiUrl, token, id, kind, log, opts.open);
|
|
2259
2363
|
}
|
|
2260
|
-
async function awaitAndReport(apiUrl, token, id, kind, log) {
|
|
2364
|
+
async function awaitAndReport(apiUrl, token, id, kind, log, open = false) {
|
|
2261
2365
|
log.step("Generating\u2026 (this can take up to a minute)");
|
|
2262
2366
|
const onProgress = (p) => log.dim(` ${p}%`);
|
|
2263
2367
|
const timeoutMs = waitTimeoutFor(kind);
|
|
@@ -2269,9 +2373,9 @@ async function awaitAndReport(apiUrl, token, id, kind, log) {
|
|
|
2269
2373
|
process.exitCode = 1;
|
|
2270
2374
|
return;
|
|
2271
2375
|
}
|
|
2272
|
-
await reportTerminal(kind, view, log);
|
|
2376
|
+
await reportTerminal(kind, view, log, open);
|
|
2273
2377
|
}
|
|
2274
|
-
async function reportTerminal(kind, view, log) {
|
|
2378
|
+
async function reportTerminal(kind, view, log, open = false) {
|
|
2275
2379
|
if (view.status !== "completed") {
|
|
2276
2380
|
log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
|
|
2277
2381
|
process.exitCode = 1;
|
|
@@ -2290,6 +2394,15 @@ async function reportTerminal(kind, view, log) {
|
|
|
2290
2394
|
log.plain(` ${label}${f.url}`);
|
|
2291
2395
|
}
|
|
2292
2396
|
log.plain("");
|
|
2397
|
+
if (kind === "texture" && files[0]) await reportTextureSeam(files[0].url, log);
|
|
2398
|
+
if (open && files[0]) {
|
|
2399
|
+
openUrl(files[0].url);
|
|
2400
|
+
log.plain(
|
|
2401
|
+
` ${c.bold("\u{1F441} Show this to the user")} \u2014 it just opened in their browser; paste this link in chat too so they can open it themselves:`
|
|
2402
|
+
);
|
|
2403
|
+
log.plain(` ${files[0].url}`);
|
|
2404
|
+
log.plain("");
|
|
2405
|
+
}
|
|
2293
2406
|
printHint(kind, files, log);
|
|
2294
2407
|
await firstPreviewNudge(log);
|
|
2295
2408
|
}
|
|
@@ -2375,6 +2488,27 @@ async function poll(apiUrl, token, id, onProgress, timeoutMs) {
|
|
|
2375
2488
|
}
|
|
2376
2489
|
return null;
|
|
2377
2490
|
}
|
|
2491
|
+
async function reportTextureSeam(url, log) {
|
|
2492
|
+
try {
|
|
2493
|
+
const res = await fetch(url);
|
|
2494
|
+
if (!res.ok) return;
|
|
2495
|
+
const png = PNG.sync.read(Buffer.from(await res.arrayBuffer()));
|
|
2496
|
+
if (png.width < 4 || png.height < 4) return;
|
|
2497
|
+
const r = measureSeam(png);
|
|
2498
|
+
if (r.seamless) {
|
|
2499
|
+
log.dim(` Tiling check: seam ${r.worstRatio}\xD7 this texture's own detail \u2014 tiles cleanly.`);
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
log.error(
|
|
2503
|
+
`Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
|
|
2504
|
+
);
|
|
2505
|
+
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.");
|
|
2508
|
+
log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
|
|
2509
|
+
} catch {
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2378
2512
|
function printHint(kind, files, log) {
|
|
2379
2513
|
const url = files[0]?.url ?? "";
|
|
2380
2514
|
const hint = {
|
|
@@ -2440,10 +2574,10 @@ async function runWait(opts) {
|
|
|
2440
2574
|
log.dim(` ${kind} ${id}`);
|
|
2441
2575
|
log.plain("");
|
|
2442
2576
|
if (TERMINAL2.has(view.status)) {
|
|
2443
|
-
await reportTerminal(kind, view, log);
|
|
2577
|
+
await reportTerminal(kind, view, log, opts.open);
|
|
2444
2578
|
return;
|
|
2445
2579
|
}
|
|
2446
|
-
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2580
|
+
await awaitAndReport(apiUrl, token, id, kind, log, opts.open);
|
|
2447
2581
|
}
|
|
2448
2582
|
|
|
2449
2583
|
// src/commands/controller.ts
|
|
@@ -3029,11 +3163,11 @@ function rank(items, query) {
|
|
|
3029
3163
|
// src/commands/ui.ts
|
|
3030
3164
|
import fs14 from "fs/promises";
|
|
3031
3165
|
import path14 from "path";
|
|
3032
|
-
import { PNG as
|
|
3166
|
+
import { PNG as PNG3 } from "pngjs";
|
|
3033
3167
|
|
|
3034
3168
|
// src/lib/png-tools.ts
|
|
3035
3169
|
import fs13 from "fs/promises";
|
|
3036
|
-
import { PNG } from "pngjs";
|
|
3170
|
+
import { PNG as PNG2 } from "pngjs";
|
|
3037
3171
|
var ALPHA_TRANSPARENT_MAX = 16;
|
|
3038
3172
|
var isHttpUrl = (s) => /^https?:\/\//i.test(s);
|
|
3039
3173
|
async function loadPng(input) {
|
|
@@ -3045,14 +3179,14 @@ async function loadPng(input) {
|
|
|
3045
3179
|
} else {
|
|
3046
3180
|
buf = await fs13.readFile(input);
|
|
3047
3181
|
}
|
|
3048
|
-
return
|
|
3182
|
+
return PNG2.sync.read(buf);
|
|
3049
3183
|
}
|
|
3050
3184
|
async function writePng(file, png) {
|
|
3051
|
-
await fs13.writeFile(file,
|
|
3185
|
+
await fs13.writeFile(file, PNG2.sync.write(png));
|
|
3052
3186
|
}
|
|
3053
3187
|
function cropPng(image, box) {
|
|
3054
|
-
const out = new
|
|
3055
|
-
|
|
3188
|
+
const out = new PNG2({ width: box.w, height: box.h });
|
|
3189
|
+
PNG2.bitblt(image, out, box.x, box.y, box.w, box.h, 0, 0);
|
|
3056
3190
|
return out;
|
|
3057
3191
|
}
|
|
3058
3192
|
function floodFill(width, height, visited, queue, startX, startY, inRegion, onPixel) {
|
|
@@ -3391,7 +3525,7 @@ async function uiExtract(opts, log) {
|
|
|
3391
3525
|
log.warn(`Skipping "${name}" \u2014 crop too small (${cropW}x${cropH}).`);
|
|
3392
3526
|
continue;
|
|
3393
3527
|
}
|
|
3394
|
-
const out = new
|
|
3528
|
+
const out = new PNG3({ width: cropW, height: cropH });
|
|
3395
3529
|
for (let yo = 0; yo < cropH; yo++) {
|
|
3396
3530
|
const sy = padY0 + yo;
|
|
3397
3531
|
for (let xo = 0; xo < cropW; xo++) {
|
|
@@ -3552,7 +3686,7 @@ function greenAlpha(r, g, b, a) {
|
|
|
3552
3686
|
return 0;
|
|
3553
3687
|
}
|
|
3554
3688
|
function convertGreenMask(annotatedCrop) {
|
|
3555
|
-
const out = new
|
|
3689
|
+
const out = new PNG3({ width: annotatedCrop.width, height: annotatedCrop.height });
|
|
3556
3690
|
let filled = 0;
|
|
3557
3691
|
let minX = annotatedCrop.width;
|
|
3558
3692
|
let minY = annotatedCrop.height;
|
|
@@ -3654,7 +3788,7 @@ function blendPixel(image, x, y, rgba) {
|
|
|
3654
3788
|
image.data[idx + 3] = Math.max(image.data[idx + 3], rgba[3]);
|
|
3655
3789
|
}
|
|
3656
3790
|
function makeOverlay(clean2, mask) {
|
|
3657
|
-
const out = new
|
|
3791
|
+
const out = new PNG3({ width: clean2.width, height: clean2.height });
|
|
3658
3792
|
clean2.data.copy(out.data);
|
|
3659
3793
|
for (let y = 0; y < mask.height; y += 1) {
|
|
3660
3794
|
for (let x = 0; x < mask.width; x += 1) {
|
|
@@ -3934,74 +4068,23 @@ async function uiTrim(opts, log) {
|
|
|
3934
4068
|
}
|
|
3935
4069
|
async function uiSeams(opts, log) {
|
|
3936
4070
|
const input = requireOpt(opts.input, "--in", "seams");
|
|
3937
|
-
const tol = opts.seamTolerance ??
|
|
4071
|
+
const tol = opts.seamTolerance ?? DEFAULT_SEAM_TOLERANCE;
|
|
3938
4072
|
const png = await loadPng(input);
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
const chan = (x, y, ch) => data[(y * w + x) * 4 + ch] ?? 0;
|
|
3942
|
-
const colDiff = (a, b) => {
|
|
3943
|
-
let s = 0;
|
|
3944
|
-
for (let y = 0; y < h; y++) {
|
|
3945
|
-
s += Math.abs(chan(a, y, 0) - chan(b, y, 0));
|
|
3946
|
-
s += Math.abs(chan(a, y, 1) - chan(b, y, 1));
|
|
3947
|
-
s += Math.abs(chan(a, y, 2) - chan(b, y, 2));
|
|
3948
|
-
}
|
|
3949
|
-
return s / (h * 3);
|
|
3950
|
-
};
|
|
3951
|
-
const rowDiff = (a, b) => {
|
|
3952
|
-
let s = 0;
|
|
3953
|
-
for (let x = 0; x < w; x++) {
|
|
3954
|
-
s += Math.abs(chan(x, a, 0) - chan(x, b, 0));
|
|
3955
|
-
s += Math.abs(chan(x, a, 1) - chan(x, b, 1));
|
|
3956
|
-
s += Math.abs(chan(x, a, 2) - chan(x, b, 2));
|
|
3957
|
-
}
|
|
3958
|
-
return s / (w * 3);
|
|
3959
|
-
};
|
|
3960
|
-
const seamH = colDiff(0, w - 1);
|
|
3961
|
-
const seamV = rowDiff(0, h - 1);
|
|
3962
|
-
const stepX = Math.max(1, Math.floor(w / 64));
|
|
3963
|
-
const stepY = Math.max(1, Math.floor(h / 64));
|
|
3964
|
-
let baseH = 0;
|
|
3965
|
-
let nH = 0;
|
|
3966
|
-
for (let x = 1; x < w - 1; x += stepX) {
|
|
3967
|
-
baseH += colDiff(x, x + 1);
|
|
3968
|
-
nH++;
|
|
3969
|
-
}
|
|
3970
|
-
let baseV = 0;
|
|
3971
|
-
let nV = 0;
|
|
3972
|
-
for (let y = 1; y < h - 1; y += stepY) {
|
|
3973
|
-
baseV += rowDiff(y, y + 1);
|
|
3974
|
-
nV++;
|
|
4073
|
+
if (png.width < 4 || png.height < 4) {
|
|
4074
|
+
fail("Image is too small to test for tiling.", { width: png.width, height: png.height });
|
|
3975
4075
|
}
|
|
3976
|
-
|
|
3977
|
-
baseV = Math.max(baseV / Math.max(1, nV), 1e-6);
|
|
3978
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
3979
|
-
const ratioH = round(seamH / baseH);
|
|
3980
|
-
const ratioV = round(seamV / baseV);
|
|
3981
|
-
const worst = Math.max(ratioH, ratioV);
|
|
3982
|
-
const axis = ratioH >= ratioV ? "horizontal" : "vertical";
|
|
3983
|
-
const seamless = worst <= tol;
|
|
4076
|
+
const r = measureSeam(png, tol);
|
|
3984
4077
|
const name = input.split("/").pop() ?? input;
|
|
3985
|
-
process.stdout.write(
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
ratioVertical: ratioV,
|
|
3992
|
-
worstRatio: worst,
|
|
3993
|
-
worstAxis: axis,
|
|
3994
|
-
tolerance: tol,
|
|
3995
|
-
seamless
|
|
3996
|
-
})}
|
|
3997
|
-
`
|
|
3998
|
-
);
|
|
3999
|
-
if (seamless) {
|
|
4000
|
-
log.success(`${name}: tiles cleanly \u2014 worst seam ${worst}\xD7 the texture's own detail (\u2264 ${tol}\xD7).`);
|
|
4078
|
+
process.stdout.write(`${JSON.stringify({ input, ...r })}
|
|
4079
|
+
`);
|
|
4080
|
+
if (r.seamless) {
|
|
4081
|
+
log.success(
|
|
4082
|
+
`${name}: tiles cleanly \u2014 worst seam ${r.worstRatio}\xD7 the texture's own detail (\u2264 ${tol}\xD7).`
|
|
4083
|
+
);
|
|
4001
4084
|
return;
|
|
4002
4085
|
}
|
|
4003
4086
|
fail(
|
|
4004
|
-
`Visible ${
|
|
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.`
|
|
4005
4088
|
);
|
|
4006
4089
|
}
|
|
4007
4090
|
|
|
@@ -4048,6 +4131,8 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `
|
|
|
4048
4131
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
|
4049
4132
|
--duration <sec> (sfx, video) target clip length in seconds.
|
|
4050
4133
|
--transparent (image) transparent background (alpha) \u2014 for decals/stickers.
|
|
4134
|
+
--open (image/video) open the finished asset in the user's browser +
|
|
4135
|
+
print a link \u2014 show them the concept frame / anything they approve.
|
|
4051
4136
|
--aspect <ratio> (image) aspect preset (e.g. square, landscape, portrait).
|
|
4052
4137
|
--quality <q> (image) quality preset: low | medium | high.
|
|
4053
4138
|
--candidates <n> (image) generate 2-4 variants in ONE call and print every
|
|
@@ -4232,6 +4317,9 @@ function parseArgs(argv) {
|
|
|
4232
4317
|
case "--no-wait":
|
|
4233
4318
|
parsed.options.noWait = true;
|
|
4234
4319
|
break;
|
|
4320
|
+
case "--open":
|
|
4321
|
+
parsed.options.open = true;
|
|
4322
|
+
break;
|
|
4235
4323
|
case "--terrain":
|
|
4236
4324
|
parsed.options.terrain = true;
|
|
4237
4325
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0-dev.112",
|
|
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": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node src/index.ts",
|
|
22
22
|
"dev": "node --watch src/index.ts",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
|
-
"test": "node --test test/*.test.ts"
|
|
24
|
+
"test": "node --test --test-concurrency=1 test/*.test.ts"
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|
|
27
27
|
"cli",
|
|
@@ -67,23 +67,38 @@ map.repeat.set(64, 64);
|
|
|
67
67
|
scene.add(ground);
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
##
|
|
70
|
+
## Tiling is checked FOR you — read the verdict
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
asks for seamless but does not guarantee it. So measure the seam, don't guess:
|
|
72
|
+
`npx genex texture` measures the wrap seam of every texture it generates and
|
|
73
|
+
prints the verdict right under the URL. A bad one looks like this:
|
|
75
74
|
|
|
76
|
-
```
|
|
77
|
-
|
|
75
|
+
```
|
|
76
|
+
✗ Visible vertical tiling seam — the tile boundary jumps 6.6× this texture's own detail (want ≤ 3×).
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`--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)
|
|
78
95
|
```
|
|
79
96
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
floor/wall texture before you call the scene done — a large flat surface with a
|
|
86
|
-
high `repeat` and an unchecked texture is exactly how seams reach the player.
|
|
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.
|
|
87
102
|
|
|
88
103
|
## Publish checklist
|
|
89
104
|
|
|
@@ -110,10 +110,11 @@ boot-path gate; `waitForAuth()` guards saves only.
|
|
|
110
110
|
advanced case of calling the Genex API by hand. The state/leaderboard
|
|
111
111
|
helpers below attach it automatically — prefer them; never hand-roll fetch
|
|
112
112
|
calls to `/state` endpoints.
|
|
113
|
-
- `getColyseusAuth()` → `{ embedToken } | undefined` —
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
automatically (~every
|
|
113
|
+
- `getColyseusAuth()` → `{ embedToken } | undefined` — relay credential
|
|
114
|
+
(REQUIRED; guest tokens are accepted). Pass `auth: () => getColyseusAuth()`
|
|
115
|
+
to both `connect()` and `matchmake()` so each explicit connect and every
|
|
116
|
+
automatic re-seat reads a fresh token. Tokens rotate automatically (~every
|
|
117
|
+
10 minutes). Never cache the value across joins and never log it.
|
|
117
118
|
- `on(event, cb)` → unsubscribe fn. Events: `"authenticated"`, `"guest"`,
|
|
118
119
|
`"blocked"`, `"error"`. A mid-game sign-in fires `"authenticated"` after
|
|
119
120
|
`"guest"` — progress saving can start right then, no reload.
|
|
@@ -325,8 +326,9 @@ Rules:
|
|
|
325
326
|
- [ ] `sentryCanvasSnapshot(renderer.domElement)` runs after `renderer.render()`
|
|
326
327
|
in the main loop (WebGL and WebGPU alike).
|
|
327
328
|
- [ ] `genex.config.ts` includes `dashboardOrigins` (from `.genex/project.json`).
|
|
328
|
-
- [ ] Multiplayer
|
|
329
|
-
|
|
329
|
+
- [ ] Multiplayer and player-name UI await `waitForPlayer()` — NEVER
|
|
330
|
+
`waitForAuth()` (guests would hang forever). Both `connect()` and
|
|
331
|
+
`matchmake()` receive `auth: () => getColyseusAuth()`.
|
|
330
332
|
- [ ] Saves/loads use the SDK helpers (`savePlayerState`/`loadPlayerState` for
|
|
331
333
|
per-player progress, `saveWorldState`/`loadWorldState` for the shared
|
|
332
334
|
world, `submitScore`/`getLeaderboard` for scores) — no hand-rolled fetch
|
|
@@ -360,8 +362,8 @@ Rules:
|
|
|
360
362
|
- **Multiplayer `connect()` fails in local test mode** — by design: no relay
|
|
361
363
|
credential exists there. Validate multiplayer on the hosted draft (the
|
|
362
364
|
owner's session) or the published game, and say plainly when it wasn't.
|
|
363
|
-
- **Multiplayer join rejected with 401** —
|
|
364
|
-
`waitForPlayer()` resolved, without `auth: getColyseusAuth()
|
|
365
|
+
- **Multiplayer join rejected with 401** — the join ran before
|
|
366
|
+
`waitForPlayer()` resolved, without `auth: () => getColyseusAuth()`, or with a
|
|
365
367
|
stale cached token on reconnect (read it fresh each call).
|
|
366
368
|
- **Multiplayer join rejected with 403 "guest capacity"** — the room is at its
|
|
367
369
|
guest limit; only signing in gets the player a seat right now. Surface the
|
|
@@ -79,13 +79,25 @@ lap counter in a game without laps). `--aspect 16:9 --quality high
|
|
|
79
79
|
--no-wait`, enqueued FIRST of all art; the URL goes into the style-brief
|
|
80
80
|
comment.
|
|
81
81
|
|
|
82
|
-
**Share it before building on it
|
|
83
|
-
the
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
**Share it before building on it — actually SHOW the image, don't just ask.**
|
|
83
|
+
When the concept lands, pick it up with `genex wait <id> --open` (or generate it
|
|
84
|
+
with `--open`): it opens in the user's browser AND prints the link. Paste that
|
|
85
|
+
URL as a clickable link in your approval message — a URL is invisible in a
|
|
86
|
+
terminal, and "do you like it?" with no picture in front of the user is the #1
|
|
87
|
+
way this checkpoint fails (they end up digging logs for the file path). Then ask
|
|
88
|
+
ONE structured question — "this is roughly how the game will look: keep it, or
|
|
89
|
+
change something?" — with 2–3 concrete adjustment options. Keep building
|
|
90
|
+
gameplay-neutral work (scaffold, physics, netcode) while waiting, but do NOT
|
|
91
|
+
anchor further art to an unapproved frame. **Approval is a LOOP, not a
|
|
92
|
+
one-shot:** if the user dislikes ANYTHING, regenerate with their exact notes
|
|
93
|
+
(`--candidates 2–3` gives them options to choose from), open + link the new
|
|
94
|
+
frame, and ask again — repeat until they actually approve. Carry every note
|
|
95
|
+
forward so each round compounds; if two rounds don't converge, offer 2–3
|
|
96
|
+
distinct directions as a structured question instead of re-rolling blind. The
|
|
97
|
+
HUD mockup, the menu, and every downstream asset wait for a frame the user has
|
|
98
|
+
signed off — a single "meh" is never permission to move on. The same "open it +
|
|
99
|
+
paste the link" rule covers every image the user weighs in on — the menu still,
|
|
100
|
+
the HUD mockup candidates.
|
|
89
101
|
|
|
90
102
|
**The concept anchors STYLE, not truth.** Palette, materials, light, and
|
|
91
103
|
register come from the frame; CONTENT comes from the game contract. When
|
|
@@ -291,6 +303,23 @@ function setPhase(phase: "loading" | "playing" | "paused" | "over" | "won") {
|
|
|
291
303
|
- **Buttons react**: a hover/focus state (scale, glow, or an indicator chevron)
|
|
292
304
|
plus a pressed state. Menus are keyboard-first — ↑/↓ moves focus, Enter
|
|
293
305
|
activates, and the hovered/focused item is visibly selected.
|
|
306
|
+
- **A menu action must release focus before gameplay begins.** A clicked
|
|
307
|
+
`<button>` keeps browser focus, so a later gameplay Space/Enter can natively
|
|
308
|
+
activate that same button again and repeat Play, Cancel, Leave, or Requeue.
|
|
309
|
+
Blur inside every action handler before running the action:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
function wireButton(button: HTMLButtonElement, act: () => void) {
|
|
313
|
+
button.addEventListener("click", () => {
|
|
314
|
+
button.blur();
|
|
315
|
+
act();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Scope ↑/↓/Enter menu navigation to menu/lobby phases only. When a phase
|
|
321
|
+
transition leaves a menu, also blur any focused button as a backstop:
|
|
322
|
+
`if (document.activeElement instanceof HTMLButtonElement) document.activeElement.blur();`
|
|
294
323
|
- **Values tween.** Score, coins, timers tick to their new value; health bars
|
|
295
324
|
slide. A number that teleports reads as a bug even when it's correct.
|
|
296
325
|
|
|
@@ -366,6 +395,9 @@ architecture and consume the shared style brief.
|
|
|
366
395
|
- Layout shifting as numbers grow.
|
|
367
396
|
- A fail state with no visible restart key, or a restart that reloads the page.
|
|
368
397
|
- Buttons that render but don't emit the game's real input intents.
|
|
398
|
+
- A clicked menu button remains focused after entering gameplay, so Space/Enter
|
|
399
|
+
natively activates it again; or menu keyboard navigation still runs outside
|
|
400
|
+
the menu/lobby phase.
|
|
369
401
|
- UI logic duplicating game rules and drifting out of sync.
|
|
370
402
|
- A waiting/lobby overlay that can miss its dismissal — see the multiplayer
|
|
371
403
|
skill's status-driven rule.
|
|
@@ -34,14 +34,15 @@ example, the shared-object/ball code, rotation, and host usage. Read
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
npm i @genex-ai/multiplayer@^0.10.
|
|
37
|
+
npm i @genex-ai/multiplayer@^0.10.1
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
> Pin `@^0.10.
|
|
40
|
+
> Pin `@^0.10.1` (not a bare `npm i`): live connected-player presence, supplier-form `connect()`
|
|
41
|
+
> auth, regional relay selection (`getColyseusUrls()` + `urls`)
|
|
41
42
|
> landed in 0.10; confirmed object controls, snaps, host-tick teardown, and reconnect rebasing
|
|
42
43
|
> in 0.9. An older resolve does not have those.
|
|
43
44
|
|
|
44
|
-
This skill targets `@genex-ai/multiplayer` **≥ 0.10.
|
|
45
|
+
This skill targets `@genex-ai/multiplayer` **≥ 0.10.1** (`objects`/`host` since 0.4;
|
|
45
46
|
`matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
|
|
46
47
|
since 0.8; soft ownership handoff since 0.8.4; confirmed controls, snap epochs, and host-tick
|
|
47
48
|
lifecycle guarantees since 0.9; regional relay selection via `getColyseusUrls()` since 0.10).
|
|
@@ -55,6 +56,19 @@ ownership, match seating/adjudication, and rate/size caps — but a modified cli
|
|
|
55
56
|
lie about its own position or score. Great for friends and casual lobbies; don't promise
|
|
56
57
|
ranked-grade fairness.
|
|
57
58
|
|
|
59
|
+
## Choose one net model first
|
|
60
|
+
|
|
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.
|
|
69
|
+
|
|
70
|
+
Do not combine the requirements or silently convert one model into the other.
|
|
71
|
+
|
|
58
72
|
## Matchmaking (competitive presets — server-owned)
|
|
59
73
|
|
|
60
74
|
When players should be **matched into separate capped rooms** rather than share one big room (a 1v1
|
|
@@ -64,16 +78,40 @@ HUD from `mm.matchmaking` (its `status`, `queue.position`, `players`/`opponents`
|
|
|
64
78
|
`winCondition`), and switch to the game once `session` goes live:
|
|
65
79
|
|
|
66
80
|
```ts
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
mm
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
import { matchmake, type Session } from "@genex-ai/multiplayer";
|
|
82
|
+
import {
|
|
83
|
+
waitForPlayer, getColyseusAuth, getColyseusUrls,
|
|
84
|
+
} from "@genex-ai/embed-sdk";
|
|
85
|
+
import { GENEX } from "./genex.config";
|
|
86
|
+
|
|
87
|
+
const { user } = await waitForPlayer();
|
|
88
|
+
const mm = await matchmake<MyState>({
|
|
89
|
+
urls: getColyseusUrls(),
|
|
90
|
+
room: GENEX.slug,
|
|
91
|
+
name: user.name,
|
|
92
|
+
auth: () => getColyseusAuth(), // fresh for every queue join and re-seat
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let wired: Session<MyState> | null = null;
|
|
96
|
+
function syncSession() {
|
|
97
|
+
const live = mm.session;
|
|
98
|
+
if (live === wired) return;
|
|
99
|
+
wired = live;
|
|
100
|
+
if (live) wireRoom(live); // attach leave/reconnect/disconnect + game listeners
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
mm.on("queue", (payload) => {
|
|
104
|
+
const q = payload as { position?: number; size?: number } | undefined;
|
|
105
|
+
updateQueue(q?.position ?? 0, q?.size ?? 0);
|
|
106
|
+
});
|
|
107
|
+
mm.on("matched", () => syncSession());
|
|
108
|
+
mm.on("matchStart", () => syncSession()); // duel/arena/teams only
|
|
109
|
+
mm.on("matchEnded", (result) => showResult(result)); // duel/arena/teams only
|
|
110
|
+
mm.on("error", (e) => showQueueError(e)); // after SDK retries; fix, then mm.retry()
|
|
111
|
+
|
|
112
|
+
// Every frame AND a low-rate timer: the handle swaps in a new Session after
|
|
113
|
+
// seating, terminal drop/requeue, and the next match.
|
|
114
|
+
syncSession();
|
|
77
115
|
|
|
78
116
|
// Report ONLY your own outcome — the server adjudicates. Which call fits depends on the win condition:
|
|
79
117
|
mm.eliminated(); // I'm out (lastStanding)
|
|
@@ -81,11 +119,22 @@ mm.score(1); // I scored (firstToScore / highScoreInTime)
|
|
|
81
119
|
mm.finish(); // I finished the race (firstToFinish)
|
|
82
120
|
```
|
|
83
121
|
|
|
122
|
+
For `open`, `matchStart` and `matchEnded` never fire: poll
|
|
123
|
+
`mm.matchmaking.status`, `mm.matchmaking.players`, and `mm.session` instead.
|
|
124
|
+
The event listeners above are for the batteries-included presets and do not
|
|
125
|
+
replace `syncSession()`.
|
|
126
|
+
|
|
84
127
|
Everything is **server-owned** — set once in `package.json` under `genex.matchmaking`, reported at
|
|
85
128
|
`genex preview` AND `genex publish` (removing it from package.json clears the stored config on the
|
|
86
129
|
next preview/publish); the client declares nothing. You never run matchmaking logic: the server
|
|
87
130
|
owns the queue, roles, winner-stays, forfeit, timeout, and the win condition.
|
|
88
131
|
|
|
132
|
+
If `genex.matchmaking` is absent, unavailable, or names an unknown preset, the
|
|
133
|
+
relay falls back to `duel`: rooms of exactly two with the duel round loop.
|
|
134
|
+
That is silently wrong for most 3–64 player `open` games. A `matchmake()` game
|
|
135
|
+
must declare and preview/publish the intended block; a `connect()` shared-world
|
|
136
|
+
game does not use this block.
|
|
137
|
+
|
|
89
138
|
### WHEN to call `matchmake()` — it IS the "Play Online" button, never a boot call (MANDATORY)
|
|
90
139
|
|
|
91
140
|
`matchmake()` is the ONE action that puts a player on the server: calling it enters the queue and
|
|
@@ -279,6 +328,33 @@ Two ways to present the lobby:
|
|
|
279
328
|
re-matchmaking, they're already together. The transition into the match still keys off
|
|
280
329
|
`status === 'playing'` first; the pad only sequences what happens after quorum.
|
|
281
330
|
|
|
331
|
+
#### Quorum loss after start is game-owned — enforce it independently
|
|
332
|
+
|
|
333
|
+
For `open`, `status` reaches `playing` once and NEVER regresses to `waiting`.
|
|
334
|
+
If live connectivity later falls below `minPlayers`, the relay preserves the
|
|
335
|
+
dropped player's seat during reconnection grace but deliberately leaves the
|
|
336
|
+
gameplay decision to you. `players` is the seated roster;
|
|
337
|
+
`connectedPlayers` / `session.activePlayers` excludes grace-window ghosts:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const MIN_PLAYERS = 2;
|
|
341
|
+
function enforceQuorum() {
|
|
342
|
+
syncSession();
|
|
343
|
+
const connected = mm.session?.activePlayers.size ?? 0;
|
|
344
|
+
if (phase === "playing" && connected < MIN_PLAYERS) {
|
|
345
|
+
setPhase("lobby");
|
|
346
|
+
showNotice("Opponent disconnected — waiting for them or a replacement…");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
setInterval(enforceQuorum, 250); // independent of a throttled/failed render loop
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Run the same check in the render/network pump for immediate response. The
|
|
353
|
+
250 ms watchdog is the backstop: no code path may leave a quorum-required game
|
|
354
|
+
in `playing` below its connected minimum. A shared-world `connect()` game may
|
|
355
|
+
intentionally continue solo; choose that explicitly rather than inheriting this
|
|
356
|
+
match rule.
|
|
357
|
+
|
|
282
358
|
**Private lobbies** (for `preset: 'private'`) don't use `matchmake()` — a host makes an invite code
|
|
283
359
|
and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
|
|
284
360
|
|
|
@@ -303,17 +379,17 @@ then gate `connect()` on `waitForPlayer()` — NOT `waitForAuth()`, which stays
|
|
|
303
379
|
pending for guests and would keep them out of multiplayer forever:
|
|
304
380
|
|
|
305
381
|
```ts
|
|
306
|
-
import { connect } from "@genex-ai/multiplayer";
|
|
382
|
+
import { connect, type Session } from "@genex-ai/multiplayer";
|
|
307
383
|
import { waitForPlayer, getColyseusAuth, getColyseusUrls } from "@genex-ai/embed-sdk";
|
|
308
384
|
|
|
309
385
|
type State = { x: number; z: number; q: number[] }; // YOUR per-player state (rotation as quaternion)
|
|
310
386
|
|
|
311
387
|
const { user } = await waitForPlayer(); // player gate (guest OR signed-in) — rejects only if blocked
|
|
312
|
-
|
|
388
|
+
let room = await connect<State>({
|
|
313
389
|
urls: getColyseusUrls(), // regional relays for this session (server-owned); SDK joins the fastest
|
|
314
390
|
room: GENEX.slug, // the project slug — everyone with this id shares a room
|
|
315
391
|
name: user.name, // display name — the server prefers the verified identity's name
|
|
316
|
-
auth: getColyseusAuth()
|
|
392
|
+
auth: () => getColyseusAuth(), // REQUIRED — fresh on every explicit connect attempt; NEVER log it.
|
|
317
393
|
});
|
|
318
394
|
```
|
|
319
395
|
|
|
@@ -322,7 +398,7 @@ envelope. Object-heavy rooms amplify fanout; measure the exact game at 8/16/32/6
|
|
|
322
398
|
supported count. Above the cap the relay opens another room for the same game. If the game needs one
|
|
323
399
|
seated competitive world, use matchmaking rather than one large `connect()` room.
|
|
324
400
|
|
|
325
|
-
## Disconnects
|
|
401
|
+
## Disconnects: transient reconnect is built in; terminal rejoin is yours
|
|
326
402
|
|
|
327
403
|
The SDK auto-reconnects after a network blip or brief signal loss: the relay holds your seat for a
|
|
328
404
|
grace window (~30 s). A short blip keeps the same session id, ownership, and host. If a disconnected
|
|
@@ -331,15 +407,58 @@ return to its seat but is demoted. Long reconnects rebase remote smoothing rathe
|
|
|
331
407
|
whole-map catch-up streak. Your UI still reflects connection state:
|
|
332
408
|
|
|
333
409
|
```ts
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
410
|
+
let intentionalLeave = false;
|
|
411
|
+
let rejoining = false;
|
|
412
|
+
|
|
413
|
+
function wireRoom(live: Session<State>) {
|
|
414
|
+
live.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt})`));
|
|
415
|
+
live.on("reconnected", () => hideOverlay());
|
|
416
|
+
live.on("disconnect", (code) => {
|
|
417
|
+
// 4409 means this player deliberately opened the game elsewhere. Rejoining
|
|
418
|
+
// here would evict the new tab, which would rejoin and evict this one forever.
|
|
419
|
+
if (code === 4409) {
|
|
420
|
+
flushSaves();
|
|
421
|
+
showMenu("This game is open in another tab or device.");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!intentionalLeave) void rejoinShared();
|
|
425
|
+
});
|
|
426
|
+
live.on("server:restart", () => flushSaves());
|
|
427
|
+
}
|
|
428
|
+
wireRoom(room);
|
|
429
|
+
|
|
430
|
+
async function rejoinShared() {
|
|
431
|
+
if (rejoining) return;
|
|
432
|
+
rejoining = true;
|
|
433
|
+
showOverlay("Connection lost — rejoining…");
|
|
434
|
+
try {
|
|
435
|
+
let delay = 1_000;
|
|
436
|
+
while (!intentionalLeave) {
|
|
437
|
+
try {
|
|
438
|
+
const { user } = await waitForPlayer();
|
|
439
|
+
room = await connect<State>({
|
|
440
|
+
urls: getColyseusUrls(),
|
|
441
|
+
room: GENEX.slug,
|
|
442
|
+
name: user.name,
|
|
443
|
+
auth: () => getColyseusAuth(), // fresh on EVERY attempt
|
|
444
|
+
});
|
|
445
|
+
wireRoom(room); // installs this same connection/disconnect wiring again
|
|
446
|
+
hideOverlay();
|
|
447
|
+
return;
|
|
448
|
+
} catch {
|
|
449
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
450
|
+
delay = Math.min(delay * 2, 10_000);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
rejoining = false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function leaveShared() {
|
|
459
|
+
intentionalLeave = true;
|
|
460
|
+
room.leave();
|
|
461
|
+
}
|
|
343
462
|
```
|
|
344
463
|
|
|
345
464
|
Keep your render loop running during `reconnecting` — remote players freeze briefly and then
|
|
@@ -376,8 +495,10 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
376
495
|
- `room.me.snap(state)` — respawn/teleport/mode edge. Publishes a discontinuity epoch so remotes
|
|
377
496
|
hard-reseed instead of interpolating from the old pose. Never use for ordinary movement.
|
|
378
497
|
- `room.players` — fresh `Map` each read, **includes you** (skip `id === room.id`). Each value is
|
|
379
|
-
`{ id, name, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you);
|
|
380
|
-
the raw latest (hit-tests, discrete values).
|
|
498
|
+
`{ id, name, connected, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you);
|
|
499
|
+
`stateRaw` is the raw latest (hit-tests, discrete values). A reconnect-grace seat remains in this
|
|
500
|
+
map with `connected: false`.
|
|
501
|
+
- `room.activePlayers` — the connected-only subset of `room.players`; use its size for live quorum.
|
|
381
502
|
- `room.objects` — shared objects nobody owns until claimed (a ball, an NPC):
|
|
382
503
|
- `claim(id)` — **legacy** optimistic request. It flips local ownership immediately and is corrected
|
|
383
504
|
if the relay rejects it. Keep only for reversible old-game behavior.
|
|
@@ -399,6 +520,12 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
399
520
|
- `room.isHost` / `room.host` — you are (or who is) the elected authority. Use to pick the single
|
|
400
521
|
writer of `shared` scores/rounds and the single simulator of host-owned objects. Settles within
|
|
401
522
|
the first patch after connect — read in your loop / react to `on('host')`, not once.
|
|
523
|
+
`room.host === undefined` before that patch means **authority is not ready**:
|
|
524
|
+
do not start host-owned simulation and do not synthesize an "acting host" from
|
|
525
|
+
a locally sorted roster. Clients can briefly observe different rosters, so
|
|
526
|
+
that fallback can create multiple writers. Gate host-dependent initialization
|
|
527
|
+
until `host !== undefined`; if it remains unset while connected, expose it in
|
|
528
|
+
diagnostics and re-seat/reconnect instead of inventing authority.
|
|
402
529
|
- `room.shared.get/set/keys` — key/value store (any JSON) for **slow agreed facts only**.
|
|
403
530
|
- `room.on(event, cb)` → unsubscribe fn. Events: `'join'`/`'change'` `(id, state)` (also fire for
|
|
404
531
|
you), `'leave'` `(id)`, `'shared'` `(key, value)`, `'object'` `(id)` (ownership handoff),
|
|
@@ -546,6 +673,22 @@ one neutral simulation (the host) runs the physics; everyone else sends **inputs
|
|
|
546
673
|
Both patterns — the claim-on-touch Rapier proxy and the host-authoritative contest, plus surviving host
|
|
547
674
|
migration and wiring the vendored controllers — are in [references/host-physics.md](references/host-physics.md).
|
|
548
675
|
|
|
676
|
+
## Production supportability floor
|
|
677
|
+
|
|
678
|
+
Every multiplayer build ships three small, token-free diagnostics:
|
|
679
|
+
|
|
680
|
+
1. A `BUILD` string, bumped for every preview/publish, shown in a quiet
|
|
681
|
+
menu/lobby corner, printed once to the console, and exposed on a read-only
|
|
682
|
+
game debug object.
|
|
683
|
+
2. A 250 ms status line containing only:
|
|
684
|
+
`build · seated/connected players · phase · host · matchmaking status · last transition/cause · last network error`.
|
|
685
|
+
Keep it unobtrusive and never include embed auth, URLs containing credentials,
|
|
686
|
+
player tokens, or arbitrary server payloads.
|
|
687
|
+
3. The independent connected-quorum watchdog above for games that cannot play solo.
|
|
688
|
+
|
|
689
|
+
These are production supportability, not a hidden test mode: a screenshot must
|
|
690
|
+
identify the build and network state without changing game behavior.
|
|
691
|
+
|
|
549
692
|
## Smoothness is felt, not seen — hand the feel to a human
|
|
550
693
|
|
|
551
694
|
Lag and stutter are *motion over time*. A screenshot is one frozen instant, so **you cannot tell
|
|
@@ -633,8 +776,11 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
633
776
|
|
|
634
777
|
## Checklist
|
|
635
778
|
|
|
636
|
-
- [ ] `npm i @genex-ai/multiplayer@^0.10.
|
|
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.
|
|
637
780
|
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
781
|
+
- [ ] `connect()` terminal `disconnect` starts a guarded backoff rejoin that reruns
|
|
782
|
+
`waitForPlayer()` and reads fresh auth on every attempt; deliberate leave stops it;
|
|
783
|
+
replacement code `4409` NEVER auto-rejoins.
|
|
638
784
|
- [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
|
|
639
785
|
- [ ] 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.
|
|
640
786
|
- [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
|
|
@@ -647,7 +793,7 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
647
793
|
stable embed `uid`, never the session id.
|
|
648
794
|
- [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
|
|
649
795
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
650
|
-
hang) and passes `auth: getColyseusAuth()
|
|
796
|
+
hang) and passes `auth: () => getColyseusAuth()` (the relay rejects tokenless joins —
|
|
651
797
|
see `genex-threejs-embed-auth`).
|
|
652
798
|
- [ ] `room` is the **project slug**.
|
|
653
799
|
- [ ] `me.set` on a fixed **10–20 Hz** tick; full object each time.
|
|
@@ -658,13 +804,27 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
658
804
|
- [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
|
|
659
805
|
- [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
|
|
660
806
|
- [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
|
|
807
|
+
- [ ] Host-owned work waits for `room.host !== undefined`; no client-invented acting host.
|
|
661
808
|
- [ ] `matchmake()` fires ONLY on the "Play Online" commit, never on page load / in boot code — the
|
|
662
809
|
menu runs an offline world with NO relay contact; "Bots"/"Local" never call it; leaving online
|
|
663
810
|
calls `mm.cancel()`.
|
|
811
|
+
- [ ] `matchmake()` polls `mm.session` and rebinds when it changes after seating or re-seat;
|
|
812
|
+
function-form auth is used.
|
|
664
813
|
- [ ] Waiting room (if any): shown only AFTER "Play Online" (seated) and while under `minPlayers`;
|
|
665
814
|
overlay driven by `mm.matchmaking.status` read every frame, gone the moment it flips to
|
|
666
815
|
`'playing'` — and you WATCHED it close in two browser windows at `minPlayers` (never gated on
|
|
667
816
|
`matchStart` or a host `shared` signal alone).
|
|
817
|
+
- [ ] Every menu/lobby action button blurs before acting, and menu Enter/arrow handling runs only
|
|
818
|
+
in menu/lobby phases.
|
|
819
|
+
- [ ] Multiplayer was exercised by two DISTINCT identities using real clicks and real key presses.
|
|
820
|
+
After clicking Play/Find Match, Space/Enter gameplay input does not repeat that menu action;
|
|
821
|
+
scripted `element.click()` is not evidence for this focus path.
|
|
822
|
+
- [ ] For a quorum-required matchmade game: close one client mid-match, watch connected quorum
|
|
823
|
+
(`activePlayers` / `connectedPlayers`) leave `playing`, then join a new distinct client and
|
|
824
|
+
watch it re-seat. For a shared-world `connect()` game, verify leave/host migration according
|
|
825
|
+
to that game's design instead of imposing a lobby.
|
|
826
|
+
- [ ] Build id is visible in menu/lobby, logged once, and exposed with token-free 250 ms network
|
|
827
|
+
telemetry; quorum-required games have the independent connected-quorum watchdog.
|
|
668
828
|
- [ ] Team game: the HOST reconciles the balanced `id → team` map into `shared` (leavers dropped,
|
|
669
829
|
newcomers to the smallest team); every client READS its team from `shared` — never computed
|
|
670
830
|
per-client, never `mm.matchmaking.teams`, never a default for the unassigned — and you
|
|
@@ -674,8 +834,8 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
674
834
|
## Troubleshooting auth
|
|
675
835
|
|
|
676
836
|
- **`connect()` rejects with 401/403** — 401 "auth required"/"invalid token": you joined
|
|
677
|
-
without `auth
|
|
678
|
-
read `getColyseusAuth()` fresh at every connect
|
|
837
|
+
without `auth`, before `waitForPlayer()` resolved, or reused a stale token for a NEW terminal
|
|
838
|
+
rejoin — read `getColyseusAuth()` fresh at every connect. 403 "wrong game": the `room` value
|
|
679
839
|
doesn't match this game's own slug. 403 "guest capacity": the room is at its guest
|
|
680
840
|
limit — signing in gets the player a seat; surface the message as-is.
|
|
681
841
|
- **`disconnect` fired and the player wants back in** — the old session is dead; run your
|
|
@@ -191,6 +191,9 @@ fourth who never comes.
|
|
|
191
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`. |
|
|
192
192
|
|
|
193
193
|
**Decisions:**
|
|
194
|
+
- **Every menu action blurs its button before acting.** Otherwise the first gameplay Space/Enter
|
|
195
|
+
can natively activate the still-focused Play/Leave button again; scope menu keyboard handlers to
|
|
196
|
+
menu/lobby phases (see the game-ui skill).
|
|
194
197
|
- **`matchmake()` is created lazily, on the click — not held from boot.** Keep the handle in a
|
|
195
198
|
variable so you can `cancel()` it; create it inside the "Play Online" handler, not at module load.
|
|
196
199
|
- **Bots/Local touch nothing networked.** They run the exact offline world the menu already booted.
|
|
@@ -208,15 +211,35 @@ fourth who never comes.
|
|
|
208
211
|
|
|
209
212
|
```ts
|
|
210
213
|
let mm = null; // no relay contact yet — we're on the menu
|
|
214
|
+
let wired = null;
|
|
211
215
|
bootOfflineWorld(); // local world under the menu overlay
|
|
212
|
-
await waitForPlayer();
|
|
216
|
+
const { user } = await waitForPlayer(); // identity token only; seats nobody
|
|
217
|
+
|
|
218
|
+
function syncSession() {
|
|
219
|
+
const live = mm?.session ?? null;
|
|
220
|
+
if (live === wired) return;
|
|
221
|
+
wired = live;
|
|
222
|
+
if (live) wireRoom(live);
|
|
223
|
+
}
|
|
213
224
|
|
|
214
225
|
onClick("play-online", async () => {
|
|
215
|
-
mm = await matchmake({
|
|
226
|
+
mm = await matchmake({
|
|
227
|
+
urls: getColyseusUrls(),
|
|
228
|
+
room: GENEX.slug,
|
|
229
|
+
name: user.name,
|
|
230
|
+
auth: () => getColyseusAuth(),
|
|
231
|
+
});
|
|
232
|
+
mm.on("matched", syncSession);
|
|
233
|
+
mm.on("matchStart", syncSession); // preset-only; `open` never emits it
|
|
234
|
+
mm.on("queue", updateQueue);
|
|
235
|
+
mm.on("error", showQueueError);
|
|
216
236
|
showWaitingOverlay(); // driven by mm.matchmaking.status, per SKILL.md
|
|
217
237
|
});
|
|
218
238
|
onClick("play-bots", () => startBots()); // offline; mm stays null
|
|
219
239
|
onClick("leave-online", () => { mm?.cancel(); mm = null; returnToMenu(); });
|
|
240
|
+
// Poll every frame or on the independent 250ms network tick: a re-seat installs
|
|
241
|
+
// a NEW Session object; the old one is never mutated back to life.
|
|
242
|
+
syncSession();
|
|
220
243
|
```
|
|
221
244
|
|
|
222
245
|
**Acceptance feel:** a player who picks Bots never appears in anyone's online room; the online
|
|
@@ -30,7 +30,7 @@ const room = await connect<S>({
|
|
|
30
30
|
urls: getColyseusUrls(),
|
|
31
31
|
room: GENEX.slug,
|
|
32
32
|
name: user.name,
|
|
33
|
-
auth: getColyseusAuth()
|
|
33
|
+
auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect; NEVER log it.
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
// --- local player: input mutates this; we render yourself from it (zero latency) ---
|
|
@@ -67,11 +67,21 @@ direction in the same plan block — the camera rig + pointer bucket
|
|
|
67
67
|
(`$genex-threejs-camera-direction`), the renderer baseline (tone mapping,
|
|
68
68
|
exposure, output color space — set deliberately at boot; stock three.js
|
|
69
69
|
defaults are not a look, `$genex-threejs-exposure-color-grading`), the post
|
|
70
|
-
stack this game ships
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
stack this game ships — read off the concept frame's OWN look (the grade, bloom
|
|
71
|
+
level, haze, grain it already shows) and which post the 2–3 AAA references lean
|
|
72
|
+
on, not a default single bloom; ONE built render-pass effect is the FLOOR
|
|
73
|
+
against no-post for EVERY game, never the target — ship the richness the concept
|
|
74
|
+
implies (the UI vignette div or a CSS canvas filter does not count;
|
|
75
|
+
`$genex-threejs-image-pipeline` owns ordering when 2+ compose), a decision for
|
|
76
|
+
**every primitive surface** the game builds — walls, barriers, kerbs and
|
|
77
|
+
platforms each get a real texture or a **shader** where that surface wants motion
|
|
78
|
+
or energy (a pulsing force barrier, an electric fence, a scrolling hazard strip),
|
|
79
|
+
judged one by one: a shader that gives a barrier life earns its place, the same
|
|
80
|
+
effect everywhere disfigures, deliberate flat black is valid if you say so — but
|
|
81
|
+
a flat-colour box beside textured geometry is the "stopped halfway" tell
|
|
82
|
+
(`$genex-threejs-procedural-materials` / `$genex-threejs-procedural-vfx`, and SEE
|
|
83
|
+
it in the running game), one named
|
|
84
|
+
**ambient-motion loop** that keeps
|
|
75
85
|
the scene alive at rest (emissive pulse, shimmer, drifting dust — shader
|
|
76
86
|
work, zero generations), and the lighting/atmosphere mood from that
|
|
77
87
|
same brief. Planning is not building — effects still land last in the
|
|
@@ -80,8 +90,12 @@ execution order.
|
|
|
80
90
|
**Multiplayer is mandatory routing:** if the game has 2+ players sharing a world, loading
|
|
81
91
|
`$genex-threejs-multiplayer` is **required** before any networking code — the SDK auto-smooths
|
|
82
92
|
remote players **and shared objects**, and gives you server-enforced object ownership (a ball) and
|
|
83
|
-
a room `host` (scores, enemies).
|
|
84
|
-
|
|
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).
|
|
85
99
|
|
|
86
100
|
## Real (AI-generated) assets — `npx genex` commands
|
|
87
101
|
|
|
@@ -41,13 +41,19 @@ Three.js release or branch, and do not blindly copy demo architecture.
|
|
|
41
41
|
- **renderer baseline**: tone mapping, exposure, and output color space set
|
|
42
42
|
deliberately at boot (`$genex-threejs-exposure-color-grading` owns the
|
|
43
43
|
staging — stock three.js defaults are not a look);
|
|
44
|
-
- **the post stack**:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
- **the post stack**: read the look off your evidence — don't default to a
|
|
45
|
+
single bloom. TWO sources drive it: (a) the CONCEPT FRAME — a rendered
|
|
46
|
+
image that already carries a grade, a bloom level, haze/DoF, maybe grain or
|
|
47
|
+
aberration; name what it actually shows and reproduce THAT; (b) the 2–3 AAA
|
|
48
|
+
references — name which post each one leans on (racing: motion blur + heat
|
|
49
|
+
haze; grounded shooter: restrained bloom + film grain + faint aberration;
|
|
50
|
+
clean sci-fi: crisp bloom + strong grade). Ship the stack that evidence
|
|
51
|
+
calls for, each effect named and justified. ONE scene-serving render-pass
|
|
52
|
+
effect (bloom, AO, or a LUT/shader grade — a UI vignette div or a CSS
|
|
53
|
+
canvas filter does NOT count) is the FLOOR against "no post", never the
|
|
54
|
+
target: match the concept's richness, don't stop at one token pass. "No
|
|
55
|
+
post at all" is the stock default, not a plan, and "it's only a draft" is
|
|
56
|
+
not a lower floor. When 2+ effects compose, `$genex-threejs-image-pipeline`
|
|
51
57
|
owns the pass ordering;
|
|
52
58
|
- **references**: name 2–3 AAA games whose look this game borrows
|
|
53
59
|
(conventions, lighting mood, palette, post — never trade dress); the
|
|
@@ -59,6 +65,21 @@ Three.js release or branch, and do not blindly copy demo architecture.
|
|
|
59
65
|
drifting dust, a slowly flowing texture. Shader/procedural, zero
|
|
60
66
|
generations, built with the scene — a world that is perfectly still
|
|
61
67
|
reads as a screenshot, not a place;
|
|
68
|
+
- **every primitive surface — texture it or shade it, decided one by one**:
|
|
69
|
+
the ground is never the only surface. Walk the walls, barriers, kerbs,
|
|
70
|
+
platforms and props the game BUILDS out of primitives, and give each a real
|
|
71
|
+
material: a generated texture, or a **shader** where that surface genuinely
|
|
72
|
+
wants motion or energy — a force barrier that pulses and refracts, an
|
|
73
|
+
electric fence, a scrolling hazard strip, an emissive seam that breathes.
|
|
74
|
+
Judge surface by surface: a shader that gives a blocking barrier life earns
|
|
75
|
+
its place; the same effect smeared over everything disfigures the scene.
|
|
76
|
+
Deliberate flat black IS a valid answer when the look calls for it — say so
|
|
77
|
+
in one line. What is never valid is not deciding: a flat-colour box standing
|
|
78
|
+
next to textured geometry is the "stopped halfway" tell, and it is what
|
|
79
|
+
ships when this bullet is skipped. `$genex-threejs-procedural-materials`
|
|
80
|
+
and `$genex-threejs-procedural-vfx` own the craft. Whatever you apply, SEE
|
|
81
|
+
it in the running game before calling it done — an unverified shader
|
|
82
|
+
disfigures as easily as it delights;
|
|
62
83
|
- **lighting/atmosphere mood** from the SAME shared style brief the UI gate
|
|
63
84
|
wrote — one art direction across scene and UI.
|
|
64
85
|
Planning is not building: effects still land LAST (steps 10–11); this step
|