clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

M14a autopilot: centerline as wishdir — pure pursuit + swept speed envelope replace the assist special cases

Dami committed 16ac61a
One general law (src/client/physics/autopilot.ts): pure-pursuit lookahead
point -> wishdir; per-station corner speeds from yaw + convex-pitch
curvature (valleys free, so chute dives stay dramatic) swept online with
sqrt(vc^2 + 2*a*ds); Quake/MB accelerate contract (deficit <= 0 -> no
force, momentum sacred -> replaces hands-off threshold AND climb carry;
saturating authority under a traction budget climbs ramps emergently);
decisive separate brake clamp; weak horizontal air control off the rail
floor. Zone extras (flight pricing, centering springs, gravity tilt,
descent pulls, icy grip) shared verbatim.

Client default (?autopilot=0 = old assist law for A/B); tests + phys-deep
drive the autopilot (one law, one truth). Gates: 105 tests green
(all named physics scenarios + determinism), 7 survivability seeds ride
8 segments to the horizon, 30-seed fuzz 0 falls 0 wedges vs 2 falls for
the assist law at identical pace (med 125s vs 127s).
commit
16ac61af4b456a124b6d7ef1e642200dd5184a3b
parent
736e372f6993c31346bafd7e4bedb531ec6a9e38
author
Dami <dami.etoile@gmail.com>
5 files changed, +363 -4
Mprogress.md66+++++++
Msrc/client/endless.ts9+-
Asrc/client/physics/autopilot.ts282++++++++++++++++++++++++++++++
Mtests/physics_test.ts4+-
Mtools/physics-debug/phys-deep.ts6+-
M progress.md +66 -0
@@ -1105,6 +1105,72 @@ violet on screen; scenery ring/wide bridge/tee verified on screen (loop)
11051105 + by rule tests. Coverage: exemptions now 33 (was 40) — curve-wide ×5,11061106 split-double-sides, straight-wide-hole all lifted.11071107 1108+## ROADMAP — M14/M15: the autopilot era (planned with the user, 2026-08-09)1109+1110+USER DIRECTION: stop treating the centerline as an automated forward path;1111+treat it as a WISHDIR — the marble propels itself, jumps, and changes speed1112+to maximize safety and progress ("closer to a real physics situation").1113+Also: marbles interacting DIRECTLY (real multi-marble contact). Research1114+round done (prior art: MBHaxe Marble.hx authority servo + traction budget +1115+top-up jumps; Quake accelerate projection rule; pure pursuit + curvature1116+speed planning with backward brake sweep; Reynolds corridor dead zone;1117+Marbles-on-Stream/Jelle's variance-as-product). Plan approved as "do this1118+all".1119+1120+### DECISION LOG (agent defaults, 2026-08-09 — user may override)1121+1122+- Force-at-COM authority (not torque servo): Box3D contact friction 0.71123+ already converts COM force → rolling spin; torque needs new shim surface1124+ for unclear feel gain. Revisit only if feel demands spin-on-bounce.1125+- The pack is a SPECTACLE RACE: N marbles (default 6) running continuously,1126+ leader-cam, no finish line in endless; finish-order variance per segment1127+ is the entertainment. Personality via per-marble controller params1128+ (label-derived rng), NEVER thrust — capability rubber-banding is what1129+ spectators detect.1130+- Autopilot lands behind a flag (?autopilot=0 fallback = old assist law)1131+ and becomes default once all gates are green.1132+1133+### Milestones1134+1135+**M14a — Autopilot core.** One law replaces assistForce's special cases:1136+per-station curvature at rail build; online windowed brake sweep1137+(v_safe(i) = min over lookahead of sqrt(v_corner² + 2·a_brake·Δs), v_corner1138+= max(cruise·speedScale, sqrt(A_LAT/κ))); pure-pursuit lookahead point at1139+L = clamp(k·|v|, Lmin, Lmax) → wishdir = normalize(look − pos); accelerate1140+with the Quake projection rule (addspeed = wishspeed − dot(v, wishdir); ≤01141+→ NO force — momentum is sacred, replaces hands-off threshold AND climb1142+carry: deficit-proportional authority clamped by a traction budget climbs1143+ramps by slowing naturally); separate decisive brake clamp above v_safe;1144+weak air accel (≈5) toward the lookahead when airborne (replaces the1145+ballistic hands-off; propeller gravScale force stays). Icy = grip1146+multiplier on the traction budget (same knob, now principled). Gates: 71147+survivability seeds, all named scenarios, deep fuzz, determinism hash,1148+re-pin scenario table.1149+1150+**M14b — Jumping as a decision.** Shim ph_marble_jump (top-up: velocity1151+along normal SET to jumpImpulse, never added — deterministic height);1152+autopilot commits when a gap/rise in the lookahead needs it AND the1153+ballistic arc from current v lands inside the corridor. Planner jump1154+set-pieces remain; the marble now executes them and can recover bad1155+approaches. Gates: jump contract at envelope extremes, cold-entry.1156+1157+**M14c — Naturalizer.** Reynolds corridor dead zone (no lateral correction1158+inside ~60% of half-width; ramped Stanley-style correction near the edge;1159+boost/split/gravity zones keep dead zone 0 = today's centering); per-marble1160+personality {bravery, lookahead gain, jump eagerness, OU-noise lateral1161+offset target}, label-derived rng, seed-stable. Gates: determinism, soak1162+review by eye.1163+1164+**M15 — Marbles interacting directly.** Shim: marble ARRAY (N dynamic1165+spheres, batched state readback); peers promoted from kinematic ghosts1166+(peersCore.ts) to real bodies, each running its own autopilot + personality;1167+marble-marble material friction ~0, restitution ~0.35 (Box3D combines via1168+MAX — bumpers stay bouncy); separation steering when a neighbor occupies1169+the lookahead cone within ~2 radii, WAIVED when heavier (shoving is1170+content); respawn behind the pack tail with a collision-immunity window;1171+leader-cam = camera follows max-s marble. Gates: N-marble determinism (same1172+seed → same order), pack-stall scenario on flats, N=6 fuzz.1173+11081174 ## HANDOFF — 2026-08-09 state: M1–M13 deferred set COMPLETE11091175 11101176 CURRENT: all M13 backlog items above are live. Remaining/possible next:
M src/client/endless.ts +8 -1
A src/client/physics/autopilot.ts +282 -0
@@ -0,0 +1,282 @@
1+/**2+ * The autopilot (M14a) — the centerline demoted from choreographer to3+ * WISHDIR. One general control law replaces the assist law's special4+ * cases (climb carry, hands-off threshold, curvature term):5+ *6+ * - Pure pursuit: chase a lookahead point on the rail at7+ * L = clamp(lookGain·|v|, L_MIN, L_MAX) → wishdir (Coulter '92; for a8+ * marble the controller degenerates to "seek the point").9+ * - Speed planning: per-station corner speeds from curvature split into10+ * yaw (wall-launch risk) and CONVEX pitch (crest-launch risk) — concave11+ * valleys don't brake, so chute dives stay dramatic — swept backward12+ * online: allowed(k) = sqrt(vc_k² + 2·A_BRAKE·Δs). wishspeed is the13+ * minimum of the local target and that envelope.14+ * - Accelerate with the Quake/Marble Blast projection contract:15+ * deficit = wishspeed − dot(v, wishdir); deficit ≤ 0 → NO force16+ * (momentum is sacred — a downhill marble is never fought; this is what17+ * replaces the hands-off threshold). Positive deficit → a saturating18+ * authority a = min(KP·deficit, A_TRACTION)·grip; climbs emerge (the19+ * marble slows, the deficit grows, authority rises above along-slope20+ * gravity) instead of being scripted by a gravity-cancelling carry.21+ * - Decisive braking only above the swept envelope (separate clamp, like22+ * MBHaxe's brakingAcceleration vs angularAcceleration).23+ * - Airborne (rail floor left behind): weak linear air control toward the24+ * lookahead — enough to line up a landing, never enough to fly (MBHaxe25+ * airAccel 5 vs g 20).26+ *27+ * Zone semantics (flight pricing, gravity tilt, restitution, centering28+ * springs, descent pulls) stay shared with rail.ts — the autopilot only29+ * replaces the propulsion/braking half of stationForce.30+ */31+import { RAIL_STEP, type Rail, type RailMod } from "./rail.ts";32+import { GRAVITY_Y } from "./engine.ts";33+34+/** Per-marble controller personality (M14c fills the jitter in; the35+ * defaults are the deterministic baseline every gate runs on). */36+export interface Pilot {37+ /** Corner-speed margin: multiplies the curvature-derived corner speed38+ * (not the target floor) — >1 corners braver, <1 more cautious. */39+ bravery: number;40+ /** Pursuit lookahead gain k in L = clamp(k·|v|, L_MIN, L_MAX). */41+ lookGain: number;42+ /** Lateral offset target from the rail line (world units; OU-noise43+ * driven in M14c — 0 rides the line). */44+ offset: number;45+}46+47+export const DEFAULT_PILOT: Pilot = { bravery: 1, lookGain: 0.55, offset: 0 };48+49+/** Cruise target on an unmodified tile (the proven M11 value — boost50+ * pricing multiplies it, so jump launch speeds stay ≈ 6.1). */51+const CRUISE = 3.2;52+/** Hard speed ceiling (the proven envelope cap). */53+const VMAX = 9;54+/** Lateral grip budget for corner speeds: vc = sqrt(A_LAT·r). Sized so a55+ * small curve (r = 1 world) corners at ≈ cruise — the old law's proven56+ * entry speed for tight bends — while large curves earn more. */57+const A_LAT = 10;58+/** Crest budget: centripetal v²·κ beyond this fraction of g lifts the59+ * marble off the floor — brake below launch, with margin. */60+const A_CREST = 16;61+/** Planning deceleration for the brake sweep (the brake itself can pull62+ * harder; planning softer means braking starts earlier and reads as63+ * intent, not panic). */64+const A_BRAKE = 6;65+/** Authority: accel per unit of speed deficit… */66+const KP = 5;67+/** …saturating at the static-friction budget (MBHaxe μs 1.1 · g 20). */68+const A_TRACTION = 22;69+/** Max brake accel (matches the old law's clamp). */70+const A_BRAKE_MAX = 10;71+/** Linear air control (MBHaxe _airAccel). */72+const AIR_ACCEL = 5;73+const L_MIN = 1.0;74+const L_MAX = 4.0;75+/** Brake-sweep window (world units) — covers VMAX → CRUISE at A_BRAKE. */76+const SWEEP_DIST = 8;77+/** Height above the rail floor that counts as airborne. The rail line is78+ * the channel floor centerline; a rolling marble's center rides ≈ its79+ * radius (0.1) above it, bumps and seams excursion within ~0.3. */80+const AIR_HEIGHT = 0.45;81+82+const clamp = (v: number, lo: number, hi: number): number =>83+ v < lo ? lo : v > hi ? hi : v;84+85+/**86+ * The complete autopilot step at a station. Same contract as87+ * rail.ts stationForce (drivers swap one call), plus a pilot.88+ */89+export function autopilotForce(90+ rail: Rail,91+ idx: number,92+ pos: readonly [number, number, number],93+ vel: readonly [number, number, number],94+ mass: number,95+ nextRail: Rail | null = null,96+ pilot: Pilot = DEFAULT_PILOT,97+): [number, number, number] | null {98+ const t = rail.tans[idx];99+ if (!t) return null;100+ const mod = rail.mods[idx];101+ // Flight zones keep their plan-time pricing: no propulsion, propeller102+ // zones cancel part of gravity. (M14b adds the jump decision + air103+ // lineup on top.)104+ if (mod?.flight) {105+ if (mod.gravScale !== undefined && mod.gravScale !== 1) {106+ return [0, (1 - mod.gravScale) * -GRAVITY_Y * mass, 0];107+ }108+ return null;109+ }110+111+ const n = rail.tans.length;112+ const tanAt = (i: number): readonly [number, number, number] | null =>113+ rail.tans[i] ?? (nextRail ? nextRail.tans[i - n] ?? null : null);114+ const ptAt = (i: number): readonly [number, number, number] | null =>115+ rail.pts[i] ?? (nextRail ? nextRail.pts[i - n] ?? null : null);116+ const modAt = (i: number): RailMod | null =>117+ i < n ? rail.mods[i] : nextRail ? nextRail.mods[i - n] ?? null : null;118+ const endS = rail.s[n - 1] ?? 0;119+ const sAt = (i: number): number | null =>120+ i < n121+ ? rail.s[i]122+ : nextRail && nextRail.s[i - n] !== undefined123+ ? endS + RAIL_STEP + nextRail.s[i - n]124+ : null;125+126+ // --- speed planning: online backward brake sweep over the lookahead ---127+ const sHere = rail.s[idx];128+ const targetHere = CRUISE * (mod?.speed ?? 1);129+ let vSafe = VMAX;130+ let climbTy = t[1];131+ let prev: readonly [number, number, number] = t;132+ let prevS = sHere;133+ for (let k = idx + 1;; k++) {134+ const look = tanAt(k);135+ const sk = sAt(k);136+ if (!look || sk === null) break;137+ const ds = sk - prevS;138+ if (ds > 1e-6) {139+ const hp = Math.hypot(prev[0], prev[2]);140+ const hl = Math.hypot(look[0], look[2]);141+ let yaw = 0;142+ if (hp > 1e-4 && hl > 1e-4) {143+ const dot = clamp(144+ (prev[0] * look[0] + prev[2] * look[2]) / (hp * hl),145+ -1,146+ 1,147+ );148+ yaw = Math.acos(dot);149+ }150+ const pitchD = Math.asin(clamp(look[1], -1, 1)) -151+ Math.asin(clamp(prev[1], -1, 1));152+ const kYaw = yaw / ds;153+ const kCrest = Math.max(0, -pitchD) / ds; // convex only — valleys free154+ const targetK = CRUISE * (modAt(k)?.speed ?? 1);155+ const grip = pilot.bravery;156+ const vYaw = kYaw > 1e-4 ? Math.sqrt(A_LAT / kYaw) * grip : VMAX;157+ const vCrest = kCrest > 1e-4 ? Math.sqrt(A_CREST / kCrest) * grip : VMAX;158+ const vc = Math.min(VMAX, Math.max(targetK, Math.min(vYaw, vCrest)));159+ const dist = sk - sHere;160+ const allowed = Math.sqrt(vc * vc + 2 * A_BRAKE * dist);161+ if (allowed < vSafe) vSafe = allowed;162+ }163+ climbTy = Math.max(climbTy, look[1]);164+ prev = look;165+ prevS = sk;166+ if (sk - sHere > SWEEP_DIST) break;167+ }168+169+ // Momentum arrival: a climb ahead raises the target — the pilot builds170+ // speed BEFORE the slope instead of being carried up it.171+ const climbing = climbTy > 0.12;172+ const target = climbing ? targetHere * (1 + 0.5 * climbTy) : targetHere;173+ const wishspeed = Math.min(target, vSafe);174+175+ // --- pure pursuit: the lookahead point → wishdir ---176+ const speed = Math.hypot(vel[0], vel[1], vel[2]);177+ const L = clamp(pilot.lookGain * speed, L_MIN, L_MAX);178+ let j = idx;179+ for (;;) {180+ const sj = sAt(j + 1);181+ if (sj === null || sj - sHere > L) break;182+ j++;183+ }184+ const look = ptAt(j) ?? rail.pts[idx];185+ let wx = look[0] - pos[0];186+ let wy = look[1] - pos[1];187+ let wz = look[2] - pos[2];188+ if (pilot.offset !== 0) {189+ const tj = tanAt(j) ?? t;190+ const nl = Math.hypot(tj[2], tj[0]);191+ if (nl > 1e-6) {192+ wx += (tj[2] / nl) * pilot.offset;193+ wz += (-tj[0] / nl) * pilot.offset;194+ }195+ }196+ const wl = Math.hypot(wx, wy, wz);197+ if (wl < 1e-6) return null;198+ wx /= wl;199+ wy /= wl;200+ wz /= wl;201+202+ const grounded = pos[1] - rail.pts[idx][1] < AIR_HEIGHT;203+ const out: [number, number, number] = [0, 0, 0];204+205+ if (grounded) {206+ // Project wishdir onto the ground plane (normal ≈ tangent-plane up)207+ // so authority pushes along the slope, not into the floor.208+ let nx = -t[0] * t[1];209+ let ny = 1 - t[1] * t[1];210+ let nz = -t[2] * t[1];211+ const nlen = Math.hypot(nx, ny, nz);212+ if (nlen > 1e-6) {213+ nx /= nlen;214+ ny /= nlen;215+ nz /= nlen;216+ const d = wx * nx + wy * ny + wz * nz;217+ wx -= d * nx;218+ wy -= d * ny;219+ wz -= d * nz;220+ const l2 = Math.hypot(wx, wy, wz);221+ if (l2 > 1e-6) {222+ wx /= l2;223+ wy /= l2;224+ wz /= l2;225+ } else {226+ wx = t[0];227+ wy = t[1];228+ wz = t[2];229+ }230+ }231+ const vAlongT = vel[0] * t[0] + vel[1] * t[1] + vel[2] * t[2];232+ if (vAlongT > vSafe) {233+ // Decisive brake above the planned envelope — its own clamp, like234+ // MBHaxe braking vs rolling authority.235+ const a = Math.min(A_BRAKE_MAX, 3 + (vAlongT - vSafe) * 2);236+ out[0] -= t[0] * a * mass;237+ out[1] -= t[1] * a * mass;238+ out[2] -= t[2] * a * mass;239+ } else {240+ const vAlongW = vel[0] * wx + vel[1] * wy + vel[2] * wz;241+ const deficit = wishspeed - vAlongW;242+ if (deficit > 0) {243+ // Icy: grip scales BOTH responsiveness and the ceiling — on ice244+ // the pilot wants the same speed and simply cannot have it.245+ const grip = mod?.icy ? 0.3 : 1;246+ const a = Math.min(KP * grip * deficit, A_TRACTION * grip);247+ out[0] += wx * a * mass;248+ out[1] += wy * a * mass;249+ out[2] += wz * a * mass;250+ }251+ }252+ } else {253+ // Airborne: weak linear control toward the lookahead, horizontal only254+ // (lift is gravity's business).255+ const hl = Math.hypot(wx, wz);256+ if (hl > 1e-4) {257+ out[0] += (wx / hl) * AIR_ACCEL * mass;258+ out[2] += (wz / hl) * AIR_ACCEL * mass;259+ }260+ }261+262+ // --- shared zone extras (identical to the assist law) ---263+ // Lateral centering spring in boost/split/gravity zones: launches and264+ // merges must be straight; these zones are straight by construction.265+ if ((mod?.speed && mod.speed > 1) || mod?.center) {266+ const r = rail.pts[idx];267+ const nl = Math.hypot(t[2], t[0]) || 1;268+ const ux = t[2] / nl;269+ const uz = -t[0] / nl;270+ const off = (pos[0] - r[0]) * ux + (pos[2] - r[2]) * uz;271+ const lvel = vel[0] * ux + vel[2] * uz;272+ const a = clamp(-40 * off - 12 * lvel, -14, 14);273+ out[0] += ux * a * mass;274+ out[2] += uz * a * mass;275+ }276+ // Chutes pull harder — descending stations only (grav mods never fight277+ // a climb; that wedged marbles at ramp bases).278+ if (mod?.grav && mod.grav !== 1 && t[1] < -0.05 && climbTy < 0.12) {279+ out[1] += (mod.grav - 1) * GRAVITY_Y * mass;280+ }281+ return out[0] === 0 && out[1] === 0 && out[2] === 0 ? null : out;282+}
M tests/physics_test.ts +2 -2
M tools/physics-debug/phys-deep.ts +5 -1