// Whirlane core: deterministic simulation (no DOM). The browser runs it to play; the CyberMax toys Worker runs the // SAME file to replay a submitted run (seed + tap ticks) and verify the score before it goes on the leaderboard. // Only + - * / and comparisons on numbers (no Math.sin/cos), so every JS engine gives the same result. // (c) 2026 CyberMax. All rights reserved. export const HZ = 60; // fixed ticks per second export const SWITCH = 9; // ticks a lane hop takes; mid-hop the spark touches BOTH lanes export const MAX_TICKS = HZ * 600; // a run can't last more than 10 minutes export const PAD = 0.05; // spark size in radians const TAU = 6.283185307179586; function rng(seed) { let h = 0x811c9dc5; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); } let a = h >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Angular speed (radians per tick) after t ticks: starts calm, ramps up, caps. */ export function speed(t) { const w = 0.034 + t * 0.0000105; return w < 0.072 ? w : 0.072; } /** * The course: blades on the inner (0) or outer (1) lane, in absolute angle along the orbit. Generated lazily from the * seed, so the course is endless and identical for everyone with the same seed. A blade on the other lane always * leaves a gap long enough to hop at top speed, so every course can be survived with perfect timing. */ export function course(seed) { const r = rng(`whirlane:${seed}`); const list = []; let pos = 2.4, lane = r() < 0.5 ? 0 : 1, n = 0; const hop = 0.072 * SWITCH + 2 * PAD + 0.12; // radians the spark needs to hop at top speed, with a margin function more(upTo) { while (pos < upTo) { const d = n < 60 ? n / 60 : 1; // difficulty 0..1 over the first 60 blades const len = 0.34 + r() * (0.28 + 0.22 * d); // blade length grows a little const nextLane = r() < 0.62 + 0.2 * d ? 1 - lane : lane; // more forced hops as it gets harder let gap = (1.55 - 0.75 * d) * (0.8 + 0.4 * r()); if (nextLane !== lane && gap < hop) gap = hop; const b = { i: n, lane: nextLane, a0: pos, a1: pos + len }; if (r() < 0.12 * d && n > 8) { b.twin = true; } // twin blade: a short one on the other lane right after list.push(b); if (b.twin) list.push({ i: n, lane: 1 - nextLane, a0: b.a1 + hop, a1: b.a1 + hop + 0.22, small: true }); pos = (b.twin ? b.a1 + hop + 0.22 : b.a1) + gap; lane = b.twin ? 1 - nextLane : nextLane; n++; } } return { list, more }; } /** A running game. step(tap) advances one tick; returns 'hit' when the spark touches a blade. */ export function makeSim(seed) { const c = course(seed); const s = { t: 0, a: 0, lane: 1, to: 1, hop: 0, score: 0, dead: false, next: 0, course: c.list }; c.more(TAU * 2); s.step = (tap) => { if (s.dead) return 'dead'; if (tap && s.hop === 0) { s.to = 1 - s.lane; s.hop = SWITCH; } if (s.hop > 0) { s.hop--; if (s.hop === 0) s.lane = s.to; } s.a += speed(s.t); s.t++; if (c.list.length === 0 || c.list[c.list.length - 1].a0 < s.a + TAU * 1.5) c.more(s.a + TAU * 2); const hopping = s.hop > 0; let ev = ''; for (let k = s.next; k < c.list.length; k++) { const b = c.list[k]; if (b.a0 > s.a + PAD) break; if (b.a1 < s.a - PAD) { if (k === s.next) { s.next++; if (!b.small) { s.score++; ev = 'pass'; } } continue; } if (hopping || b.lane === s.lane) { s.dead = true; return 'hit'; } } if (s.t >= MAX_TICKS) { s.dead = true; return 'timeout'; } return ev; }; return s; } /** * Replay a run: taps = increasing tick numbers at which the player tapped. Returns { score, ticks, ok }. * ok is false when the tap list is malformed (not increasing, out of range, too long). */ export function simulate(seed, taps) { if (!Array.isArray(taps) || taps.length > 20000) return { score: 0, ticks: 0, ok: false }; for (let i = 0; i < taps.length; i++) { const v = taps[i]; if (!Number.isInteger(v) || v < 0 || v >= MAX_TICKS || (i && v <= taps[i - 1])) return { score: 0, ticks: 0, ok: false }; } const s = makeSim(seed); let k = 0; while (!s.dead) { const tap = k < taps.length && taps[k] === s.t; if (tap) k++; s.step(tap); } return { score: s.score, ticks: s.t, ok: true }; } /** Wordle-style bar: one block per 5 blades (max 10), colour by tier. */ export function bar(score) { const blocks = Math.min(10, Math.ceil(score / 5)); const tile = score >= 40 ? '🟪' : score >= 25 ? '🟧' : score >= 10 ? '🟨' : '🟩'; return tile.repeat(blocks) + '⬛'.repeat(10 - blocks); } // ------------------------------------------------------------------ levels (separate from the daily: own seeds, own runner) // The daily course, makeSim() and simulate() above stay exactly as they were (the toys Worker replays them). Levels use // their own seed space (`whirlane:lv:`), a finite course with a finish line, shields and gems. export const SHIELDS = 2; // hits a normal level forgives (each hit costs a star); challenge levels have none export const FLIP = 2.3; // radians ahead of the spark where a purple blade jumps to the other orbit export const RUSH = 1.35; // speed factor on gold rush arcs export const FOG = 1.75; // how far ahead blades show on night levels (drawing only; the sim is the same) export const PULSE = 80; // ticks per grow-and-shrink cycle of a pulse blade export const STAR_HITS = [2, 1, 0]; // stars by hits taken (lower is better): 0 hits 3 stars, 1 hit 2, 2 hits 1 const LV = (name, goal, w0, cap, gap, swap, o = {}) => ({ name, goal, w0, cap, gap, swap, gems: Math.ceil(goal * 0.4), ...o }); /** 30 hand-tuned levels: goal = blades to pass, w0..cap = spark speed (radians per tick), gap = room between blades, * swap = chance the next blade sits on the other orbit; twin/rush/flip/pulse = share of blades with that twist. */ export const LEVELS = [ LV('First spin', 10, 0.030, 0.034, 1.8, 0.5), LV('Two orbits', 12, 0.032, 0.037, 1.7, 0.6), LV('Quick hops', 14, 0.034, 0.040, 1.6, 0.65), LV('Spin up', 16, 0.036, 0.044, 1.5, 0.7), LV('Orbit run', 18, 0.038, 0.047, 1.45, 0.72), LV('Twin blades', 16, 0.037, 0.045, 1.6, 0.65, { twin: 0.3, twist: 'Twin blades: a short one waits on the other orbit' }), LV('Double edge', 18, 0.039, 0.048, 1.55, 0.7, { twin: 0.3 }), LV('Sharp turns', 20, 0.041, 0.050, 1.5, 0.72, { twin: 0.35 }), LV('Blade storm', 22, 0.043, 0.053, 1.45, 0.75, { twin: 0.35 }), LV('Twin trial', 30, 0.044, 0.055, 1.45, 0.75, { twin: 0.4, boss: true }), LV('Gold rush', 18, 0.040, 0.048, 1.6, 0.7, { twin: 0.2, rush: 0.4, twist: 'Gold arcs: the spark rushes faster' }), LV('Fast lane', 20, 0.041, 0.050, 1.55, 0.72, { twin: 0.25, rush: 0.45 }), LV('Slipstream', 22, 0.043, 0.052, 1.5, 0.74, { twin: 0.25, rush: 0.5 }), LV('Overdrive', 24, 0.045, 0.054, 1.45, 0.75, { twin: 0.3, rush: 0.5 }), LV('Afterburn', 26, 0.046, 0.056, 1.45, 0.76, { twin: 0.3, rush: 0.55 }), LV('Flip side', 18, 0.041, 0.049, 1.6, 0.7, { flip: 0.25, twist: 'Purple blades jump orbit as you near' }), LV('Switchback', 20, 0.042, 0.050, 1.55, 0.72, { flip: 0.3, twin: 0.2 }), LV('Mirror maze', 22, 0.043, 0.052, 1.5, 0.74, { flip: 0.3, rush: 0.35 }), LV('Trickster', 24, 0.045, 0.054, 1.45, 0.75, { flip: 0.35, twin: 0.25, rush: 0.35 }), LV('Flip trial', 34, 0.046, 0.056, 1.45, 0.76, { flip: 0.35, twin: 0.3, rush: 0.4, boss: true }), LV('Heartbeat', 20, 0.043, 0.052, 1.55, 0.72, { pulse: 0.5, twist: 'Pulse blades grow and shrink' }), LV('Breathing ring', 22, 0.045, 0.054, 1.5, 0.74, { pulse: 0.5, twin: 0.25 }), LV('Tidal', 24, 0.046, 0.056, 1.48, 0.75, { pulse: 0.45, flip: 0.25 }), LV('Pulse rush', 26, 0.047, 0.058, 1.45, 0.76, { pulse: 0.5, rush: 0.4 }), LV('Thrum', 28, 0.048, 0.060, 1.42, 0.77, { pulse: 0.4, twin: 0.3, flip: 0.25 }), LV('Night orbit', 22, 0.046, 0.056, 1.5, 0.74, { fog: true, twin: 0.25, twist: 'Night: blades appear late' }), LV('Blackout', 24, 0.047, 0.058, 1.48, 0.75, { fog: true, flip: 0.25, twin: 0.2 }), LV('Eclipse', 26, 0.048, 0.060, 1.45, 0.76, { fog: true, pulse: 0.4, rush: 0.35 }), LV('Dark star', 28, 0.049, 0.062, 1.42, 0.77, { fog: true, twin: 0.3, flip: 0.3, pulse: 0.3 }), LV('Whirl crown', 40, 0.050, 0.064, 1.4, 0.78, { fog: true, twin: 0.3, flip: 0.3, pulse: 0.35, rush: 0.4, boss: true }), ]; /** Course of level `num` (1-based): a finite list of blades, gems, gold rush arcs and the finish line. */ export function levelCourse(num, L = LEVELS[num - 1], seed = `whirlane:lv:${num}`) { const r = rng(seed); const on = (k, i) => !!L[k] && i >= ((L.unlock && L.unlock[k]) || 0); // Gauntlet: each twist joins after N blades const vmax = L.cap * (L.rush ? RUSH : 1); const hop = vmax * SWITCH + 2 * PAD + 0.12; // room to hop at this level's top speed, with a margin const list = [], gems = [], zones = []; let prevEnd = 0, prevLane = 1, rushLeft = 0; for (let i = 0; i < L.goal; i++) { const d = i / L.goal; const len = 0.34 + r() * (0.18 + 0.12 * d); let ln = i === 0 ? (num === 1 ? 1 : r() < 0.5 ? 0 : 1) : r() < L.swap ? 1 - prevLane : prevLane; let gap = L.gap * (0.85 + 0.3 * r()) * (1 - 0.15 * d); if (i > 0 && ln !== prevLane && gap < hop) gap = hop; const a0 = i === 0 ? 2.2 : prevEnd + gap; // a gem in the middle of a roomy gap: always reachable (room to hop there and away again) // gems: a roomy gap gets one in the middle on either orbit (hop there and away again); a tighter gap gets one just // after the last blade on the orbit you are already on (hop too early and you miss it). Every gem is reachable. if (i > 0 && gems.length < L.gems && r() < 0.6) { if (a0 - prevEnd >= 2 * hop + 0.1) gems.push({ a: (prevEnd + a0) / 2, lane: r() < 0.5 ? 0 : 1 }); else if (a0 - prevEnd >= hop + 0.35) gems.push({ a: prevEnd + 0.2, lane: 1 - prevLane }); } const b = { i, lane: ln, a0, a1: a0 + len }; const kind = i > 1 ? r() : 1; const fl = on('flip', i) ? L.flip : 0, pu = on('pulse', i) ? L.pulse : 0; if (fl && kind < fl) { b.flip = true; b.lane = 1 - ln; } // starts on the other orbit, jumps to `ln` at FLIP else if (pu && kind < fl + pu) { b.pulse = true; b.ph = Math.floor(r() * PULSE); } if (on('rush', i) && rushLeft === 0 && i > 1 && r() < L.rush * 0.4) { rushLeft = 3; zones.push({ a0: a0 - 0.8, a1: 0 }); } if (rushLeft > 0 && --rushLeft === 0) zones[zones.length - 1].a1 = b.a1 + 0.4; list.push(b); prevEnd = b.a1; prevLane = ln; if (on('twin', i) && i > 1 && !b.flip && r() < L.twin) { list.push({ i, lane: 1 - ln, a0: prevEnd + hop, a1: prevEnd + hop + 0.22, small: true }); prevEnd += hop + 0.22; prevLane = 1 - ln; } } if (zones.length && !zones[zones.length - 1].a1) zones[zones.length - 1].a1 = prevEnd + 0.4; return { list, gems, zones, finish: prevEnd + 0.8, hop }; } const tri = (u) => (u < 0.5 ? 2 * u : 2 - 2 * u); /** * A level run. Same controls as the daily; the run ends at the finish line ('win') or on a hit with no shield left. * opts.shields overrides the level's shields (the tests use 0 to prove a flawless 3-star run exists). */ export function makeLevelSim(num, opts = {}) { const L = opts.def || LEVELS[num - 1], c = levelCourse(num, L, opts.seed); const ramp = (L.cap - L.w0) / (L.goal * 30); const s = { level: num, t: 0, a: 0, lane: 1, to: 1, hop: 0, score: 0, dead: false, won: false, next: 0, course: c.list, gems: c.gems, zones: c.zones, finish: c.finish, goal: L.goal, got: 0, hits: 0, shields: opts.shields ?? (L.boss || L.endless ? 0 : SHIELDS), safe: 0, fog: !!L.fog, rushing: false }; let gi = 0, zi = 0; /** Current length of a blade (pulse blades breathe between 40% and 100%). */ s.len = (b) => (b.pulse ? (b.a1 - b.a0) * (0.4 + 0.6 * tri(((s.t + b.ph) % PULSE) / PULSE)) : b.a1 - b.a0); s.step = (tap) => { if (s.dead || s.won) return 'dead'; if (tap && s.hop === 0) { s.to = 1 - s.lane; s.hop = SWITCH; } if (s.hop > 0) { s.hop--; if (s.hop === 0) s.lane = s.to; } while (zi < c.zones.length && c.zones[zi].a1 <= s.a) zi++; s.rushing = zi < c.zones.length && c.zones[zi].a0 <= s.a; const w = L.w0 + s.t * ramp; const base = w < L.cap ? w : L.cap; s.a += s.rushing ? base * RUSH : base; s.t++; if (s.safe > 0) s.safe--; const hopping = s.hop > 0; let ev = ''; for (let k = s.next; k < c.list.length; k++) { const b = c.list[k]; if (b.a0 > s.a + FLIP) break; if (b.flip && !b.flipped) { b.lane = 1 - b.lane; b.flipped = true; ev = 'flip'; } } while (gi < c.gems.length && c.gems[gi].a < s.a - 0.12) gi++; for (let k = gi; k < c.gems.length && c.gems[k].a <= s.a + 0.12; k++) { const gm = c.gems[k]; if (!gm.got && !hopping && gm.lane === s.lane) { gm.got = true; s.got++; ev = 'gem'; } } for (let k = s.next; k < c.list.length; k++) { const b = c.list[k]; if (b.a0 > s.a + PAD) break; if (b.a1 < s.a - PAD) { if (k === s.next) { s.next++; if (!b.small) { s.score++; if (ev !== 'gem') ev = 'pass'; } } continue; } if (b.broken || s.safe > 0 || b.a0 + s.len(b) < s.a - PAD) continue; if (hopping || b.lane === s.lane) { s.hits++; if (s.shields > 0) { s.shields--; b.broken = true; s.safe = 30; ev = 'shield'; continue; } s.dead = true; return 'hit'; } } if (s.a >= s.finish) { s.won = true; return 'win'; } if (s.t >= MAX_TICKS) { s.dead = true; return 'timeout'; } return ev; }; return s; } /** Replay a level run (taps = increasing tick numbers). Returns { won, hits, gems, score, ticks, ok }. */ export function simulateLevel(num, taps, opts) { if (!(num >= 1 && num <= LEVELS.length) || !Array.isArray(taps)) return { won: false, hits: 0, gems: 0, score: 0, ticks: 0, ok: false }; const s = makeLevelSim(num, opts); let k = 0; while (!s.dead && !s.won) { const tap = k < taps.length && taps[k] === s.t; if (tap) k++; s.step(tap); } return { won: s.won, hits: s.hits, gems: s.got, score: s.score, ticks: s.t, ok: true }; } /** Stars for a cleared level: 3 with no hits, 2 with one, 1 with two (0 when not cleared). */ export function levelStars(won, hits) { if (!won) return 0; let st = 0; for (const v of STAR_HITS) if (hits <= v) st++; return st; } /** Share line for a level: stars and gems. */ export function levelBar(stars, gems, total) { return `${'⭐'.repeat(stars)}${'☆'.repeat(3 - stars)} 💎 ${gems}/${total}`; } // ------------------------------------------------------------------ Gauntlet (full-game mode, games lane GM 9 Oct 2026) /** Gauntlet: one long run on a fresh course every time. No shields; every twist joins in turn (twins after 10 blades, * gold rush after 25, purple flips after 45, pulse blades after 70) and the speed keeps rising. Medals at 25/50/100/150 * blades; reaching 300 is the crown. Survivable by design: every gap still leaves room to hop at the top speed. */ export const GAUNTLET = { name: 'Gauntlet', goal: 300, w0: 0.036, cap: 0.068, gap: 1.5, swap: 0.76, gems: 120, endless: true, twin: 0.3, rush: 0.4, flip: 0.3, pulse: 0.35, unlock: { twin: 10, rush: 25, flip: 45, pulse: 70 } }; export const MEDALS = [[25, '🥉', 'Bronze'], [50, '🥈', 'Silver'], [100, '🥇', 'Gold'], [150, '💠', 'Diamond'], [300, '👑', 'Crown']]; /** The best medal for a blade count: [blades, emoji, name] or null. */ export const medalFor = (n) => MEDALS.filter((m) => n >= m[0]).pop() || null; export function makeGauntletSim(seed) { return makeLevelSim(0, { def: GAUNTLET, seed: `whirlane:gauntlet:${seed}` }); }