Racer count is a numeric input 1-9999 on the title screen (arrows still nudge; typing wins). Making that range REAL: shim marble cap 10000 with ph_states batched readback (one wasm call per step for the whole pack); per-racer persistent state scratch (no per-step allocation); progress-sorted neighbor windows replace the O(N^2) rival scan; decision decimation (control decisions recompute round-robin every K=1/2/4/8 steps by pack size while the cached force applies every step); kit-mesh visuals up to 32 peers, one InstancedMesh with per-instance color beyond (camera/trail follow instanced subjects via a proxy); grid-wrapped avalanche spawn (stations x 3 lanes x stacked tiers) and lane-fanned tail respawns. Measured: N=200 17x realtime, N=2000 1.7x, N=9999 correct at ~0.27x (slow-mo at the extreme; the 10k-body physics step is the wall). Race telemetry caps at top-12. Marble phenotype (user round): racerIdentity gains SHELL (cosine-palette per-channel color multiplier) and EFFECT (gloss/pearl/ember/ghost x intensity) as two additional MULTIPLICATIVE expressions over the base mesh+tint, appended draws (older identities byte-stable). Kit path reshapes materials (ember owns a baseline emissive that boost-glow flameout restores); instanced path folds the phenotype into instance color. document.title = 'marbles - #seed' (readable history entries). Verified in-browser: menu numeric 200 -> n=200 racing pack with varied shells through tunnels; 2000 and 9999 boot and race with 0 console errors, bounded scene nodes. 115 tests green.
- commit
- e55858697dd1029d3eb507d3adfa48b08231972b
- parent
- c4fefc24823b3f11127aac041b58d4ad2bcf93ab
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 26 | ++ |
| M | src/ | 480 | ++++++++++++++++++++++-------- |
| M | src/ | 28 | +- |
| M | src/ | 56 | +++- |
| M | src/ | 20 | + |
| M | src/ | 30 | ++- |
| M | src/ | 2 | + |
| M | tools/ | 2 | +- |
@@ -1252,6 +1252,32 @@ always writes an explicit endless= param; choices persist in
12521252 localStorage; the HUD "menu" button returns via ?menu=1 keeping seed +12531253 settings.12541254 1255+PACK SCALE (user: racer count is a NUMERIC input 1–9999): shim1256+MAX_MARBLES 10000 + ph_states batched readback (one wasm call/step);1257+per-racer persistent pos/vel/quat scratch (no per-step allocation);1258+progress-SORTED neighbor windows for gaps + separation (O(N log N));1259+DECISION DECIMATION — control decisions recompute round-robin every K1260+steps (K = 1/2/4/8 at ≤64/≤256/≤1024/beyond) while the cached force1261+applies every step; peers ≤32 keep kit meshes + materials, beyond that1262+ONE InstancedMesh sphere with per-instance color (camera/trail use a1263+proxy Object3D for instanced subjects); grid-wrapped avalanche spawn1264+(stations × 3 lanes × stacked tiers); tail respawns fan across lanes.1265+Measured: N=200 17× realtime, N=2000 1.7×, N=9999 runs correctly at1266+~0.27× (slow-mo spectacle; Box3D step is the wall). raceStats caps its1267+list at top-12 (plus n/leader). document.title = "marbles - #seed"1268+(readable history entries, set in seed.ts emit).1269+1270+MARBLE PHENOTYPE (user: "tint the outside and effects as two additional1271+multiplicative phenotype expressions"): racerIdentity (peersCore.ts) →1272+RacerLook gains SHELL (per-channel cosine-palette color multiplier,1273+strength ≤0.35) and EFFECT ({gloss|pearl|ember|ghost|none} × intensity1274+0.4–1) as appended draws — layered MULTIPLICATIVELY over the base1275+mesh+tint. Kit-mesh path: shell multiplies color; gloss cuts roughness/1276+raises metalness, pearl lifts toward white at low roughness, ember sets1277+emissive (boost-glow flameout RESTORES the ember baseline, not black),1278+ghost goes transparent. Instanced path folds phenotype into instance1279+color (brighten/lift/darken approximations).1280+12551281 REMAINING / NEXT IDEAS: marble-marble collision-immunity window on12561282 respawn (shim filter bits — deferred from M15); rubber-banding via pilot12571283 skill params (never thrust — Melder); race HUD (leaderboard strip, name@@ -25,6 +25,7 @@ import { TRACK_SCALE } from "../gen/endless/scale.ts";
2525 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";2626 import {2727 FIXED_DT,28+ MARBLE_RADIUS,2829 MARBLE_SCALE,2930 PhysicsEngine,3031 type PhysicsModule,@@ -82,6 +83,10 @@ const ICE_PIPE: readonly [number, number, number] = [178, 224, 240];
8283 /** Violet-charged pipe recolor for gravity-rotation zones. */8384 const GRAVITY_PIPE: readonly [number, number, number] = [186, 148, 255];8485 86+/** Peer count above which peers render as one InstancedMesh (per-racer87+ * kit meshes + cloned materials would swamp the scene). */88+const INSTANCED_AT = 32;89+8590 // Marble feel — same constants as the finite loop (marble.ts).8691 const MARBLE_PIECE = "marble-center-high";8792 const BASE_SPEED = 2.2;@@ -273,8 +278,10 @@ interface Racer {
273278 idx: number;274279 pilot: Pilot;275280 mass: number;276- obj: THREE.Object3D;277- pivot: THREE.Object3D;281+ /** Scene objects — null for instanced peers (big packs render through282+ * one InstancedMesh instead of per-racer groups). */283+ obj: THREE.Object3D | null;284+ pivot: THREE.Object3D | null;278285 materials: THREE.Material[];279286 nearest: { seg: number; idx: number };280287 s: number;@@ -289,6 +296,22 @@ interface Racer {
289296 wasBoosting: boolean;290297 /** Last force step's introspection — headroom feeds the boost law. */291298 dbg: PilotDebug;299+ /** Persistent state scratch (filled from the batched readback each300+ * step — no per-step allocation at pack scale). */301+ pos: [number, number, number];302+ vel: [number, number, number];303+ quat: [number, number, number, number];304+ /** Cached control force between decision steps (large packs decimate305+ * the DECISION rate round-robin; the force still applies every step). */306+ cachedF: [number, number, number] | null;307+ /** Ember phenotype baseline — boost-glow flameout restores it. */308+ baseEmissive: THREE.Color | null;309+}310+311+/** Reusable neighbor slots (separation steering) — mutated in place. */312+interface NeighborSlot {313+ pos: [number, number, number];314+ bravery: number;292315 }293316 294317 class Session {@@ -342,6 +365,22 @@ class Session {
342365 private followedPivot: THREE.Object3D;343366 /** Racer idx the camera currently follows (leader-cam hysteresis). */344367 private followedIdx = 0;368+ /** Progress-sorted racer order (rebuilt per fixed step, reused). */369+ private readonly sorted: Racer[] = [];370+ /** Separation-partner scratch (reused per decision). */371+ private readonly othersScratch: Neighbor[] = [];372+ private stepCounter = 0;373+ /** Instanced peer rendering (packs > INSTANCED_AT): one mesh. */374+ private peerMesh: THREE.InstancedMesh | null = null;375+ private readonly peerColors: THREE.Color[] = [];376+ /** Camera/trail stand-in when the followed racer is instanced. */377+ private readonly instProxy = new THREE.Object3D();378+ private readonly instMatrix = new THREE.Matrix4();379+ private readonly instPos = new THREE.Vector3();380+ private readonly instQuat = new THREE.Quaternion();381+ private readonly instScale = new THREE.Vector3(1, 1, 1);382+ private readonly instGlow = new THREE.Color();383+ private readonly glowBlue = new THREE.Color(0x66aaff);345384 private readonly rails = new Map<number, Rail>();346385 /** Active diamond branch per segment — respawns alternate it, so a347386 * fall's retry may ride the OTHER branch (respawn variety). */@@ -454,9 +493,9 @@ class Session {
454493 const peersParam = new URLSearchParams(location.search).get("peers");455494 const peerCount = peersParam === null456495 ? undefined457- : Math.max(0, Math.min(24, Number.parseInt(peersParam, 10) || 0));496+ : Math.max(0, Math.min(9999, Number.parseInt(peersParam, 10) || 0));458497 const realRace = this.engine !== null && this.useAutopilot;459- this.realPeerCount = realRace ? Math.min(15, peerCount ?? 5) : 0;498+ this.realPeerCount = realRace ? Math.min(9999, peerCount ?? 5) : 0;460499 this.peerRing = new PeerRing(461500 seed,462501 assets,@@ -495,20 +534,76 @@ class Session {
495534 boost: { ...FULL_TANK },496535 wasBoosting: false,497536 dbg: {},537+ pos: [0, 0, 0],538+ vel: [0, 0, 0],539+ quat: [0, 0, 0, 1],540+ cachedF: null,541+ baseEmissive: null,498542 });499- // Racer peers (M15): real bodies staggered ahead of the hero on the500- // first straight, alternating lanes so the grid start reads.543+ // Racer peers (M15): real bodies on a wrapped grid start —544+ // stations × 3 lanes, then stacked tiers above (a huge pack rains545+ // in as a marble avalanche). Kit meshes up to INSTANCED_AT peers;546+ // one InstancedMesh beyond.547+ const instanced = this.realPeerCount > INSTANCED_AT;548+ if (instanced) {549+ const geo = new THREE.SphereGeometry(MARBLE_RADIUS, 12, 8);550+ const mat = new THREE.MeshStandardMaterial({551+ roughness: 0.35,552+ metalness: 0.05,553+ });554+ this.peerMesh = new THREE.InstancedMesh(geo, mat, this.realPeerCount);555+ this.peerMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);556+ this.peerMesh.frustumCulled = false;557+ ctx.scene.add(this.peerMesh);558+ }559+ const span = Math.max(2, Math.min(rail.pts.length - 2, 120));560+ const LANES = [-0.26, 0, 0.26];501561 for (let i = 1; i <= this.realPeerCount; i++) {502- const at = Math.min(rail.pts.length - 2, i * 2);562+ const at = 1 + ((i - 1) % span);563+ const lane = LANES[i % LANES.length];564+ const tier = Math.floor((i - 1) / (span * LANES.length));503565 const tt = rail.tans[at];504566 const nl = Math.hypot(tt[2], tt[0]) || 1;505- const lat = (i % 2 === 0 ? 1 : -1) * 0.22;506567 this.engine.spawnMarble(i, [507- rail.pts[at][0] + (tt[2] / nl) * lat,508- rail.pts[at][1] + 0.4,509- rail.pts[at][2] + (-tt[0] / nl) * lat,568+ rail.pts[at][0] + (tt[2] / nl) * lane,569+ rail.pts[at][1] + 0.4 + tier * 0.35,570+ rail.pts[at][2] + (-tt[0] / nl) * lane,510571 ], [tt[0] * 2.2, 0, tt[2] * 2.2]);511- const { obj, pivot, materials } = this.buildRacerVisual(i);572+ let obj: THREE.Group | null = null;573+ let pivot: THREE.Group | null = null;574+ let materials: THREE.Material[] = [];575+ let baseEmissive: THREE.Color | null = null;576+ if (!instanced) {577+ ({ obj, pivot, materials, baseEmissive } = this.buildRacerVisual(i));578+ } else {579+ // Instanced path: the phenotype folds into the instance color580+ // (one material — shell multiplies, effects approximate).581+ const identity = racerIdentity(seed, i);582+ const color = identity.tint583+ ? new THREE.Color(584+ identity.tint[0] / 255,585+ identity.tint[1] / 255,586+ identity.tint[2] / 255,587+ )588+ : new THREE.Color(0xdfe8f2);589+ color.multiply(590+ new THREE.Color(591+ identity.shell[0],592+ identity.shell[1],593+ identity.shell[2],594+ ),595+ );596+ const ei = identity.effect.intensity;597+ if (identity.effect.kind === "ember") {598+ color.multiplyScalar(1 + 0.5 * ei);599+ } else if (identity.effect.kind === "pearl") {600+ color.lerp(new THREE.Color(1, 1, 1), 0.3 * ei);601+ } else if (identity.effect.kind === "ghost") {602+ color.multiplyScalar(0.55);603+ }604+ this.peerColors.push(color);605+ this.peerMesh!.setColorAt(i - 1, color);606+ }512607 this.racers.push({513608 idx: i,514609 pilot: pilotFor(seed, i),@@ -527,8 +622,16 @@ class Session {
527622 boost: { ...FULL_TANK },528623 wasBoosting: false,529624 dbg: {},625+ pos: [0, 0, 0],626+ vel: [0, 0, 0],627+ quat: [0, 0, 0, 1],628+ cachedF: null,629+ baseEmissive,530630 });531631 }632+ if (this.peerMesh?.instanceColor) {633+ this.peerMesh.instanceColor.needsUpdate = true;634+ }532635 }533636 534637 this.cameraProxy = {@@ -562,11 +665,12 @@ class Session {
562665 if (this.racers.length === 0) return null;563666 let leader = this.racers[0];564667 for (const r of this.racers) if (r.s > leader.s) leader = r;668+ const top = [...this.racers].sort((a, b) => b.s - a.s).slice(0, 12);565669 return {566670 n: this.racers.length,567671 leader: leader.idx,568672 leaderCam: this.leaderCam,569- racers: this.racers.map((r) => ({673+ racers: top.map((r) => ({570674 idx: r.idx,571675 s: Number(r.s.toFixed(1)),572676 speed: Number(r.speed.toFixed(2)),@@ -871,101 +975,135 @@ class Session {
871975 }872976 }873977 978+ /** One control decision for a racer (called at the decimated cadence):979+ * nearest station, boost economy, autopilot force, jump — the result980+ * lands in r.cachedF. `K` is the decision period in fixed steps. */981+ private decide(r: Racer, K: number): void {982+ const engine = this.engine!;983+ this.updateNearestFor(r, r.pos);984+ const rail = this.rails.get(r.nearest.seg);985+ if (!rail) {986+ r.cachedF = null;987+ return;988+ }989+ engine.setRestitution(r.idx, stationRestitution(rail, r.nearest.idx));990+ const nextRail = this.rails.get(r.nearest.seg + 1) ?? null;991+ let f: [number, number, number] | null;992+ if (this.useAutopilot) {993+ r.simT += FIXED_DT * K;994+ r.pilot.offset = pilotOffset(r.pilot, r.simT);995+ // Rank-window rivals: nearest distinct racer ahead/behind, and up996+ // to four separation partners within 2 units of progress.997+ const k0 = this.sorted.indexOf(r);998+ let gapAhead: number | null = null;999+ let gapBehind: number | null = null;1000+ this.othersScratch.length = 0;1001+ for (let k = k0 + 1; k < this.sorted.length && k <= k0 + 3; k++) {1002+ const o = this.sorted[k];1003+ const d = o.s - r.s;1004+ if (gapAhead === null && d > 0.05) gapAhead = d;1005+ if (d < 2 && this.othersScratch.length < 4) {1006+ this.othersScratch.push({ pos: o.pos, bravery: o.pilot.bravery });1007+ }1008+ }1009+ for (let k = k0 - 1; k >= 0 && k >= k0 - 3; k--) {1010+ const o = this.sorted[k];1011+ const d = r.s - o.s;1012+ if (gapBehind === null && d > 0.05) gapBehind = d;1013+ if (d < 2 && this.othersScratch.length < 4) {1014+ this.othersScratch.push({ pos: o.pos, bravery: o.pilot.bravery });1015+ }1016+ }1017+ const headroom = r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined1018+ ? r.dbg.vSafe - r.dbg.wishspeed1019+ : null;1020+ r.boost = boostStep(1021+ r.boost,1022+ r.pilot,1023+ FIXED_DT * K,1024+ headroom,1025+ gapAhead,1026+ gapBehind,1027+ );1028+ f = autopilotForce(1029+ rail,1030+ r.nearest.idx,1031+ r.pos,1032+ r.vel,1033+ r.mass,1034+ nextRail,1035+ r.pilot,1036+ r.dbg,1037+ this.othersScratch,1038+ r.boost.boosting,1039+ );1040+ const j = wantJump(rail, r.nearest.idx, r.pos, r.vel, nextRail, r.pilot);1041+ if (j) {1042+ engine.jump(r.idx, j[0], j[1]);1043+ if (!r.wasHop && r.idx === 0) this.audio.cue("launch");1044+ r.wasHop = true;1045+ } else r.wasHop = false;1046+ } else {1047+ f = stationForce(1048+ rail,1049+ r.nearest.idx,1050+ r.pos,1051+ r.vel,1052+ r.mass,1053+ nextRail,1054+ );1055+ }1056+ // Gravity zones as per-marble delta forces (world gravity is global1057+ // and fixed — one vector cannot serve N marbles).1058+ const gd = stationGravityDelta(rail, r.nearest.idx, r.mass);1059+ const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);1060+ const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);1061+ const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);1062+ r.cachedF = fx !== 0 || fy !== 0 || fz !== 0 ? [fx, fy, fz] : null;1063+ }1064+8741065 /** Box3D driver (M15): N real marbles — the hero plus racer peers —8751066 * each running its own autopilot; real falls, per-marble respawn,8761067 * leader-cam. */8771068 private physicsMove(dt: number): void {8781069 const engine = this.engine!;1070+ const n = this.racers.length;1071+ // Pack-scale controls: DECISIONS (nearest scan, sweep, boost, jump)1072+ // recompute round-robin every K steps while the cached force applies1073+ // every step — feel is preserved, CPU stays flat as N grows.1074+ const K = n <= 64 ? 1 : n <= 256 ? 2 : n <= 1024 ? 4 : 8;8791075 engine.step(dt, () => {880- // One state read per racer per fixed step, shared by all pilots881- // (separation steering needs everyone's positions).882- const states = this.racers.map((r) => engine.state(r.idx));883- for (const [ri, r] of this.racers.entries()) {884- const st = states[ri];885- this.updateNearestFor(r, st.pos);886- const rail = this.rails.get(r.nearest.seg);887- if (!rail) continue;888- engine.setRestitution(889- r.idx,890- stationRestitution(rail, r.nearest.idx),891- );892- const nextRail = this.rails.get(r.nearest.seg + 1) ?? null;893- let f: [number, number, number] | null;894- if (this.useAutopilot) {895- r.simT += FIXED_DT;896- r.pilot.offset = pilotOffset(r.pilot, r.simT);897- const others: Neighbor[] = [];898- let gapAhead: number | null = null;899- let gapBehind: number | null = null;900- for (const [oi, o] of this.racers.entries()) {901- if (o === r) continue;902- others.push({ pos: states[oi].pos, bravery: o.pilot.bravery });903- const d = o.s - r.s;904- if (d > 0.05) {905- gapAhead = gapAhead === null ? d : Math.min(gapAhead, d);906- } else if (d < -0.05) {907- gapBehind = gapBehind === null ? -d : Math.min(gapBehind, -d);908- }909- }910- // The boost economy: headroom from the LAST force step's911- // introspection (1 fixed step of lag, fully deterministic).912- const headroom =913- r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined914- ? r.dbg.vSafe - r.dbg.wishspeed915- : null;916- r.boost = boostStep(917- r.boost,918- r.pilot,919- FIXED_DT,920- headroom,921- gapAhead,922- gapBehind,923- );924- f = autopilotForce(925- rail,926- r.nearest.idx,927- st.pos,928- st.vel,929- r.mass,930- nextRail,931- r.pilot,932- r.dbg,933- others,934- r.boost.boosting,935- );936- } else {937- f = stationForce(938- rail,939- r.nearest.idx,940- st.pos,941- st.vel,942- r.mass,943- nextRail,944- );1076+ // ONE wasm call for the whole pack, copied into persistent1077+ // per-racer scratch (no per-step allocation).1078+ const flat = engine.statesAll(n);1079+ for (let i = 0; i < n; i++) {1080+ const r = this.racers[i];1081+ const b = i * 10;1082+ r.pos[0] = flat[b];1083+ r.pos[1] = flat[b + 1];1084+ r.pos[2] = flat[b + 2];1085+ r.vel[0] = flat[b + 3];1086+ r.vel[1] = flat[b + 4];1087+ r.vel[2] = flat[b + 5];1088+ r.quat[0] = flat[b + 6];1089+ r.quat[1] = flat[b + 7];1090+ r.quat[2] = flat[b + 8];1091+ r.quat[3] = flat[b + 9];1092+ }1093+ // Progress-sorted order: rival gaps + separation partners come1094+ // from rank neighbors — O(N log N), not O(N²).1095+ this.sorted.length = 0;1096+ for (const r of this.racers) this.sorted.push(r);1097+ this.sorted.sort((a, b2) => a.s - b2.s);1098+ this.stepCounter++;1099+ for (let i = 0; i < n; i++) {1100+ const r = this.racers[i];1101+ if ((this.stepCounter + i) % K === 0) {1102+ this.decide(r, K);9451103 }946- // Gravity zones as per-marble delta forces (world gravity is947- // global and fixed — one vector cannot serve N marbles).948- const gd = stationGravityDelta(rail, r.nearest.idx, r.mass);949- const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);950- const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);951- const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);952- if (fx !== 0 || fy !== 0 || fz !== 0) engine.force(r.idx, fx, fy, fz);953- // The jump decision (M14b, autopilot only): top-up at a gap lip954- // when the actual approach is too slow for the priced arc.955- if (this.useAutopilot) {956- const j = wantJump(957- rail,958- r.nearest.idx,959- st.pos,960- st.vel,961- nextRail,962- r.pilot,963- );964- if (j) {965- engine.jump(r.idx, j[0], j[1]);966- if (!r.wasHop && r.idx === 0) this.audio.cue("launch");967- r.wasHop = true;968- } else r.wasHop = false;1104+ const cf = r.cachedF;1105+ if (cf && (cf[0] !== 0 || cf[1] !== 0 || cf[2] !== 0)) {1106+ engine.force(r.idx, cf[0], cf[1], cf[2]);9691107 }9701108 }9711109 });@@ -978,26 +1116,64 @@ class Session {
9781116 const windowLoS = this.built.get(windowLo)?.startS ?? 0;9791117 9801118 let heroFell = false;981- for (const r of this.racers) {982- const st = engine.state(r.idx);983- r.obj.position.set(st.pos[0], st.pos[1], st.pos[2]);984- r.pivot.quaternion.set(st.quat[0], st.quat[1], st.quat[2], st.quat[3]);985- r.speed = Math.hypot(st.vel[0], st.vel[1], st.vel[2]);1119+ const flat = engine.statesAll(this.racers.length);1120+ for (const [ri, r] of this.racers.entries()) {1121+ const b = ri * 10;1122+ r.pos[0] = flat[b];1123+ r.pos[1] = flat[b + 1];1124+ r.pos[2] = flat[b + 2];1125+ r.speed = Math.hypot(flat[b + 3], flat[b + 4], flat[b + 5]);1126+ if (r.obj && r.pivot) {1127+ r.obj.position.set(flat[b], flat[b + 1], flat[b + 2]);1128+ r.pivot.quaternion.set(1129+ flat[b + 6],1130+ flat[b + 7],1131+ flat[b + 8],1132+ flat[b + 9],1133+ );1134+ } else if (this.peerMesh) {1135+ // Instanced peer: one matrix write into the shared mesh.1136+ this.instPos.set(flat[b], flat[b + 1], flat[b + 2]);1137+ this.instQuat.set(1138+ flat[b + 6],1139+ flat[b + 7],1140+ flat[b + 8],1141+ flat[b + 9],1142+ );1143+ this.instMatrix.compose(this.instPos, this.instQuat, this.instScale);1144+ this.peerMesh.setMatrixAt(r.idx - 1, this.instMatrix);1145+ }9861146 const seg = this.built.get(r.nearest.seg);9871147 const rail = this.rails.get(r.nearest.seg);9881148 if (seg && rail) {9891149 r.s = Math.max(r.s, seg.startS + rail.s[r.nearest.idx]);9901150 }9911151 992- // Boost glow: ignition/flameout toggles the marble's own emissive;993- // the audio cue fires for the camera subject only.1152+ // Boost glow: ignition/flameout toggles the marble's own emissive1153+ // (instanced peers brighten their instance color); the audio cue1154+ // fires for the camera subject only.9941155 if (r.boost.boosting !== r.wasBoosting) {9951156 r.wasBoosting = r.boost.boosting;996- for (const m of r.materials) {997- const std = m as THREE.MeshStandardMaterial;998- if (std.emissive) {999- std.emissive.setHex(r.boost.boosting ? 0x2255ff : 0x000000);1000- std.emissiveIntensity = r.boost.boosting ? 0.9 : 1;1157+ if (r.obj) {1158+ for (const m of r.materials) {1159+ const std = m as THREE.MeshStandardMaterial;1160+ if (std.emissive) {1161+ if (r.boost.boosting) std.emissive.setHex(0x2255ff);1162+ else if (r.baseEmissive) std.emissive.copy(r.baseEmissive);1163+ else std.emissive.setHex(0x000000);1164+ std.emissiveIntensity = r.boost.boosting ? 0.9 : 1;1165+ }1166+ }1167+ } else if (this.peerMesh) {1168+ const base = this.peerColors[r.idx - 1];1169+ this.peerMesh.setColorAt(1170+ r.idx - 1,1171+ r.boost.boosting1172+ ? this.instGlow.copy(base).lerp(this.glowBlue, 0.75)1173+ : base,1174+ );1175+ if (this.peerMesh.instanceColor) {1176+ this.peerMesh.instanceColor.needsUpdate = true;10011177 }10021178 }10031179 if (r.boost.boosting && r.idx === this.followedIdx) {@@ -1022,7 +1198,7 @@ class Session {
10221198 10231199 // Falls + stalls, per marble. The hero keeps the checkpoint10241200 // escalation; peers respawn behind the pack tail.1025- const fell = st.pos[1] < lowest - 10;1201+ const fell = r.pos[1] < lowest - 10;10261202 if (r.idx === 0) {10271203 if (fell) {10281204 heroFell = true;@@ -1062,6 +1238,7 @@ class Session {
10621238 this.respawnPeer(r);10631239 }10641240 }1241+ if (this.peerMesh) this.peerMesh.instanceMatrix.needsUpdate = true;10651242 if (heroFell) {10661243 this.respawn();10671244 return;@@ -1084,13 +1261,29 @@ class Session {
10841261 this.followedIdx = followed.idx;10851262 this.speed = followed.speed;10861263 this.marbleS = Math.max(this.marbleS, followed.s);1264+ // Instanced peers have no scene object — a proxy stands in for the1265+ // camera and the trail.1266+ let fObj: THREE.Object3D;1267+ let fPivot: THREE.Object3D;1268+ if (followed.obj && followed.pivot) {1269+ fObj = followed.obj;1270+ fPivot = followed.pivot;1271+ } else {1272+ this.instProxy.position.set(1273+ followed.pos[0],1274+ followed.pos[1],1275+ followed.pos[2],1276+ );1277+ fObj = this.instProxy;1278+ fPivot = this.instProxy;1279+ }10871280 if (this.cameraProxy) {1088- this.cameraProxy.obj = followed.obj;1281+ this.cameraProxy.obj = fObj;10891282 this.cameraProxy.s = this.marbleS;10901283 this.cameraProxy.speed = this.speed;10911284 }1092- if (this.followedPivot !== followed.pivot) {1093- this.followedPivot = followed.pivot;1285+ if (this.followedPivot !== fPivot) {1286+ this.followedPivot = fPivot;10941287 this.trail.reset();10951288 }10961289 }@@ -1122,9 +1315,15 @@ class Session {
11221315 let idx = 0;11231316 while (idx < rail.s.length - 1 && rail.s[idx] < at - seg.startS) idx++;11241317 const t = rail.tans[idx];1318+ const nl = Math.hypot(t[2], t[0]) || 1;1319+ const lane = ((r.idx % 3) - 1) * 0.24; // big packs: don't pile the tail11251320 engine.spawnMarble(11261321 r.idx,1127- [rail.pts[idx][0], rail.pts[idx][1] + 0.4, rail.pts[idx][2]],1322+ [1323+ rail.pts[idx][0] + (t[2] / nl) * lane,1324+ rail.pts[idx][1] + 0.4,1325+ rail.pts[idx][2] + (-t[0] / nl) * lane,1326+ ],11281327 [t[0] * 2.2, 0, t[2] * 2.2],11291328 );11301329 r.nearest = { seg: seg.index, idx };@@ -1141,6 +1340,7 @@ class Session {
11411340 obj: THREE.Group;11421341 pivot: THREE.Group;11431342 materials: THREE.Material[];1343+ baseEmissive: THREE.Color | null;11441344 } {11451345 const identity = racerIdentity(this.seed, index);11461346 const obj = new THREE.Group();@@ -1153,8 +1353,11 @@ class Session {
11531353 if (mesh) {11541354 mesh.scale.setScalar(MARBLE_SCALE);11551355 // Always clone materials (boost glow needs its own emissive even1156- // on untinted marbles).1356+ // on untinted marbles). The phenotype layers MULTIPLY over the1357+ // base look: shell tints the color, the effect reshapes the1358+ // material (gloss/pearl/ember/ghost at its intensity).11571359 const tint = identity.tint;1360+ const { shell, effect } = identity;11581361 mesh.traverse((child: THREE.Object3D) => {11591362 const m = child as THREE.Mesh;11601363 if (!m.isMesh) return;@@ -1163,6 +1366,23 @@ class Session {
11631366 if (tint) {11641367 clone.color.setRGB(tint[0] / 255, tint[1] / 255, tint[2] / 255);11651368 }1369+ clone.color.multiply(1370+ new THREE.Color(shell[0], shell[1], shell[2]),1371+ );1372+ const i = effect.intensity;1373+ if (effect.kind === "gloss") {1374+ clone.roughness *= 1 - 0.7 * i;1375+ clone.metalness = Math.min(1, clone.metalness + 0.35 * i);1376+ } else if (effect.kind === "pearl") {1377+ clone.roughness = 0.15;1378+ clone.metalness = 0.5 * i;1379+ clone.color.lerp(new THREE.Color(1, 1, 1), 0.25 * i);1380+ } else if (effect.kind === "ember") {1381+ clone.emissive = clone.color.clone().multiplyScalar(0.5 * i);1382+ } else if (effect.kind === "ghost") {1383+ clone.transparent = true;1384+ clone.opacity = 1 - 0.45 * i;1385+ }11661386 materials.push(clone);11671387 return clone;11681388 };@@ -1177,7 +1397,13 @@ class Session {
11771397 pivot.add(mesh);11781398 }11791399 this.ctx.scene.add(obj);1180- return { obj, pivot, materials };1400+ const baseEmissive = identity.effect.kind === "ember"1401+ ? new THREE.Color().copy(1402+ (materials[0] as THREE.MeshStandardMaterial | undefined)?.emissive ??1403+ new THREE.Color(0, 0, 0),1404+ )1405+ : null;1406+ return { obj, pivot, materials, baseEmissive };11811407 }11821408 11831409 /** Nearest rail station to a racer, advancing across segments. */@@ -1335,11 +1561,19 @@ class Session {
13351561 // Racer peers (M15): visuals + bodies go with the session.13361562 for (const r of this.racers) {13371563 if (r.idx === 0) continue;1338- this.ctx.scene.remove(r.obj);1339- r.obj.clear();1340- for (const m of r.materials) m.dispose();1564+ if (r.obj) {1565+ this.ctx.scene.remove(r.obj);1566+ r.obj.clear();1567+ for (const m of r.materials) m.dispose();1568+ }13411569 this.engine?.destroyMarble(r.idx);13421570 }1571+ if (this.peerMesh) {1572+ this.ctx.scene.remove(this.peerMesh);1573+ this.peerMesh.geometry.dispose();1574+ (this.peerMesh.material as THREE.Material).dispose();1575+ this.peerMesh = null;1576+ }13431577 this.racers = [];13441578 // Session teardown is immediate — no exit waves on a seed change.13451579 for (const seg of [...this.built.values()]) {@@ -60,7 +60,7 @@ function prefill(params: URLSearchParams): MenuChoices {
6060 endless: params.has("endless")6161 ? params.get("endless") !== "0"6262 : stored.endless,63- peers: Math.max(0, Math.min(15, Math.round(num("peers", stored.peers)))),63+ peers: Math.max(0, Math.min(9998, Math.round(num("peers", stored.peers)))),6464 physics: params.has("phys") ? params.get("phys") !== "0" : stored.physics,6565 autopilot: params.has("autopilot")6666 ? params.get("autopilot") !== "0"@@ -173,6 +173,12 @@ export function maybeShowMenu(): boolean {
173173 padding: 2px 0; letter-spacing: 0.06em;174174 }175175 #menu input#menu-seed::placeholder { color: rgba(127, 212, 255, 0.3); }176+ #menu input#menu-racers {177+ flex: 1; width: 100%; box-sizing: border-box;178+ background: transparent; border: none; outline: none;179+ color: #7fd4ff; font: inherit; text-align: center;180+ letter-spacing: 0.06em;181+ }176182 #menu .row.start-row {177183 grid-template-columns: 24px 1fr; margin-top: 26px; text-align: center;178184 }@@ -205,7 +211,6 @@ export function maybeShowMenu(): boolean {
205211 <div class="col">206212 <div class="orb"></div>207213 <h1>MARBLES</h1>208- <p class="sub">SEEDED RACING IN THE VOID</p>209214 <div class="rows">210215 <div class="row" data-row="mode">211216 <span class="cursor">▸</span><span class="k">MODE</span>@@ -216,7 +221,8 @@ export function maybeShowMenu(): boolean {
216221 <div class="row endless-only" data-row="racers">217222 <span class="cursor">▸</span><span class="k">RACERS</span>218223 <span class="val"><span class="arrow" data-dir="-1">◂</span>219- <span class="v" id="menu-peers-v"></span>224+ <input id="menu-racers" inputmode="numeric" maxlength="4"225+ spellcheck="false" autocomplete="off">220226 <span class="arrow" data-dir="1">▸</span></span>221227 </div>222228 <div class="row" data-row="seed">@@ -295,6 +301,12 @@ export function maybeShowMenu(): boolean {
295301 root.querySelector(`#${id}`) as T;296302 const seedInput = el<HTMLInputElement>("menu-seed");297303 seedInput.value = currentSeed();304+ const racersInput = el<HTMLInputElement>("menu-racers");305+ racersInput.addEventListener("input", () => {306+ const v = Number.parseInt(racersInput.value, 10);307+ if (Number.isFinite(v)) c.peers = Math.max(0, Math.min(9998, v - 1));308+ });309+ racersInput.addEventListener("blur", () => sync());298310 const phys = el<HTMLInputElement>("menu-phys");299311 const ap = el<HTMLInputElement>("menu-ap");300312 const debug = el<HTMLInputElement>("menu-debug");@@ -317,7 +329,9 @@ export function maybeShowMenu(): boolean {
317329 318330 const sync = () => {319331 el("menu-mode-v").textContent = c.endless ? "endless" : "loop";320- el("menu-peers-v").textContent = `hero + ${c.peers}`;332+ if (document.activeElement !== racersInput) {333+ racersInput.value = String(c.peers + 1);334+ }321335 for (const r of root.querySelectorAll(".endless-only")) {322336 r.classList.toggle("off", !c.endless);323337 }@@ -325,12 +339,14 @@ export function maybeShowMenu(): boolean {
325339 for (const row of rows) rowEl(row).classList.toggle("sel", row === sel);326340 if (sel === "seed") seedInput.focus();327341 else seedInput.blur();342+ if (sel === "racers") racersInput.focus();343+ else racersInput.blur();328344 };329345 330346 const adjust = (row: Row, dir: number) => {331347 if (row === "mode") c.endless = !c.endless;332348 else if (row === "racers") {333- c.peers = Math.max(0, Math.min(15, c.peers + dir));349+ c.peers = Math.max(0, Math.min(9998, c.peers + dir));334350 }335351 sync();336352 };@@ -357,7 +373,7 @@ export function maybeShowMenu(): boolean {
357373 return;358374 }359375 if (row === "start") start();360- else if (row !== "seed") adjust(row, 1);376+ else if (row === "mode") adjust(row, 1);361377 });362378 }363379 @@ -273,20 +273,54 @@ export class PeerRingCore {
273273 }274274 }275275 276+/** Marble phenotype (user round): the outer SHELL tint and an EFFECT are277+ * two additional MULTIPLICATIVE expressions layered over the base look —278+ * shell multiplies the color, the effect multiplies material character279+ * (gloss/pearl/ember/ghost at a drawn intensity). */280+export interface RacerLook {281+ meshId: string;282+ tint: readonly [number, number, number] | null;283+ /** Per-channel color multiplier (1,1,1 = no shell). */284+ shell: readonly [number, number, number];285+ effect: {286+ kind: "none" | "gloss" | "pearl" | "ember" | "ghost";287+ intensity: number;288+ };289+}290+291+/** Gentle multiplicative shell color: identity at strength 0; a cosine292+ * palette keeps every channel in (1−s, 1]. */293+function shellOf(294+ h: number,295+ strength: number,296+): readonly [number, number, number] {297+ const ch = (o: number): number =>298+ 1 - strength * (0.5 + 0.5 * Math.cos(2 * Math.PI * (h + o)));299+ return [ch(0), ch(1 / 3), ch(2 / 3)];300+}301+276302 /** Seed-stable looks for a REAL racer marble (M15 — physics peers). Uses277303 * the same mesh pool + tint law as ring peers; label-derived, so adding278- * racers never perturbs anything else. */279-export function racerIdentity(280- seed: string,281- index: number,282-): { meshId: string; tint: readonly [number, number, number] | null } {304+ * racers never perturbs anything else. Phenotype draws are APPENDED so305+ * older identities stay byte-identical. */306+export function racerIdentity(seed: string, index: number): RacerLook {283307 const rng = rngFamily(seed, `racer/${index}`);284- return {285- meshId: rng.weighted(MESH_CHOICES),286- tint: rng.chance(0.2)287- ? null288- : drawTint(rng.float(), rng.float(), rng.float()),289- };308+ const meshId = rng.weighted(MESH_CHOICES);309+ const tint = rng.chance(0.2)310+ ? null311+ : drawTint(rng.float(), rng.float(), rng.float());312+ const shell = shellOf(rng.float(), rng.float() * 0.35);313+ const kind = rng.weighted(314+ [315+ ["none", 0.35],316+ ["gloss", 0.2],317+ ["pearl", 0.15],318+ ["ember", 0.15],319+ ["ghost", 0.15],320+ ] as const,321+ ) as RacerLook["effect"]["kind"];322+ const intensity = 0.4 + rng.float() * 0.6;323+ return { meshId, tint, shell, effect: { kind, intensity } };290324 }291325 292326 /** Rich marble-glass tints (bounded like the pipe colors so peers read@@ -68,6 +68,7 @@ export interface PhysicsModule {
6868 _ph_gravity(gx: number, gy: number, gz: number): void;6969 _ph_step(dt: number, substeps: number): void;7070 _ph_marble_state(idx: number, outPtr: number): void;71+ _ph_states(outPtr: number, n: number): void;7172 _malloc(bytes: number): number;7273 _free(ptr: number): void;7374 HEAPF64: Float64Array;@@ -157,6 +158,8 @@ export function assistForce(
157158 158159 export class PhysicsEngine {159160 private readonly out: number;161+ private batchPtr = 0;162+ private batchCap = 0;160163 private accumulator = 0;161164 /** Per-marble restitution cache (index → last set value). */162165 private readonly restitutions = new Map<number, number>();@@ -304,6 +307,23 @@ export class PhysicsEngine {
304307 this.m._ph_marble_jump(idx, n[0], n[1], n[2], target);305308 }306309 310+ /** Batched state readback for large packs: ONE wasm call for marbles311+ * [0, n); returns a dense HEAP VIEW [i*10 .. i*10+9] = px py pz vx vy312+ * vz qx qy qz qw. The view is invalidated by the next call and by any313+ * heap growth — consume it within the step. */314+ statesAll(n: number): Float64Array {315+ if (n > this.batchCap) {316+ if (this.batchPtr !== 0) this.m._free(this.batchPtr);317+ this.batchCap = n;318+ this.batchPtr = this.m._malloc(n * 10 * 8);319+ }320+ this.m._ph_states(this.batchPtr, n);321+ return this.m.HEAPF64.subarray(322+ this.batchPtr / 8,323+ this.batchPtr / 8 + n * 10,324+ );325+ }326+307327 state(idx = 0): MarbleState {308328 this.m._ph_marble_state(idx, this.out);309329 const h = this.m.HEAPF64;@@ -14,7 +14,7 @@
1414 1515 static b3WorldId world;1616 17-17+1818 static b3BodyId marbles[MAX_MARBLES];1919 static b3ShapeId marbleShapes[MAX_MARBLES];2020 static int marblesAlive[MAX_MARBLES];@@ -240,6 +240,34 @@ void ph_step(float dt, int substeps) {
240240 b3World_Step(world, dt, substeps);241241 }242242 243+// Batched readback (large packs): marbles [0, n) written densely to244+// `out` (n x 10 doubles; dead slots zeroed). One wasm call per step245+// instead of N.246+EMSCRIPTEN_KEEPALIVE247+void ph_states(double* out, int n) {248+ if (n > MAX_MARBLES) n = MAX_MARBLES;249+ for (int i = 0; i < n; i++) {250+ double* o = out + i * 10;251+ if (!marblesAlive[i]) {252+ for (int k = 0; k < 10; k++) o[k] = 0;253+ continue;254+ }255+ b3Pos p = b3Body_GetPosition(marbles[i]);256+ b3Vec3 v = b3Body_GetLinearVelocity(marbles[i]);257+ b3WorldTransform t = b3Body_GetTransform(marbles[i]);258+ o[0] = p.x;259+ o[1] = p.y;260+ o[2] = p.z;261+ o[3] = v.x;262+ o[4] = v.y;263+ o[5] = v.z;264+ o[6] = t.q.v.x;265+ o[7] = t.q.v.y;266+ o[8] = t.q.v.z;267+ o[9] = t.q.s;268+ }269+}270+243271 // [px py pz vx vy vz qx qy qz qw] written to `out` (10 doubles).244272 EMSCRIPTEN_KEEPALIVE245273 void ph_marble_state(int idx, double* out) {@@ -37,6 +37,8 @@ export function initSeed(onSeed: (seed: string) => void): void {
3737 const emit = (seed: string) => {3838 if (seed === current) return; // echo guard3939 current = seed;40+ // The title carries the seed so history entries read as tracks.41+ document.title = `marbles - #${seed}`;4042 onSeed(seed);4143 };4244 @@ -30,7 +30,7 @@ emcc "$ROOT/src/client/physics/shim.c" \
3030 -O2 \3131 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,node \3232 -sALLOW_MEMORY_GROWTH=1 \33- -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_destroy_marble,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_malloc,_free \33+ -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_destroy_marble,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_ph_states,_malloc,_free \3434 -sEXPORTED_RUNTIME_METHODS=HEAPF64,HEAPF32,HEAP32 \3535 -o "$ROOT/static/dist/box3d.mjs"3636