diff --git a/AI-Car-Racer/buttonResponse.js b/AI-Car-Racer/buttonResponse.js
index 2be105b..88ed87f 100644
--- a/AI-Car-Racer/buttonResponse.js
+++ b/AI-Car-Racer/buttonResponse.js
@@ -1,20 +1,109 @@
function pauseGame(){
- pause=!pause;
+ // App not booted yet (classic scripts still loading). Queue the intent;
+ // main.js drains __pendingStart after begin/animate are defined.
+ if (typeof pause === 'undefined' || typeof phase === 'undefined'){
+ window.__pendingStart = true;
+ markStartOverlayStarting();
+ return;
+ }
+
+ // First Start click: leave the page-load gate and build+run the swarm.
+ // (Population build is deferred until this moment so the Start CTA is
+ // clickable as soon as the overlay paints — not after a multi-second
+ // worker/brain setup.)
+ if (window.__awaitingStart){
+ window.__awaitingStart = false;
+ window.__firstStart = false;
+ window.__pendingStart = false;
+ pause = false;
+ const btn = document.getElementById("pause");
+ if (btn){
+ btn.textContent = "Pause";
+ btn.classList.remove('start-cta');
+ }
+ markStartOverlayStarting();
+ try { if (typeof syncStartOverlay === 'function') syncStartOverlay(); } catch (_) {}
+ if (typeof begin === 'function'){
+ begin();
+ } else {
+ // main.js still evaluating — re-queue so the boot footer can start.
+ window.__pendingStart = true;
+ }
+ return;
+ }
+
+ // Normal Pause / Play toggle after training has started.
+ pause = !pause;
const btn = document.getElementById("pause");
if (btn){
- btn.textContent = pause?"Play":"Pause";
- // First-press of the Start CTA graduates the button to the normal
- // Pause/Play cycle.
+ btn.textContent = pause ? "Play" : "Pause";
btn.classList.remove('start-cta');
}
- window.__firstStart = false;
// Halt / resume the worker's AI step loop too. Without this, sim-worker
// would keep burning CPU while the user has paused — and on resume the
// accumulator would stampede a huge backlog of physics steps at once.
if (typeof simWorker !== 'undefined' && simWorker){
simWorker.postMessage({ type: 'setPause', pause });
}
+ try { if (typeof syncStartOverlay === 'function') syncStartOverlay(); } catch (_) {}
+}
+
+function markStartOverlayStarting(){
+ const ob = document.getElementById('startOverlayBtn');
+ if (!ob) return;
+ ob.classList.add('is-starting');
+ ob.disabled = true;
+ const label = ob.querySelector('.start-overlay-label');
+ if (label) label.textContent = 'Starting\u2026';
+}
+
+// Canvas-centered Start overlay — present in index.html for first paint,
+// kept in sync here once the app boots. Mirrors the panel's ▶ Start CTA.
+function syncStartOverlay(){
+ let el = document.getElementById('startOverlay');
+ // Show while awaiting Start on the training screen. Before main.js sets
+ // `phase`, treat as showable so the static HTML overlay stays visible
+ // during script load (that was the multi-second "can't click yet" gap).
+ const show = !!window.__awaitingStart &&
+ (typeof phase === 'undefined' || phase === 4);
+ if (!show){
+ if (el) el.hidden = true;
+ return;
+ }
+ if (!el){
+ el = document.createElement('div');
+ el.id = 'startOverlay';
+ el.setAttribute('role', 'dialog');
+ el.setAttribute('aria-modal', 'true');
+ el.setAttribute('aria-label', 'Start training');
+ el.innerHTML =
+ '';
+ const host = document.getElementById('canvasDiv') || document.body;
+ host.appendChild(el);
+ }
+ const btn = el.querySelector('#startOverlayBtn');
+ if (btn && btn.dataset.earlyBound !== '1' && btn.dataset.bound !== '1'){
+ btn.dataset.bound = '1';
+ btn.addEventListener('click', () => {
+ if (typeof pauseGame === 'function') pauseGame();
+ else window.__pendingStart = true;
+ });
+ }
+ // Reset busy state when re-showing (e.g. Customize Track → back to train).
+ if (btn && window.__awaitingStart){
+ btn.disabled = false;
+ btn.classList.remove('is-starting');
+ const label = btn.querySelector('.start-overlay-label');
+ if (label) label.textContent = 'Start Training';
+ }
+ el.hidden = false;
}
+window.syncStartOverlay = syncStartOverlay;
+window.markStartOverlayStarting = markStartOverlayStarting;
// Route a phase-4 user back into the track editor. Mirrors backPhase()'s
// cleanup (pause the worker, close any SONA trajectory) but sets phase=0
diff --git a/AI-Car-Racer/demoPresentation.js b/AI-Car-Racer/demoPresentation.js
index 73b63ba..d7d4213 100644
--- a/AI-Car-Racer/demoPresentation.js
+++ b/AI-Car-Racer/demoPresentation.js
@@ -12,8 +12,9 @@
// • Optional 3D perspective view of the same 2D world
// • Demo mode: auto-start, follow, story beats
//
-// URL flags: ?demo=1 ?follow=1 ?view=3d ?trails=0 ?heatmap=0
-// Keyboard: F follow · 3 3D · T trails · H heatmap · D demo · 0 reset cam
+// URL flags: ?demo=1 ?follow=1 ?view=3d ?trails=0 ?heatmap=0 ?rays=0
+// Keyboard: F follow · 3 3D · T trails · H heatmap · R rays · M demo · 0 reset cam
+// Note: W/A/S/D are reserved for driving and never bind presentation toggles.
(function (global) {
'use strict';
@@ -33,6 +34,7 @@
view3d: params.get('view') === '3d',
trails: flag('trails', true),
heatmap: flag('heatmap', true),
+ rays: flag('rays', true),
rankColors: true,
// camera
camScale: 1,
@@ -96,7 +98,7 @@
syncControlButtons();
setStory(state.demoMode
? 'Demo mode — watch a swarm learn to drive.'
- : 'Train a neural net to race. Press D for demo mode.', 6000);
+ : 'Train a neural net to race. Press M for demo mode (W/A/S/D drive).', 6000);
}
function ensureHud() {
@@ -129,7 +131,8 @@
'' +
'' +
'' +
- '';
+ '' +
+ '';
el.addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-act]');
if (!btn) return;
@@ -138,6 +141,7 @@
else if (act === 'view3d') setView3d(!state.view3d);
else if (act === 'trails') { state.trails = !state.trails; if (!state.trails) state.trail.length = 0; }
else if (act === 'heatmap') state.heatmap = !state.heatmap;
+ else if (act === 'rays') setRays(!state.rays);
else if (act === 'demo') startDemoMode();
syncControlButtons();
});
@@ -152,6 +156,7 @@
view3d: state.view3d,
trails: state.trails,
heatmap: state.heatmap,
+ rays: state.rays,
demo: state.demoMode,
};
state.controlsEl.querySelectorAll('[data-act]').forEach((btn) => {
@@ -167,15 +172,32 @@
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
const k = e.key;
+ // Never steal driving keys. WASD controls the player car (phase 3) and
+ // must not toggle Demo / Follow / other presentation chrome. Demo used
+ // to bind D, which flipped Follow on every right-turn press.
+ if (k === 'w' || k === 'a' || k === 's' || k === 'd' ||
+ k === 'W' || k === 'A' || k === 'S' || k === 'D') {
+ return;
+ }
+ // While the user is actively driving (physics-tune phase), ignore all
+ // presentation hotkeys so arrow keys and free driving stay clean.
+ try {
+ if (typeof phase !== 'undefined' && phase === 3) return;
+ } catch (_) {}
if (k === 'f' || k === 'F') { setFollow(!state.followBest); syncControlButtons(); }
else if (k === '3') { setView3d(!state.view3d); syncControlButtons(); }
else if (k === 't' || k === 'T') { state.trails = !state.trails; if (!state.trails) state.trail.length = 0; syncControlButtons(); }
else if (k === 'h' || k === 'H') { state.heatmap = !state.heatmap; syncControlButtons(); }
- else if (k === 'd' || k === 'D') { startDemoMode(); syncControlButtons(); }
+ else if (k === 'r' || k === 'R') { setRays(!state.rays); syncControlButtons(); }
+ else if (k === 'm' || k === 'M') { startDemoMode(); syncControlButtons(); }
else if (k === '0') { resetCamera(); }
});
}
+ function setRays(on) {
+ state.rays = !!on;
+ }
+
// --------------------------------------------------------------- road cache
function useRoadCache() {
// Cache only during training when the editor is locked — during track
@@ -785,8 +807,8 @@
const x = best.x, y = best.y, ang = best.angle;
if (state.view3d) {
drawProjectedCar(ctx, x, y, ang, CHAMPION, 1, state.carHeight * 1.15);
- // Sensor rays
- if (best.sensor && best.sensor.rays && best.sensor.rays.length) {
+ // Sensor rays (toggle via Rays chip / R)
+ if (state.rays && best.sensor && best.sensor.rays && best.sensor.rays.length) {
for (let i = 0; i < best.sensor.rays.length; i++) {
const ray = best.sensor.rays[i];
const reading = best.sensor.readings[i];
@@ -832,7 +854,7 @@
ctx.stroke();
ctx.restore();
- if (best.sensor && best.sensor.rays && best.sensor.rays.length) {
+ if (state.rays && best.sensor && best.sensor.rays && best.sensor.rays.length) {
for (let i = 0; i < best.sensor.rays.length; i++) {
const ray = best.sensor.rays[i];
const reading = best.sensor.readings[i];
@@ -1006,12 +1028,18 @@
if (ss) ss.value = '5';
} catch (_) {}
- // Unpause if waiting on first start.
+ // Unpause / first-start: pauseGame() clears __awaitingStart and calls
+ // begin() to build the swarm. A second begin() is only needed when the
+ // sim was already past the Start gate (restart with demo knobs).
try {
- if (typeof pause !== 'undefined' && pause && typeof pauseGame === 'function') {
+ if (window.__awaitingStart && typeof pauseGame === 'function') {
+ pauseGame();
+ } else if (typeof pause !== 'undefined' && pause && typeof pauseGame === 'function') {
pauseGame();
+ if (typeof begin === 'function') begin();
+ } else if (typeof begin === 'function') {
+ begin();
}
- if (typeof begin === 'function') begin();
} catch (_) {}
state.demoStarted = true;
@@ -1066,6 +1094,7 @@
onGenEnd,
setFollow,
setView3d,
+ setRays,
resetCamera,
startDemoMode,
setStory,
diff --git a/AI-Car-Racer/index.html b/AI-Car-Racer/index.html
index 00f87dd..43e7a3b 100644
--- a/AI-Car-Racer/index.html
+++ b/AI-Car-Racer/index.html
@@ -44,7 +44,7 @@
-
+
+
+
+
@@ -75,7 +86,7 @@
Train Your Model
-
+
+
@@ -187,10 +223,15 @@
Train Your Model
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
toggle.setAttribute('aria-label', collapsed ? 'Expand control panel' : 'Collapse control panel');
};
- // Restore — default to expanded. localStorage read is wrapped because
- // Safari private mode throws synchronously on access.
- let saved = false;
- try { saved = localStorage.getItem(KEY) === '1'; } catch (_) {}
+ // Restore preference. Default to collapsed so the track claims the
+ // viewport; the canvas Start overlay stays reachable either way.
+ // localStorage read is wrapped because Safari private mode throws.
+ let saved = true; // default collapsed
+ try {
+ const v = localStorage.getItem(KEY);
+ if (v === '0') saved = false;
+ else if (v === '1') saved = true;
+ } catch (_) {}
apply(saved);
toggle.addEventListener('click', () => {
const nowCollapsed = !display.classList.contains('panel-collapsed');
diff --git a/AI-Car-Racer/main.js b/AI-Car-Racer/main.js
index 834d9dd..5a77995 100644
--- a/AI-Car-Racer/main.js
+++ b/AI-Car-Racer/main.js
@@ -253,10 +253,21 @@ let pause=true;
var phase = 0; //0 welcome, 1 track, 2 checkpoints, 3 physics, 4 training
var maxSpeed = 15;
// Default entry flow: land straight on phase 4 (training) with a preloaded
-// rectangle track so visitors see cars racing immediately behind a prominent
-// Start button. The old draw-a-track-from-scratch flow is still one click
-// away via "Customize Track" (see customizeTrack() in buttonResponse.js) and
-// can also be forced with `?edit=1` on the URL for dev use.
+// rectangle track. The sim stays paused until the user hits Start — no
+// auto-run on page load. The old draw-a-track-from-scratch flow is still one
+// click away via "Customize Track" (buttonResponse.js) and can also be forced
+// with `?edit=1` on the URL for dev use.
+//
+// __awaitingStart — session gate: every fresh page load waits for an explicit
+// Start / Demo click before the worker steps. Cleared by pauseGame().
+// Inline script in index.html sets this true for first paint; do NOT force
+// it back to true here if an early Start click already cleared the gate
+// while the rest of main.js was still evaluating.
+// __firstStart — first-ever visitor (no trainCount yet); drives CTA copy and
+// the stronger amber Start button styling.
+if (window.__awaitingStart !== false) {
+ window.__awaitingStart = true;
+}
window.__firstStart = !localStorage.getItem('trainCount');
if (new URLSearchParams(location.search).get('edit') === '1') {
nextPhase(); // → phase 1 (track draw)
@@ -742,7 +753,13 @@ simWorker.onmessage = (ev) => {
switch (m.type){
case 'ready':
workerReady = true;
- if (pendingBegin){ const pb = pendingBegin; pendingBegin = null; performBegin(pb.N); }
+ // Never auto-start a pending begin while the Start CTA is still
+ // waiting — boot used to race performBegin here and freeze input.
+ if (pendingBegin && !window.__awaitingStart){
+ const pb = pendingBegin; pendingBegin = null; performBegin(pb.N);
+ } else if (window.__awaitingStart){
+ pendingBegin = null;
+ }
break;
case 'snapshot':
handleSnapshot(m);
@@ -1207,6 +1224,30 @@ function buildBrainsBuffer(N){
// -----------------------------------------------------------------------------
function begin(){
seconds = nextSeconds;
+ // Page-load gate: while awaiting an explicit Start click, do NOT build
+ // the 500-car population or touch the worker. That work used to run on
+ // boot and blocked the main thread / delayed a responsive Start CTA for
+ // multiple seconds. pauseGame() clears __awaitingStart then calls begin()
+ // again to actually engage the worker.
+ if (window.__awaitingStart) {
+ pause = true;
+ computeStartInfoInPlace(currentCheckpointList());
+ // Lightweight placeholders so phase-3 tooling still has objects if the
+ // user hops into Customize Track before starting. AI swarm is deferred.
+ try {
+ playerCar = new Car(startInfo.x, startInfo.y, 30, 50, "KEYS", maxSpeed, startInfo.heading);
+ playerCar2 = new Car(startInfo.x, startInfo.y, 30, 50, "WASD", maxSpeed, startInfo.heading);
+ } catch (_) {}
+ frameCount = 0;
+ wallStart = performance.now();
+ _simStepAccum = 1;
+ _lastTickWall = performance.now();
+ bestCar = null;
+ _bestProxyEpoch = -1;
+ latestSnapshot = null;
+ pendingBegin = null;
+ return;
+ }
pause = false;
computeStartInfoInPlace(currentCheckpointList());
playerCar = new Car(startInfo.x, startInfo.y, 30, 50, "KEYS", maxSpeed, startInfo.heading);
@@ -1245,12 +1286,11 @@ function performBegin(N){
workerInited = true;
}
const brains = buildBrainsBuffer(N);
- // Keep worker speed/pause aligned with main defaults. setSimSpeed() only
- // posts when the user moves the dropdown — without this, a default
- // simSpeed≠1 never reaches the worker until the first manual change.
+ // Keep worker speed aligned with main defaults. setSimSpeed() only posts
+ // when the user moves the dropdown — without this, a default simSpeed≠1
+ // never reaches the worker until the first manual change.
try {
simWorker.postMessage({ type: 'setSimSpeed', v: simSpeed });
- simWorker.postMessage({ type: 'setPause', pause: !!pause });
} catch (_) {}
simWorker.postMessage({
type: 'begin',
@@ -1259,6 +1299,12 @@ function performBegin(N){
poseJitter: Object.assign({ radiusPx: 0, angleDeg: 0, maxAttempts: 8 }, window.__poseJitter || {}),
brains
}, [brains.buffer]);
+ // Worker handleBegin always sets pause=false and starts the step loop.
+ // Re-assert the intended pause state AFTER begin so a pre-start page
+ // load keeps the swarm frozen until the user opts in.
+ try {
+ simWorker.postMessage({ type: 'setPause', pause: !!pause });
+ } catch (_) {}
// Co-start the A/B baseline worker so both canvases begin each generation
// on the same wall-clock tick. Without this, B auto-restarts on its own
// genEnd (no archive/observe/seed delay) and drifts ahead of A.
@@ -1403,13 +1449,18 @@ function performNextBatch(genData){
}
begin();
-// First-visit: keep the sim paused so the user clicks the "▶ Start Training"
-// CTA. begin() flips pause=false, so we re-pause here AFTER it runs. Phase-4
-// layout already relabels the pause button; this just makes sure the sim
-// doesn't start stepping before the user opts in.
-if (window.__firstStart){
+// Page-load gate: keep the sim paused until the user clicks Start / Demo.
+// Population build is deferred (see begin() early-return on __awaitingStart).
+if (window.__awaitingStart){
pause = true;
}
+// Honor Start clicks that arrived while classic scripts were still loading.
+if (window.__pendingStart){
+ window.__pendingStart = false;
+ try { pauseGame(); } catch (_) {}
+} else {
+ try { if (typeof syncStartOverlay === 'function') syncStartOverlay(); } catch (_) {}
+}
animate();
// -----------------------------------------------------------------------------
@@ -1623,7 +1674,11 @@ function drawCarQuad(x, y, angle){
function drawBestCar(bc){
ctx.fillStyle = bc.damaged ? "gray" : "rgb(227, 138, 15)";
drawCarQuad(bc.x, bc.y, bc.angle);
- if (bc.sensor && bc.sensor.rays && bc.sensor.rays.length){
+ // Honor DemoPresentation rays toggle when the presentation layer is
+ // present but fell through to this legacy draw path.
+ const raysOn = !(window.DemoPresentation && window.DemoPresentation.state
+ && window.DemoPresentation.state.rays === false);
+ if (raysOn && bc.sensor && bc.sensor.rays && bc.sensor.rays.length){
for (let i = 0; i < bc.sensor.rays.length; i++){
const ray = bc.sensor.rays[i];
const reading = bc.sensor.readings[i];
diff --git a/AI-Car-Racer/roadEditor.js b/AI-Car-Racer/roadEditor.js
index 0886670..186f386 100644
--- a/AI-Car-Racer/roadEditor.js
+++ b/AI-Car-Racer/roadEditor.js
@@ -4,11 +4,10 @@ class roadEditor{
this.checkPointMode=false;
this.editMode = true;
// Fresh visitors land with the Rectangle preset exactly (same walls
- // AND same 4-gate bottom-right-spawn checkpoint layout as clicking
- // "Load preset: Rectangle" from the dropdown). If TRACK_PRESETS is
- // loaded by the time we boot (it's a sibling script), mirror it;
- // otherwise fall back to the hardcoded values that match the preset
- // at the time of this file's authoring. localStorage always wins.
+ // AND same checkpoint layout as clicking "Load preset: Rectangle").
+ // If TRACK_PRESETS is loaded by the time we boot, mirror it;
+ // otherwise fall back to the hardcoded values that match the preset.
+ // localStorage always wins over both.
const rectPreset = (typeof window !== 'undefined' && window.TRACK_PRESETS && window.TRACK_PRESETS[0]) || null;
if(localStorage.getItem("trackInner") && localStorage.getItem("trackOuter")){
this.points=JSON.parse(localStorage.getItem("trackInner"));
@@ -32,13 +31,14 @@ class roadEditor{
]);
}
else{
- // Fallback mirroring Rectangle preset's 4-gate layout: bottom-right
- // spawn, right-top, top-mid, left-mid.
+ // Fallback mirroring Rectangle preset's 5-gate layout: diagonal
+ // lower/upper-right spawn pair, top-mid, left-mid, bottom-mid.
this.checkPointListEditor = [
- [{x:3100,y:1200},{x:2450,y:1200}],
- [{x:3100,y:600 },{x:2450,y:600 }],
+ [{x:3128,y:1316},{x:2418,y:1068}],
+ [{x:3135,y:376 },{x:2414,y:725 }],
[{x:1600,y:300 },{x:1600,y:700 }],
- [{x:250, y:900 },{x:650, y:900 }]
+ [{x:250, y:900 },{x:650, y:900 }],
+ [{x:1600,y:1060},{x:1600,y:1565}]
];
}
this.drag_point = -1;
diff --git a/AI-Car-Racer/sim-worker.js b/AI-Car-Racer/sim-worker.js
index 8eeadc6..91a87c9 100644
--- a/AI-Car-Racer/sim-worker.js
+++ b/AI-Car-Racer/sim-worker.js
@@ -44,22 +44,40 @@ let seconds = 15;
let startInfo = null;
let _accum = 1;
let _lastTickWall = 0;
-const MAX_STEPS = 60;
-let _loopHandle = null;
+// Soft per-tick step safety (overridden by maxStepsForSpeed at runtime).
+const MAX_STEPS = 60; // legacy name; runtime uses maxAccumForSpeed / maxStepsPerTick
let bestEpoch = 0;
-// Per-tick wall-time budget. A single tick runs steps until it's been
-// stepping for ~TICK_BUDGET_MS, then yields + posts a snapshot. Two regimes:
-// - Heavy sim (N=10k, perStep≈10ms): budget hits after ~2 steps →
-// snapshots flow at ~50Hz, visuals stay smooth. Sim throughput is
-// whatever the CPU permits; worker just doesn't hog it.
-// - Light sim (N<500, perStep<1ms): hundreds of steps fit in one budget →
-// full simSpeed reached without being throttled by setTimeout(0)'s
-// ~4ms clamp. Critical for the N≈100, simSpeed=100× "fast training"
-// use case that the old main-thread loop could hit but a naive
-// "few steps per tick" worker cannot.
+// Per-tick wall-time budget base. Scaled up with simSpeed so 100× can burn
+// real CPU instead of yielding every 20ms after only a handful of steps.
+// Heavy N still self-limits via the budget; light/mid N at high speed needs
+// a larger slice to approach the requested multiplier.
const TICK_BUDGET_MS = 20;
+// Max sim-frame backlog kept across ticks. Old hard cap of 60 dropped almost
+// all of a 100× accumulator every tick (100× × 16ms × 60fps ≈ 96 steps
+// requested, 60 kept, rest discarded) — effective rate collapsed to ~1–2×.
+function maxAccumForSpeed(ss) {
+ // Keep up to ~1s of sim-time debt so temporary main-thread stalls can
+ // catch up, without letting the queue grow without bound.
+ const s = (Number.isFinite(ss) && ss > 0) ? ss : 1;
+ return Math.max(60, Math.ceil(s * 60));
+}
+function tickBudgetForSpeed(ss) {
+ const s = (Number.isFinite(ss) && ss > 0) ? ss : 1;
+ if (s <= 2) return 16;
+ if (s <= 5) return 24;
+ if (s <= 20) return 40;
+ if (s <= 50) return 60;
+ return 100; // 100× — burn hard; main thread paints from latest snapshot only
+}
+function maxStepsPerTick(ss) {
+ // Don't artificially stop early when budget remains; allow a full
+ // second of sim per tick at high speed (budget will cut first if heavy).
+ const s = (Number.isFinite(ss) && ss > 0) ? ss : 1;
+ return Math.max(60, Math.ceil(s * 60));
+}
+
// Flat-brain layout — see brainCodec.js on the main side. Hard-coded here so
// the worker doesn't have to importScripts a module (importScripts only loads
// classic scripts). If TOPOLOGY changes upstream, bump both sides.
@@ -75,7 +93,11 @@ self.onmessage = (ev) => {
switch (m.type) {
case 'init': handleInit(m); break;
case 'begin': handleBegin(m); break;
- case 'setSimSpeed': simSpeed = m.v; break;
+ case 'setSimSpeed':
+ simSpeed = (Number.isFinite(m.v) && m.v > 0) ? m.v : 1;
+ // Avoid a huge catch-up step right after a speed jump.
+ _lastTickWall = performance.now();
+ break;
case 'setPause': handlePause(m.pause); break;
case 'setTraction': self.traction = m.v; break;
case 'setMaxSpeed': self.maxSpeed = m.v; break;
@@ -215,13 +237,25 @@ function handleBegin(m) {
_lastTickWall = performance.now();
pause = false;
startLoop();
-
self.postMessage({
type: 'debug',
event: 'beginBuilt',
N,
ms: performance.now() - _t0
});
+ // Publish one pose snapshot immediately so the main thread can paint the
+ // parked swarm even when page-load re-pauses before the first step ticks.
+ // steps=0 keeps the HUD honest ("not stepping yet"). Must run after
+ // beginBuilt so a postSnapshot throw doesn't hide the build timing.
+ try {
+ postSnapshot(0, 0);
+ } catch (e) {
+ self.postMessage({
+ type: 'debug',
+ event: 'previewSnapFail',
+ err: (e && e.message) || String(e)
+ });
+ }
}
function handlePause(p) {
@@ -255,55 +289,70 @@ function flattenBrain(brain) {
}
// --- main loop ---------------------------------------------------------------
+// MessageChannel scheduling avoids the browser's setTimeout(0) ~4ms clamp,
+// which capped throughput at ~250 ticks/s and made high simSpeed starve.
+
+const _tickChannel = new MessageChannel();
+let _loopScheduled = false;
+_tickChannel.port1.onmessage = () => {
+ _loopScheduled = false;
+ if (pause) return;
+ stepOnce();
+ // Re-arm immediately while running so the worker stays hot at high speed.
+ if (!pause) scheduleTick();
+};
+
+function scheduleTick() {
+ if (_loopScheduled) return;
+ _loopScheduled = true;
+ _tickChannel.port2.postMessage(null);
+}
function startLoop() {
- if (_loopHandle != null) return;
- const tick = () => {
- _loopHandle = setTimeout(tick, 0);
- if (pause) return;
- stepOnce();
- };
- _loopHandle = setTimeout(tick, 0);
+ scheduleTick();
}
function computeStride(ss) {
+ // Heavier LOD at high speed: non-champion AI reuse last controls more often.
if (ss <= 2) return 1;
if (ss <= 5) return 2;
- if (ss <= 20) return 3;
- return 4;
+ if (ss <= 20) return 4;
+ if (ss <= 50) return 8;
+ return 16;
}
let _prevTickEnd = 0;
const WORKER_HITCH_MS = 60;
+// At high speed, snapshot less often so postMessage packing doesn't dominate.
+let _stepsSinceSnap = 0;
function stepOnce() {
const now = performance.now();
- // Gap between end of the previous tick and start of this one. Normally
- // ~4-10ms (setTimeout(0) clamp). Large values here indicate the worker
- // thread was paused — usually GC, sometimes OS scheduling.
+ // Gap between end of the previous tick and start of this one.
const tickGap = _prevTickEnd > 0 ? now - _prevTickEnd : 0;
let dt = (now - _lastTickWall) / 1000;
_lastTickWall = now;
- if (dt > 0.25) dt = 0.25;
+ // Slightly larger clamp at high speed so a single long tick after archive
+ // work still converts into useful catch-up instead of being truncated to
+ // 0.25s of wall (which dropped most of a 100× burst under the old cap).
+ const dtCap = simSpeed >= 50 ? 0.5 : 0.25;
+ if (dt > dtCap) dt = dtCap;
_accum += simSpeed * dt * 60;
- // Cap accumulator against runaway catch-up after a stall. MAX_STEPS
- // bounds the worst-case backlog we'll try to absorb; anything beyond
- // that is dropped so sim-time falls behind wall-time gracefully.
- if (_accum > MAX_STEPS) _accum = MAX_STEPS;
- // Drain up to floor(_accum) steps, but break as soon as we've burned
- // TICK_BUDGET_MS of wall time. Remaining steps go back into the
- // accumulator and drain on subsequent ticks. This keeps snapshots
- // flowing at ~50Hz under heavy load while still letting a light sim
- // rip through hundreds of steps per tick for high simSpeed.
- let steps = Math.floor(_accum);
- _accum -= steps;
+ const accumCap = maxAccumForSpeed(simSpeed);
+ if (_accum > accumCap) _accum = accumCap;
+
+ const budgetMs = tickBudgetForSpeed(simSpeed);
+ const stepCap = maxStepsPerTick(simSpeed);
self.SENSOR_STRIDE = computeStride(simSpeed);
+ // Drain until empty, step cap, or wall budget — leftover stays in _accum
+ // for the next tick (no pre-debit of the whole queue).
const simStart = performance.now();
const cpLen = self.road.checkPointList.length;
let stepsRun = 0;
let maxStepMs = 0;
- for (let s = 0; s < steps; s++) {
+ while (_accum >= 1 && stepsRun < stepCap) {
+ if (performance.now() - simStart > budgetMs) break;
const stepStart = performance.now();
if (self.frameCount >= 60 * seconds) {
postSnapshot(performance.now() - simStart, stepsRun);
@@ -311,6 +360,7 @@ function stepOnce() {
return;
}
self.frameCount++;
+ _accum -= 1;
for (let i = 0; i < cars.length; i++) {
const cc = cars[i];
const prevDamaged = cc.damaged;
@@ -327,38 +377,54 @@ function stepOnce() {
cc.deathY = cc.y;
}
}
- // O(N) bestCar scan — mirror of main.js's pre-worker logic.
- let bestFit = -Infinity, bestC = null;
- for (let i = 0; i < cars.length; i++) {
- const c = cars[i];
- const f = c.checkPointsCount + c.laps * cpLen;
- if (f > bestFit) { bestFit = f; bestC = c; }
- }
- if (bestC) {
- const currentFit = self.bestCar
- ? (self.bestCar.checkPointsCount + self.bestCar.laps * cpLen)
- : -Infinity;
- if (self.bestCar !== bestC && bestFit > currentFit) {
+ // O(N) live-champion scan (prefer alive). At high simSpeed scan every
+ // other step — a 1-frame lag on the yellow highlight is invisible and
+ // saves a full population walk. Always scan if the current champion
+ // just died so rays hop immediately.
+ const needBestScan = simSpeed < 50
+ || (self.frameCount & 1) === 0
+ || (self.bestCar && self.bestCar.damaged);
+ if (needBestScan) {
+ let bestFitAlive = -Infinity, bestAlive = null;
+ let bestFitAll = -Infinity, bestAll = null;
+ for (let i = 0; i < cars.length; i++) {
+ const c = cars[i];
+ const f = c.checkPointsCount + c.laps * cpLen;
+ if (f > bestFitAll) { bestFitAll = f; bestAll = c; }
+ if (!c.damaged && f > bestFitAlive) { bestFitAlive = f; bestAlive = c; }
+ }
+ const bestC = bestAlive || bestAll;
+ if (bestC && self.bestCar !== bestC) {
self.bestCar = bestC;
bestEpoch++;
+ // New champion wasn't privileged this step (LOD may have skipped
+ // its perception). Refresh rays immediately so the same-tick
+ // snapshot shows sensors on the new leader, not a blank/stale set.
+ if (!bestC.damaged && bestC.sensor) {
+ try { bestC.sensor.update(self.road.borders); } catch (_) {}
+ }
}
}
stepsRun++;
const stepMs = performance.now() - stepStart;
if (stepMs > maxStepMs) maxStepMs = stepMs;
- if (performance.now() - simStart > TICK_BUDGET_MS) {
- // Budget tripped — hand remaining steps back to the accumulator
- // so next tick catches up.
- _accum += (steps - stepsRun);
- break;
- }
}
const simMs = performance.now() - simStart;
- // Always snapshot at the end of a tick. Main rAF pulls at 60Hz and
- // keeps only the latest — overshoot is dropped for free.
+ // Snapshot cadence: every tick at low speed; every ~2–3 ticks at 100× so
+ // packing N poses doesn't eat the budget we just freed for stepping.
+ _stepsSinceSnap += stepsRun;
+ const snapEvery = simSpeed >= 50 ? 3 : (simSpeed >= 20 ? 2 : 1);
+ const wantSnap = stepsRun > 0 && (
+ _stepsSinceSnap >= snapEvery ||
+ // Always snap if we did a meaningful chunk this tick.
+ stepsRun >= 30
+ );
const postStart = performance.now();
- if (stepsRun > 0) postSnapshot(simMs, stepsRun);
+ if (wantSnap) {
+ postSnapshot(simMs, stepsRun);
+ _stepsSinceSnap = 0;
+ }
const postMs = performance.now() - postStart;
const tickEnd = performance.now();
@@ -492,10 +558,20 @@ function postSnapshot(simMs, steps) {
}
function endGen() {
- if (!self.bestCar) return;
- const bc = self.bestCar;
- const flat = flattenBrain(bc.brain);
+ // Re-pick the fitness elite across the whole population — including
+ // damaged cars. Live bestCar prefers survivors for the ray overlay; the
+ // brain we archive/seed from should still be whoever got furthest.
const cpLen = self.road.checkPointList.length || 0;
+ let elite = null, eliteFit = -Infinity;
+ for (let i = 0; i < cars.length; i++) {
+ const c = cars[i];
+ const f = c.checkPointsCount + c.laps * cpLen;
+ if (f > eliteFit) { eliteFit = f; elite = c; }
+ }
+ if (!elite) return;
+ self.bestCar = elite;
+ const bc = elite;
+ const flat = flattenBrain(bc.brain);
// Population-wide stats: per-car checkpoint counts + death frames. Main
// thread derives median / p90 / survival@T percentiles from these arrays,
diff --git a/AI-Car-Racer/style.css b/AI-Car-Racer/style.css
index c9c432e..9120f98 100644
--- a/AI-Car-Racer/style.css
+++ b/AI-Car-Racer/style.css
@@ -284,7 +284,12 @@ p { line-height: 1.5; margin: 0 0 .6em 0; }
}
/* Sits at the midline between the stacked A and B canvases (top:50%) and
anchored to the left so it doesn't collide with the primary "metrics"
- HUD which is fixed at top-center of the viewport. */
+ HUD which is fixed at top-center of the viewport.
+
+ IMPORTANT: `display: flex` would otherwise override the HTML `hidden`
+ attribute (UA [hidden]{display:none} loses to an author #id rule), which
+ left empty #ab-hud-b / #ab-hud-delta chips floating on the track when A/B
+ mode was off. Always pair the flex rule with an explicit [hidden] kill. */
#ab-hud {
position: absolute;
top: calc(50% - 32px);
@@ -297,6 +302,9 @@ p { line-height: 1.5; margin: 0 0 .6em 0; }
font: 11px/1.35 ui-monospace, Menlo, monospace;
color: #a8c8ff;
}
+#ab-hud[hidden] {
+ display: none !important;
+}
#ab-hud-b, #ab-hud-delta {
background: rgba(12, 14, 18, .88);
padding: 6px 9px;
@@ -566,7 +574,7 @@ p { line-height: 1.5; margin: 0 0 .6em 0; }
}
/* ---------- Start-training CTA ----------------------------------
- On first visit the Pause button is repurposed as an explicit Start
+ On page load the Pause button is repurposed as an explicit Start
Training call-to-action. Filled amber, larger type, centered icon. */
.controlButton.start-cta {
color: #fff;
@@ -597,6 +605,78 @@ p { line-height: 1.5; margin: 0 0 .6em 0; }
.controlButton.start-cta { animation: none; }
}
+/* Canvas-centered Start overlay — reachable when the right panel is
+ collapsed. Mirrors the panel Start CTA so the main action is never
+ buried behind a hidden sidebar. */
+#startOverlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ background:
+ radial-gradient(ellipse 55% 45% at 50% 50%,
+ rgba(12, 14, 18, 0.55),
+ rgba(12, 14, 18, 0.12) 70%,
+ transparent 100%);
+}
+#startOverlay[hidden] { display: none !important; }
+.start-overlay-btn {
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ min-width: min(72vw, 280px);
+ padding: 1.15em 1.6em 1em;
+ border: 1.5px solid var(--amber-600);
+ border-radius: var(--r-xl);
+ background: linear-gradient(180deg, var(--amber-600), var(--amber-700));
+ color: #fff;
+ font-family: var(--font-body);
+ box-shadow: var(--shadow-lg), 0 0 0 0 rgba(211, 139, 75, 0.4);
+ cursor: pointer;
+ animation: start-cta-pulse 2200ms var(--ease-in-out) infinite;
+ transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
+}
+.start-overlay-btn:hover {
+ transform: translateY(-2px) scale(1.02);
+ background: linear-gradient(180deg, var(--amber-500), var(--amber-700));
+}
+.start-overlay-btn:focus-visible {
+ outline: none;
+ box-shadow: var(--ring), var(--shadow-lg);
+}
+.start-overlay-icon {
+ font-size: 1.6rem;
+ line-height: 1;
+}
+.start-overlay-label {
+ font-weight: 700;
+ font-size: 1.15rem;
+ letter-spacing: 0.03em;
+}
+.start-overlay-hint {
+ font-size: 0.72rem;
+ font-weight: 500;
+ opacity: 0.85;
+ letter-spacing: 0.01em;
+}
+.start-overlay-btn.is-starting {
+ opacity: 0.9;
+ cursor: progress;
+ animation: none;
+ transform: none;
+}
+.start-overlay-btn:disabled {
+ cursor: progress;
+}
+@media (prefers-reduced-motion: reduce) {
+ .start-overlay-btn { animation: none; }
+}
+
/* Secondary control button tint — used for Customize Track so it reads as
subordinate to the Start CTA above it. */
.controlButton.secondary {
@@ -682,7 +762,9 @@ p { line-height: 1.5; margin: 0 0 .6em 0; }
scene behind it stays legible. */
#bottomText {
position: absolute;
- left: 50%;
+ /* Slightly left of center so the cinema control chips (bottom-right)
+ never sit on top of the phase banner. */
+ left: 42%;
bottom: var(--sp-3);
transform: translateX(-50%);
padding: var(--sp-2) var(--sp-5);
@@ -694,7 +776,7 @@ p { line-height: 1.5; margin: 0 0 .6em 0; }
box-shadow: var(--shadow-sm);
color: var(--ink-900);
text-align: center;
- max-width: min(80%, 620px);
+ max-width: min(48%, 320px);
pointer-events: none;
z-index: 5;
}
@@ -2302,10 +2384,12 @@ label {
============================================================= */
#cinema-hud {
position: absolute;
- top: 10px;
- left: 10px;
+ /* Sit below the fixed help stack (Explain / Tour / GitHub) which lives
+ at viewport top-left over the canvas. Stack ≈ sp-4 + 3×(pill+gap). */
+ top: calc(var(--sp-4) + 3 * (36px + var(--sp-2)) + var(--sp-2));
+ left: 12px;
z-index: 8;
- max-width: min(92%, 560px);
+ max-width: min(72%, 420px);
padding: 8px 12px;
border-radius: 12px;
background: rgba(12, 14, 18, 0.72);
@@ -2355,17 +2439,18 @@ label {
/* Bottom-right of the track cell so we don't collide with the
bottom-left perf HUD or the centered #bottomText banner. */
position: absolute;
- bottom: 12px;
- right: 12px;
+ bottom: 14px;
+ right: 14px;
left: auto;
- z-index: 8;
+ z-index: 9;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
- max-width: min(70%, 480px);
+ max-width: min(56%, 420px);
pointer-events: auto;
}
+
#cinema-controls button {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.14);
@@ -2414,17 +2499,29 @@ label {
#cinema-hud {
font-size: 11px;
padding: 6px 9px;
- top: 6px;
- left: 6px;
+ /* On mobile the help FABs move to bottom-right, so reclaim top-left. */
+ top: 8px;
+ left: 8px;
+ max-width: min(92%, 360px);
}
#cinema-controls {
bottom: 8px;
right: 8px;
+ max-width: min(88%, 360px);
}
#cinema-controls button {
padding: 4px 9px;
font-size: 10px;
}
+ #bottomText {
+ left: 50%;
+ max-width: min(70%, 280px);
+ bottom: calc(var(--sp-3) + 36px); /* clear cinema chips */
+ }
+ .start-overlay-btn {
+ min-width: min(84vw, 260px);
+ padding: 1em 1.2em 0.85em;
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/AI-Car-Racer/trackPresets.js b/AI-Car-Racer/trackPresets.js
index f348ea1..3d5c3b9 100644
--- a/AI-Car-Racer/trackPresets.js
+++ b/AI-Car-Racer/trackPresets.js
@@ -11,11 +11,11 @@
// orientation consistent across presets, which helps the ruvector cross-
// track seed recall.
//
-// Checkpoint order (4 gates per preset): bottom-right → top-right →
-// top-mid → left-mid → back to bottom-right (CW in canvas). The 4-gate
-// layout gives a 33% denser fitness gradient than the old 3-gate one,
-// helping partial survivors get ranked before any full laps. See
-// sim-worker.js:295 for the fitness formula consuming checkPointsCount.
+// Checkpoint order (CW in canvas, spawn bottom-right): typically
+// bottom-right → top-right → top-mid → left-mid [→ bottom-mid] → lap.
+// Most presets use 4 gates; Rectangle uses 5 (extra bottom-mid) for a
+// denser fitness gradient. See sim-worker.js for fitness = checkPointsCount
+// + laps * gateCount.
//
// Data format matches roadEditor.js:
// points: inner wall, array of {x, y} — saved under `trackInner`
@@ -31,26 +31,30 @@
window.TRACK_PRESETS = [
{
name: 'Rectangle',
- description: '4-corner rectangular loop. Widest corridor, easiest to learn.',
+ description: '4-corner rectangular loop. Widest corridor, easiest to learn. 5 gates with diagonal right-lobe spawn.',
points: [
{ x: 650, y: 700 },
{ x: 2450, y: 700 },
{ x: 2450, y: 1100 },
{ x: 650, y: 1100 }
],
- // Outer right pushed to x=3100 so the start (2880,900) has ~220px
- // buffer instead of the old ~70px squeeze against x=2950.
+ // Outer right pushed to x=3100 so the start has corridor buffer.
points2: [
{ x: 250, y: 300 },
{ x: 3100, y: 300 },
{ x: 3100, y: 1500 },
{ x: 250, y: 1500 }
],
+ // Hand-tuned gates (outer→inner). Right lobe uses diagonals so spawn
+ // sits in the lower-right corridor with heading toward the upper-right
+ // gate; top/left stay axis-aligned; bottom-mid closes the loop for a
+ // denser fitness gradient than the old 4-gate layout.
checkPointListEditor: [
- [{ x: 3100, y: 1200 }, { x: 2450, y: 1200 }], // 1: right-low (spawn)
- [{ x: 3100, y: 600 }, { x: 2450, y: 600 }], // 2: right-top (above cp[0] → heading up)
+ [{ x: 3128, y: 1316 }, { x: 2418, y: 1068 }], // 1: lower-right diagonal (spawn)
+ [{ x: 3135, y: 376 }, { x: 2414, y: 725 }], // 2: upper-right diagonal (heading up)
[{ x: 1600, y: 300 }, { x: 1600, y: 700 }], // 3: top-mid
- [{ x: 250, y: 900 }, { x: 650, y: 900 }] // 4: left-mid
+ [{ x: 250, y: 900 }, { x: 650, y: 900 }], // 4: left-mid
+ [{ x: 1600, y: 1060 }, { x: 1600, y: 1565 }] // 5: bottom-mid
]
},
{
diff --git a/AI-Car-Racer/utils.js b/AI-Car-Racer/utils.js
index 7389e9e..37ae275 100644
--- a/AI-Car-Racer/utils.js
+++ b/AI-Car-Racer/utils.js
@@ -106,7 +106,7 @@ function phaseToLayout(phase){
rightPanel.innerHTML = `
Pause
✏️ Customize Track
-
🎬 Demo mode
+
🎬 Demo mode