cybermax / sledvolt /core.js
CyberMax-tools's picture
Deploy sledvolt to the hub
4c8e9de verified
Raw History Blame Contribute Delete
36.3 kB
// Sled Volt core: the seeded mountain course, the run simulation and a bot (pure, no DOM, no three.js).
// Deterministic on every device: fixed 60 Hz ticks and only + - * / floor min max abs sqrt (no sin/exp), so the
// toys Worker re-plays a posted run from its inputs and gets the same score. The Roblox version (src/Course.luau,
// src/Run.luau) is a line-by-line port of this file; tests/parity.json pins both to the same numbers.
// (c) 2026 CyberMax. All rights reserved.
export const VERSION = 1;
export const TICK = 60;
export const LANE_W = 6;
export const RACE_LEN = 3000;
export const BIOME_LEN = 1500;
export const BIOMES = ['pine', 'village', 'caves', 'aurora'];
export const BIOME_NAMES = { pine: 'Pine Forest', village: 'Frozen Village', caves: 'Ice Caves', aurora: 'Aurora Night' };
export const JUMP = { ticks: 38, apex: 5.6 };
export const RAMP = { ticks: 64, apex: 11 };
export const SKY_Y = 16;
export const RAIL_Y = 2.2;
export const POWERS = ['magnet', 'shield', 'double', 'glider', 'gift'];
export const POWER_TICKS = { magnet: 480, shield: 720, double: 600, glider: 360 };
export const BOOST_TICKS = 150;
export const FROST_PER = 14;
export const BEAST_TICKS = 420;
const G_JUMP = 8 * JUMP.apex / (JUMP.ticks * JUMP.ticks);
const V_JUMP = G_JUMP * JUMP.ticks / 2;
const G_RAMP = 8 * RAMP.apex / (RAMP.ticks * RAMP.ticks);
const V_RAMP = G_RAMP * RAMP.ticks / 2;
const SCAN = 34;
/** Forward speed in units per second at distance d (40 at the start, 68 at 9,000, approaches 96). */
export function speedAt(d) { return 40 + 56 * d / (d + 9000); }
export function biomeAt(z) { return BIOMES[Math.floor(z / BIOME_LEN) % 4]; }
export function lapAt(z) { return Math.floor(z / (BIOME_LEN * 4)); }
export function tierAt(z) { return Math.min(5, Math.floor(z / 900)); }
/** Daily seed: the same course for every player on a UTC day. */
export function dailySeed(dayNumber) { return (dayNumber * 7919 + 17) % 2147483646 + 1; }
// MINSTD (Park-Miller): every product stays below 2^47, exact in doubles in JS and Luau.
const M = 2147483647;
export function seedOf(n) { let s = Math.floor(Math.abs(n)) % (M - 1); return s + 1; }
function rnd(c) { c.s = (c.s * 48271) % M; return c.s / M; }
function pick(c, n) { return Math.floor(rnd(c) * n); }
function weighted(c, ws) {
let t = 0; for (let i = 0; i < ws.length; i++) t += ws[i];
let r = rnd(c) * t;
for (let i = 0; i < ws.length; i++) { r -= ws[i]; if (r < 0) return i; }
return ws.length - 1;
}
// ------------------------------------------------------------------ obstacle kinds
// c: 'b' = solid up to height h (jump over it if h is low), 'h' = high bar (duck under, 2.0 to 4.0), 'g' = gap (be in
// the air), 's' = stumble (snow drift: slows you and wakes the Rimehorn). m: moving visual ('sled', 'ball').
export const KINDS = {
tree: { c: 'b', h: 12 }, rock: { c: 'b', h: 2.8 }, log: { c: 'b', h: 1.5 }, branch: { c: 'h' }, bush: { c: 's' },
fence: { c: 'b', h: 2.0 }, snowman: { c: 'b', h: 6.5 }, sledder: { c: 'b', h: 3.5, m: 'sled' }, post: { c: 'b', h: 9 },
banner: { c: 'h' }, drift: { c: 's' }, pillar: { c: 'b', h: 14 }, crystal: { c: 'b', h: 2.8 }, icicles: { c: 'h' },
icelog: { c: 'b', h: 1.5 }, boulder: { c: 'b', h: 2.8 }, ball: { c: 'b', h: 4.4, m: 'ball' }, pylon: { c: 'b', h: 13 },
gap: { c: 'g' }, hole: { c: 'g' },
};
const MIX = {
pine: [['tree', 40], ['rock', 25], ['log', 15], ['branch', 12], ['bush', 8]],
village: [['fence', 25], ['snowman', 25], ['sledder', 20], ['post', 10], ['banner', 12], ['drift', 8]],
caves: [['pillar', 30], ['crystal', 25], ['icicles', 25], ['drift', 10], ['icelog', 10]],
aurora: [['tree', 25], ['boulder', 25], ['fence', 15], ['branch', 15], ['sledder', 12], ['drift', 8]],
};
const LOWS = { pine: 'log', village: 'fence', caves: 'icelog', aurora: 'fence' };
const HIGHS = { pine: 'branch', village: 'banner', caves: 'icicles', aurora: 'branch' };
const SPECIALS = { pine: ['skilift', 'avalanche'], village: ['street', 'bridge'], caves: ['tunnel', 'tunnel', 'lake'], aurora: ['avalanche', 'bridge', 'lake'] };
const SHAPES = [[70, 30, 0, 0], [55, 40, 5, 0], [40, 45, 10, 5], [30, 45, 15, 10], [22, 45, 20, 13], [15, 45, 25, 15]];
function mixKind(c, biome) {
const m = MIX[biome]; const ws = [];
for (let i = 0; i < m.length; i++) ws.push(m[i][1]);
return m[weighted(c, ws)][0];
}
/** Can a lane with this kind be passed without changing lane? (by jumping, ducking, or riding through a stumble) */
export function passable(k) { const d = KINDS[k]; return d.c !== 'b' || d.h <= 2.8 || d.m === 'ball'; }
// ------------------------------------------------------------------ course
/** A course is generated lazily, in z order, as the run needs it. Same seed = same course everywhere. */
export function makeCourse(seed) {
const c = { seed: seedOf(seed), s: seedOf(seed), items: [], pending: [], longs: [], sky: [], decor: [], id: 0, z: 0, rows: 0,
nextPow: 420, nextSpecial: 700, special: null, lastWall: -9, keep: null, genTo: 0, railEnd: 0 };
rnd(c); rnd(c);
return c;
}
function add(c, list, it) { c.id += 1; it.id = c.id; list.push(it); return it; }
function addItem(c, k, l, z, extra) {
const it = { k, l, z, e: z + (k === 'gap' || k === 'hole' ? 9 : k === 'sledder' ? 3 : 1.6) };
if (extra) for (const key in extra) it[key] = extra[key];
return add(c, c.pending, it);
}
const byZ = (a, b) => (a.z < b.z ? -1 : a.z > b.z ? 1 : a.id - b.id);
/** Move pending items up to z into the course list, in strict (z, id) order. */
function flush(c, z) {
c.pending.sort(byZ);
let n = 0;
while (n < c.pending.length && c.pending[n].z <= z) { c.items.push(c.pending[n]); n++; }
c.pending.splice(0, n);
}
function laneOk(c, l, z) { return !(c.keep && c.keep.l === l && z <= c.keep.until); }
/** Lane kinds for one row: an array of 3 (lane -1, 0, 1) with a kind name or '' for empty. */
function rowShape(c, z, biome, tier) {
const row = ['', '', ''];
if (z < 300) { row[pick(c, 3)] = mixKind(c, biome); return row; }
let shape = weighted(c, SHAPES[tier]);
if (shape === 3 && c.rows - c.lastWall < 4) shape = 1;
if (shape === 0) row[pick(c, 3)] = mixKind(c, biome);
else if (shape === 1) { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = mixKind(c, biome); }
else if (shape === 2) {
for (let i = 0; i < 3; i++) row[i] = mixKind(c, biome);
if (!passable(row[0]) && !passable(row[1]) && !passable(row[2])) row[pick(c, 3)] = LOWS[biome];
} else {
c.lastWall = c.rows;
const k = rnd(c) < 0.5 ? LOWS[biome] : HIGHS[biome];
for (let i = 0; i < 3; i++) row[i] = k;
}
return row;
}
function specialRow(c, sp, z, biome) {
const row = ['', '', ''];
const t = sp.type;
if (t === 'skilift') { row[sp.n % 2 === 0 ? 0 : 2] = 'pylon'; if (rnd(c) < 0.35) row[1] = 'rock'; }
else if (t === 'avalanche') { const a = pick(c, 3); row[a] = 'ball'; if (rnd(c) < 0.4) row[(a + 1 + pick(c, 2)) % 3] = 'ball'; }
else if (t === 'street') {
if (sp.n % 3 === 2) { row[0] = 'fence'; row[1] = 'fence'; row[2] = 'fence'; c.lastWall = c.rows; }
else { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = rnd(c) < 0.5 ? 'snowman' : 'sledder'; }
} else if (t === 'bridge') { const solid = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== solid && rnd(c) < 0.7) row[i] = 'gap'; }
else if (t === 'tunnel') { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = rnd(c) < 0.55 ? 'icicles' : 'pillar'; if (sp.n % 4 === 3) { row[0] = 'icicles'; row[1] = 'icicles'; row[2] = 'icicles'; } }
else if (t === 'lake') { const free = pick(c, 3); for (let i = 0; i < 3; i++) if (i !== free) row[i] = rnd(c) < 0.6 ? 'hole' : 'crystal'; }
return row;
}
function startSpecial(c, z, biome) {
const opts = SPECIALS[biome];
const type = opts[pick(c, opts.length)];
const len = type === 'tunnel' ? 260 : type === 'bridge' ? 170 : 230;
c.special = { type, z0: z, z1: z + len, n: 0 };
add(c, c.decor, { k: 'sign', l: 0, z: z - 120, e: z - 120, t: type });
add(c, c.longs, { k: 'zone', l: 0, z, e: z + len, t: type });
if (type === 'lake') add(c, c.longs, { k: 'ice', l: 0, z, e: z + len });
c.nextSpecial = z + len + 550 + pick(c, 250);
}
/** Generate rows until the course reaches zMax. */
export function genUntil(c, zMax) {
while (c.z < zMax) genRow(c);
c.genTo = c.z;
}
function genRow(c) {
const z0 = c.z;
const tier = tierAt(z0);
const v = speedAt(z0);
let gap = c.rows === 0 ? 60 : z0 < 300 ? v * 1.2 : v * (0.62 + (0.9 - tier * 0.12) * rnd(c));
if (c.prevWall || c.prevGap) gap = Math.max(gap, v * 0.95);
if (c.special) gap = Math.max(gap, v * 0.7);
if (c.railEnd) { gap = Math.max(gap, c.railEnd - z0 + v * 0.6); c.railEnd = 0; } // time to come off a rail
const z1 = z0 + gap;
const biome = biomeAt(z1);
if (!c.special && z1 >= c.nextSpecial && z1 > 400) startSpecial(c, z1, biome);
let row;
if (c.special && z1 < c.special.z1) { row = specialRow(c, c.special, z1, biome); c.special.n += 1; }
else { if (c.special) c.special = null; row = rowShape(c, z1, biome, tier); }
// a ramp's flight lane stays clear of tall things
for (let i = 0; i < 3; i++) if (row[i] && !laneOk(c, i - 1, z1)) row[i] = '';
c.prevWall = row[0] !== '' && row[0] === row[1] && row[1] === row[2];
c.prevGap = row[0] === 'gap' || row[1] === 'gap' || row[2] === 'gap' || row[0] === 'hole' || row[1] === 'hole' || row[2] === 'hole';
const free = [];
for (let i = 0; i < 3; i++) if (row[i] === '') free.push(i - 1);
gapContent(c, z0, z1, row, free, biome);
for (let i = 0; i < 3; i++) {
const k = row[i];
if (!k) continue;
const l = i - 1;
if (k === 'sledder') {
const l0 = rnd(c) < 0.4 ? Math.max(-1, Math.min(1, l + (rnd(c) < 0.5 ? -1 : 1))) : l;
addItem(c, k, l, z1, { l0: l0 === l ? l : l0 });
} else if (k === 'ball') addItem(c, k, l, z1, { x0: rnd(c) < 0.5 ? -20 : 20 });
else addItem(c, k, l, z1);
}
c.rows += 1;
c.z = z1;
flush(c, z1);
}
function gapContent(c, z0, z1, row, free, biome) {
const span = z1 - z0;
if (c.rows === 0 || span < 18) return;
const v = speedAt(z0);
// power-up in a free lane, half way
if (z1 >= c.nextPow && free.length > 0) {
const l = free[pick(c, free.length)];
const ws = [25, 20, 20, biome === 'caves' || (c.special && c.special.type === 'tunnel') ? 0 : 15, 20];
const p = POWERS[weighted(c, ws)];
const zp = z0 + span * 0.5;
addItem(c, 'pow', l, zp, { p });
c.nextPow = zp + 380 + pick(c, 180);
if (p === 'glider') skyCoins(c, zp + 24, zp + v * 6.4);
return;
}
const r = rnd(c);
if (r < 0.1 && free.length > 0 && !c.special && span > 40) { // ramp: launch over the next row
const l = free[pick(c, free.length)];
const zr = z1 - 16;
addItem(c, 'ramp', l, zr);
const fly = RAMP.ticks * speedAt(zr) / TICK;
c.keep = { l, until: zr + fly + speedAt(zr) * 0.5 };
for (let i = 1; i <= 7; i++) {
const t = i / 8; const y = 4 * RAMP.apex * t * (1 - t) + 1.2;
addItem(c, 'coin', l, zr + fly * t, { y });
}
return;
}
if (r < 0.2 && free.length + countLow(row) > 0 && !c.special && biome !== 'caves' && span > 34) { // grind rail
const lanes = [];
for (let i = 0; i < 3; i++) if (row[i] === '' || (KINDS[row[i]].c === 'b' && KINDS[row[i]].h <= 2)) lanes.push(i - 1);
const l = lanes[pick(c, lanes.length)];
const zs = z0 + 8; const ze = z1 + Math.min(16, span * 0.4);
add(c, c.longs, { k: 'rail', l, z: zs, e: ze });
c.railEnd = ze;
for (let z = zs + 4; z < ze - 2; z += 4.5) addItem(c, 'coin', l, z, { y: RAIL_Y + 1.4 });
return;
}
if (r < 0.36) { addItem(c, 'frost', free.length ? free[pick(c, free.length)] : 0, z0 + span * 0.5, { y: 1.4 }); return; }
if (free.length > 0) { // coin line that shows the safe lane
const l = free[pick(c, free.length)];
const n = Math.min(6, Math.floor((span - 10) / 4.5));
for (let i = 0; i < n; i++) addItem(c, 'coin', l, z1 - 6 - (n - 1 - i) * 4.5, { y: 1.2 });
} else { // a wall: coins arc over the middle
for (let i = -2; i <= 2; i++) {
const t = (i + 2.5) / 5;
addItem(c, 'coin', 0, z1 + i * 3.4, { y: 1.2 + 4 * (JUMP.apex - 0.6) * t * (1 - t) });
}
}
}
function countLow(row) { let n = 0; for (let i = 0; i < 3; i++) if (row[i] && KINDS[row[i]].c === 'b' && KINDS[row[i]].h <= 2) n++; return n; }
function skyCoins(c, za, zb) {
let n = 0;
for (let z = za; z < zb; z += 7) { add(c, c.sky, { k: 'coin', l: (n % 6 < 3 ? (n % 3) - 1 : 1 - (n % 3)), z, e: z, y: SKY_Y + 0.6 }); n++; }
}
/** Where a moving thing is drawn (x) when the player is at distance d. Collisions use its final lane at z. */
export function itemX(it, d) {
const x1 = it.l * LANE_W;
if (it.k === 'sledder' && it.l0 !== undefined && it.l0 !== it.l) {
const t = Math.max(0, Math.min(1, (d - (it.z - 130)) / 30));
return it.l0 * LANE_W + (x1 - it.l0 * LANE_W) * t * t * (3 - 2 * t);
}
if (it.k === 'ball') { const t = Math.max(0, Math.min(1, (d - (it.z - 75)) / 75)); return it.x0 + (x1 - it.x0) * t; }
return x1;
}
/** Where a sledder is drawn along the course (it moves forward at 45% of your speed until you meet). */
export function itemZ(it, d) { if (it.k === 'sledder' && d < it.z) return it.z - (it.z - d) * 0.45; return it.z; }
// ------------------------------------------------------------------ run
export function powerTicks(p, level) { return POWER_TICKS[p] + Math.min(300, Math.floor(Math.max(0, level - 1) / 2) * 30); }
/** opts: { level (Mission Level = score multiplier, 1 for daily/race), finish (race length or 0) } */
export function newRun(seed, opts = {}) {
const course = makeCourse(seed);
genUntil(course, 400);
return {
course, tick: 0, d: 0, v: 40, lane: 0, x: 0, fromX: 0, laneT: 99, laneDur: 9, y: 0, vy: 0, g: 0, air: '', duck: 0,
grind: 0, ice: false, coins: 0, score: 0, frost: 0, level: Math.max(1, Math.min(30, Math.floor(opts.level || 1))),
finish: opts.finish || 0, finished: 0, alive: true, cause: '', pw: { magnet: 0, shield: 0, double: 0, glider: 0 },
boost: 0, beast: 0, slow: 0, grace: 0, combo: 1, comboT: 0, streak: 0, streakT: 0, buf: '', bufT: 0, lastLaneT: -99,
jumpT: -99, duckT0: -99, done: [], lo: 0, llo: 0, slo: 0,
st: { jumps: 0, ducks: 0, grind: 0, near: 0, boosts: 0, powers: 0, smash: 0, coins: 0, ramps: 0, cleanT: 0, bestClean: 0, stumbles: 0 },
ev: [],
};
}
function ev(r, type, data) { if (!r.quiet) r.ev.push(data ? { type, ...data } : { type }); }
function multOf(r) { return r.level * (r.pw.double > 0 ? 2 : 1) * (r.boost > 0 ? 2 : 1); }
function bonus(r, pts, type) { const p = pts * multOf(r) * r.combo; r.score += p; ev(r, type, { pts: p }); }
function comboUp(r) { r.combo = Math.min(5, r.combo + 1); r.comboT = 0; }
function tryInput(r, a) {
if (a === 'L' || a === 'R') {
const target = Math.max(-1, Math.min(1, r.lane + (a === 'L' ? -1 : 1)));
if (target === r.lane) { ev(r, 'edge'); return true; }
r.fromX = r.x; r.lane = target; r.laneT = 0; r.laneDur = r.ice ? 18 : 9; r.lastLaneT = r.tick;
ev(r, 'lane');
return true;
}
if (a === 'U') {
if (r.pw.glider > 0) return true;
if (r.air === '' || r.grind) { r.air = 'jump'; r.vy = V_JUMP; r.g = G_JUMP; r.duck = 0; r.grind = 0; r.jumpT = r.tick; r.st.jumps++; ev(r, 'jump'); return true; }
return false;
}
if (a === 'D') {
if (r.pw.glider > 0) return true;
if (r.air !== '' && !r.grind) { r.vy = Math.min(r.vy, -1.6); r.g = G_JUMP; r.duck = 40; ev(r, 'slam'); return true; }
r.grind = 0; r.duck = 36; r.duckT0 = r.tick; r.st.ducks++; ev(r, 'duck'); return true;
}
if (a === 'B') {
if (r.frost >= 100 && r.boost <= 0) { r.frost = 0; r.boost = BOOST_TICKS; r.st.boosts++; ev(r, 'boost'); }
return true;
}
return true;
}
function hit(r, it, kind) { // returns true when the run continues
r.done[it.id] = true;
if (r.boost > 0) { r.st.smash++; bonus(r, 50, 'smash'); ev(r, 'smashed', { id: it.id }); return true; }
if (r.pw.shield > 0) { r.pw.shield = 0; r.grace = 40; r.st.smash++; ev(r, 'shieldbreak', { id: it.id }); if (kind === 'g') { r.air = 'jump'; r.vy = V_JUMP; r.g = G_JUMP; } return true; }
r.alive = false; r.cause = kind === 'g' ? 'fell' : it.k; ev(r, 'crash', { id: it.id, k: it.k });
return false;
}
function stumble(r, it) {
if (it) r.done[it.id] = true;
if (r.boost > 0) { if (it) { r.st.smash++; bonus(r, 50, 'smash'); } return; }
r.st.stumbles++; r.st.cleanT = 0; r.slow = 30; r.combo = 1;
if (r.beast > 0) { r.alive = false; r.cause = 'beast'; ev(r, 'caught'); return; }
r.beast = BEAST_TICKS; ev(r, 'stumble', { id: it ? it.id : 0 });
}
/** Advance one tick. action: '', 'L', 'R', 'U', 'D' or 'B'. Fills r.ev with this tick's events. */
export function step(r, action) {
r.ev.length = 0;
if (!r.alive || r.finished) return r;
const c = r.course;
r.tick += 1;
if (action) { r.buf = action; r.bufT = 7; }
if (r.buf) { if (tryInput(r, r.buf)) r.buf = ''; else if (--r.bufT <= 0) r.buf = ''; }
// speed
const base = speedAt(r.d);
let mul = 1;
if (r.duck > 0 && r.air === '') mul += 0.12;
if (r.boost > 0) mul += 0.5;
if (r.ice) mul += 0.1;
if (r.slow > 0) { mul -= 0.3; r.slow -= 1; }
r.v = base * mul;
const dPrev = r.d;
r.d += r.v / TICK;
if (c.genTo < r.d + 320) genUntil(c, r.d + 420);
// lateral
if (r.laneT < r.laneDur) {
r.laneT += 1;
const t = r.laneT / r.laneDur;
r.x = r.fromX + (r.lane * LANE_W - r.fromX) * t * t * (3 - 2 * t);
} else r.x = r.lane * LANE_W;
// vertical
if (r.pw.glider > 0) {
r.pw.glider -= 1; r.y += (SKY_Y - r.y) * 0.08; r.air = 'glide'; r.vy = 0; r.grind = 0;
if (r.pw.glider === 0) { r.air = 'jump'; r.g = G_JUMP; r.vy = 0; r.grace = 80; ev(r, 'glideEnd'); }
} else if (r.grind) {
r.y = RAIL_Y;
} else if (r.air !== '') {
r.y += r.vy; r.vy -= r.g;
if (r.y <= 0) {
const was = r.air; r.y = 0; r.vy = 0; r.air = '';
ev(r, 'land', { from: was });
if (was === 'ramp') { r.st.ramps++; bonus(r, 50, 'stunt'); }
}
}
if (r.duck > 0 && r.air === '') r.duck -= 1;
// long things: ice, rails
r.ice = false;
let onRail = 0;
while (r.llo < c.longs.length && c.longs[r.llo].e < r.d - 300) r.llo++;
for (let i = r.llo; i < c.longs.length; i++) {
const L = c.longs[i];
if (L.z > r.d + 300) break;
if (L.e < r.d - 2 || L.z > r.d + 1) continue;
if (L.k === 'ice') r.ice = true;
if (L.k === 'rail' && Math.abs(r.x - L.l * LANE_W) < 2.2 && r.y <= RAIL_Y + 0.6 && r.pw.glider <= 0 && !(r.air === 'jump' && r.vy > 0)) onRail = L.id;
}
if (onRail) {
if (!r.grind) { r.grind = onRail; r.air = ''; r.vy = 0; r.y = RAIL_Y; ev(r, 'grind'); }
const gd = r.d - dPrev;
r.st.grind += gd; r.score += 3 * gd * multOf(r) * r.combo;
} else if (r.grind) { r.grind = 0; r.air = 'jump'; r.vy = 0; r.g = G_JUMP; ev(r, 'grindEnd'); }
// obstacles and pickups
while (r.lo < c.items.length && c.items[r.lo].e < r.d - 4) r.lo++;
const isDuck = r.duck > 0 && r.y < 0.5;
for (let i = r.lo; i < c.items.length; i++) {
const it = c.items[i];
if (it.z > r.d + SCAN) break; // items are in strict z order (flush)
if (r.done[it.id]) continue;
const k = it.k;
if (k === 'coin' || k === 'frost' || k === 'pow') {
const dz = it.z - r.d;
const magnet = k === 'coin' && r.pw.magnet > 0 && dz > -2 && dz < 30;
const near = Math.abs(dz) < 1.8 && Math.abs(r.x - it.l * LANE_W) < 2.4 && Math.abs(r.y - (it.y || 1.2)) < 2.6;
if (magnet || near) collect(r, it);
continue;
}
if (k === 'ramp') {
if (r.air === '' && !r.grind && it.z <= r.d && it.z > dPrev - 0.01 && Math.abs(r.x - it.l * LANE_W) < 2.6 && r.pw.glider <= 0) {
r.done[it.id] = true; r.air = 'ramp'; r.vy = V_RAMP; r.g = G_RAMP; r.duck = 0; ev(r, 'ramp');
}
continue;
}
const def = KINDS[k];
if (!def) continue;
if (r.grace > 0 && it.z - 1.2 <= r.d) { r.done[it.id] = true; continue; }
if (it.z - 1.2 > r.d || it.e + 1.2 < dPrev) {
if (it.e + 1.2 < r.d && !r.done[it.id]) { r.done[it.id] = true; nearMiss(r, it, def); }
continue;
}
const dx = Math.abs(r.x - it.l * LANE_W);
if (dx >= 3.2) continue;
if (def.c === 'g') {
if (r.y <= 0.05 && !r.grind && r.pw.glider <= 0 && dx < 2.6) { if (!hit(r, it, 'g')) return r; }
continue;
}
if (def.c === 's') { if (r.y < 1) stumble(r, it); else r.done[it.id] = true; if (!r.alive) return r; continue; }
if (def.c === 'h') {
if (isDuck || r.y > 4.0) continue;
} else if (r.y > def.h) continue;
if (dx < 1.7) { if (!hit(r, it, def.c)) return r; }
else { // side-swipe: bounce back to the lane you came from
r.done[it.id] = true;
const back = Math.floor(r.fromX / LANE_W + 0.5);
r.fromX = r.x; r.lane = Math.max(-1, Math.min(1, back)); r.laneT = 0; r.laneDur = 9;
ev(r, 'bump', { id: it.id });
stumble(r, null);
if (!r.alive) return r;
}
}
if (r.pw.glider > 0 || r.y > 10) {
while (r.slo < c.sky.length && c.sky[r.slo].z < r.d - 500) r.slo++;
for (let i = r.slo; i < c.sky.length; i++) {
const it = c.sky[i];
if (it.z > r.d + SCAN) break;
if (r.done[it.id] || it.z < r.d - 3) continue;
if (Math.abs(it.z - r.d) < 2.2 && Math.abs(r.x - it.l * LANE_W) < 3 && Math.abs(r.y - it.y) < 3.5) collect(r, it);
}
}
// timers
if (r.pw.magnet > 0) r.pw.magnet -= 1;
if (r.pw.shield > 0) r.pw.shield -= 1;
if (r.pw.double > 0) r.pw.double -= 1;
if (r.boost > 0) { r.boost -= 1; if (r.boost === 0) { r.grace = Math.max(r.grace, 30); ev(r, 'boostEnd'); } } // half a second to react after VOLT
if (r.grace > 0) r.grace -= 1;
if (r.beast > 0) { r.beast -= 1; if (r.beast === 0) ev(r, 'beastGone'); }
r.comboT += 1; if (r.comboT > 180 && r.combo > 1) { r.combo = 1; ev(r, 'comboEnd'); }
r.streakT += 1; if (r.streakT > 60) r.streak = 0;
r.st.cleanT += 1; if (r.st.cleanT > r.st.bestClean) r.st.bestClean = r.st.cleanT;
// score: distance x multiplier
r.score += (r.d - dPrev) * multOf(r);
if (r.finish && r.d >= r.finish) { r.finished = r.tick; ev(r, 'finish'); }
return r;
}
function collect(r, it) {
r.done[it.id] = true;
if (it.k === 'coin') {
r.coins += 1; r.st.coins += 1; r.streak += 1; r.streakT = 0;
if (r.streak % 10 === 0) comboUp(r);
ev(r, 'coin', { id: it.id, n: r.streak });
} else if (it.k === 'frost') { r.frost = Math.min(100, r.frost + FROST_PER); ev(r, 'frost', { id: it.id }); }
else if (it.k === 'pow') {
r.st.powers += 1;
let p = it.p;
if (p === 'gift') {
const g = (it.id * 7 + r.course.seed) % 6;
if (g < 3) { const n = 25 + g * 50 + (g === 2 ? 25 : 0); r.coins += n; ev(r, 'gift', { id: it.id, coins: n }); return; }
if (g === 3) { r.frost = 100; ev(r, 'gift', { id: it.id, frost: 1 }); return; }
p = g === 4 ? 'magnet' : 'shield';
ev(r, 'gift', { id: it.id, p });
}
r.pw[p] = powerTicks(p, r.level);
if (p === 'glider') { r.grind = 0; r.duck = 0; }
ev(r, 'power', { id: it.id, p });
}
}
function nearMiss(r, it, def) {
const dx = Math.abs(r.x - it.l * LANE_W);
let close = false;
if (def.c === 'b' && def.h > 2.8 && dx > 3.2 && dx < 7 && r.tick - r.lastLaneT < 18) close = true;
if (def.c === 'b' && def.h <= 2.8 && dx < 3.2 && r.tick - r.jumpT < 16) close = true;
if (def.c === 'h' && dx < 3.2 && r.tick - r.duckT0 < 16) close = true;
if (def.c === 'g' && dx < 3.2 && r.tick - r.jumpT < 10) close = true;
if (close) { r.st.near += 1; comboUp(r); bonus(r, 25, 'near'); }
}
/** Replay inputs: [[tick, action], ...] sorted by tick. Stops at a crash, a finish or maxTicks. */
export function replay(seed, inputs, opts = {}) {
const r = newRun(seed, opts);
r.quiet = !!opts.quiet;
const max = opts.maxTicks || 60 * 60 * 20;
let j = 0;
while (r.alive && !r.finished && r.tick < max) {
const t = r.tick + 1;
let a = '';
while (j < inputs.length && inputs[j][0] < t) j++;
if (j < inputs.length && inputs[j][0] === t) { a = inputs[j][1]; j++; }
step(r, a);
}
return r;
}
export function finalScore(r) { return Math.floor(r.score); }
// ------------------------------------------------------------------ inputs <-> compact string (links, board posts)
const ACTS = 'LRUDB';
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
/** Encode inputs as base64url of (delta-tick varint, action) pairs. ~2 chars per input. */
export function encodeInputs(inputs) {
const bytes = []; let last = 0;
for (const [t, a] of inputs) {
let dt = t - last; last = t;
const code = ACTS.indexOf(a);
let v = dt * 5 + code;
while (v >= 32) { bytes.push(32 | (v & 31)); v = Math.floor(v / 32); }
bytes.push(v);
}
let s = ''; for (const b of bytes) s += B64[b];
return s;
}
export function decodeInputs(s) {
const out = []; let last = 0, v = 0, mul = 1;
if (typeof s !== 'string' || s.length > 40000) return null;
for (const ch of s) {
const b = B64.indexOf(ch);
if (b < 0) return null;
v += (b & 31) * mul; mul *= 32;
if (!(b & 32)) {
const code = v % 5; const dt = Math.floor(v / 5);
last += dt; out.push([last, ACTS[code]]); v = 0; mul = 1;
if (dt === 0) return null; // two inputs in one tick: not from a real run
}
}
return out;
}
/** Re-play a posted daily run (used by the toys Worker): { ok, score, d, ticks }. */
export function verifyRun(seed, encoded, opts = {}) {
const inputs = decodeInputs(encoded);
if (!inputs || inputs.length > 20000) return { ok: false };
const r = replay(seed, inputs, { level: 1, quiet: true, maxTicks: Math.min(opts.maxTicks || 1e9, 60 * 60 * 30) });
return { ok: true, score: finalScore(r), d: Math.floor(r.d), ticks: r.tick, alive: r.alive };
}
/** Daily board entry point for the toys Worker: d = YYYY-MM-DD (the game's daily date), taps = encoded inputs.
* Day #1 = 2026-10-02 (the CyberMax daily epoch). Runs are capped at 6 minutes so a re-play stays cheap. */
export function boardRun(d, taps) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(d || ''));
if (!m) return { ok: false };
const day = Math.round((Date.UTC(+m[1], +m[2] - 1, +m[3]) - Date.UTC(2026, 9, 2)) / 864e5) + 1;
const v = verifyRun(dailySeed(day), taps, { maxTicks: 60 * 60 * 6 });
return v.ok ? { ok: true, score: v.score } : { ok: false };
}
// ------------------------------------------------------------------ bot (tests, the attract mode and the trailer)
/** One tick of a careful bot: returns '', 'L', 'R', 'U', 'D' or 'B'. skill 0..1 (1 = never misses). */
export function bot(r, skill = 1, roll = Math.random) {
const c = r.course;
if (r.frost >= 100 && r.boost <= 0) return 'B';
if (r.pw.glider > 0) return '';
const v = r.v;
// rows ahead: threat per lane
const rows = [];
for (let i = r.lo; i < c.items.length; i++) {
const it = c.items[i];
if (it.z > r.d + v * 1.6) break;
if (r.done[it.id] || it.e < r.d - 1) continue;
const def = KINDS[it.k];
if (!def) continue;
let row = rows.find((w) => Math.abs(w.z - it.z) < 6);
if (!row) { row = { z: it.z, lanes: ['', '', ''], len: [0, 0, 0] }; rows.push(row); }
const need = def.c === 'g' ? 'jump' : def.c === 'h' ? 'duck' : def.c === 's' ? 'jump' : (def.h <= 4.4 ? 'jump' : 'block');
row.lanes[it.l + 1] = need;
row.len[it.l + 1] = it.e - it.z;
}
const cur = r.lane;
// in the air you can't jump again: a jump-row just after the landing point is a wall
if (r.air !== '' && !r.grind) {
let t = 0, y = r.y, vy = r.vy;
while (y > 0 && t < 200) { y += vy; vy -= r.g; t++; }
const landD = r.d + t * v / TICK;
for (const row of rows) for (let i = 0; i < 3; i++) if (row.lanes[i] === 'jump' && row.z - (r.air === 'ramp' ? r.d + 1 : landD - 3) > 0 && row.z - landD < v * 8 / TICK + 1.5) row.lanes[i] = 'block';
}
const here = rows.filter((w) => w.z - 1.5 <= r.d);
const ahead = rows.filter((w) => w.z - 1.5 > r.d);
rows.length = 0; rows.push(...ahead);
const under = (l) => here.some((w) => w.lanes[l + 1] !== '');
let target = cur;
const commit = rows.length && rows[0].lanes[cur + 1] !== 'block' && (rows[0].z - 1.5 - r.d) / (v / TICK) < 14;
if (rows.length && !commit) {
const score = (l) => {
let s = 0;
const a = rows[0].lanes[l + 1];
if (a === 'block') s += 1000; else if (a) s += 3;
if (rows[1]) { const b = rows[1].lanes[l + 1]; if (b === 'block') s += 4; else if (b) s += 1; }
s += Math.abs(l - cur) * 1.5;
if (rail(r, l)) s -= 2;
return s;
};
let best = 1e9;
for (let l = -1; l <= 1; l++) {
// can we reach lane l before row 0? each lane takes laneDur ticks
const ticksTo = (rows[0].z - 1.5 - r.d) / (v / TICK);
const need = Math.abs(l - cur) * (r.ice ? 18 : 9) + 2;
if (l !== cur && ((ticksTo < need && rows[0].lanes[l + 1] === 'block') || under(l) || (Math.abs(l - cur) === 2 && under(0)))) continue;
const s = score(l) + (ticksTo < need ? 50 : 0);
if (s < best) { best = s; target = l; }
}
}
if (r.laneT >= r.laneDur && target !== cur) return target < cur ? 'L' : 'R';
// timing for the row in our lane
for (const row of here.concat(rows)) {
const a = row.lanes[cur + 1];
if (!a) continue;
const dist = row.z - r.d;
if (a === 'jump' && r.air === '' && !r.grind) {
const lead = row.len[cur + 1] > 4 ? 3 : 15;
if (dist < v * lead / TICK + 1.5 && dist > -1) return skill >= 1 || roll() < skill ? 'U' : '';
}
if (a === 'jump' && r.grind && dist < v * 15 / TICK + 1.5) return '';
if (a === 'duck') {
if (r.air !== '' && dist < v * 14 / TICK + 2) return 'D';
if (r.air === '' && r.duck < 4 && dist < v * 10 / TICK + 2 && dist > -1) return 'D';
}
break;
}
return '';
}
function rail(r, l) {
const c = r.course;
for (const L of c.longs) if (L.k === 'rail' && L.l === l && L.z > r.d && L.z < r.d + 40) return true;
return false;
}
// ------------------------------------------------------------------ progression (shared numbers)
export const MISSIONS = [
{ id: 'coins1', text: 'Collect 150 coins in one run', stat: 'coins', goal: 150, run: true },
{ id: 'coins2', text: 'Collect 400 coins in one run', stat: 'coins', goal: 400, run: true },
{ id: 'jumps', text: 'Jump 20 times', stat: 'jumps', goal: 20 },
{ id: 'jumps2', text: 'Jump 60 times', stat: 'jumps', goal: 60 },
{ id: 'ducks', text: 'Tuck under 15 things', stat: 'ducks', goal: 15 },
{ id: 'grind', text: 'Grind 300 units of rail', stat: 'grind', goal: 300 },
{ id: 'grind2', text: 'Grind 150 units in one run', stat: 'grind', goal: 150, run: true },
{ id: 'near', text: 'Get 10 near misses', stat: 'near', goal: 10 },
{ id: 'near2', text: 'Get 8 near misses in one run', stat: 'near', goal: 8, run: true },
{ id: 'boost', text: 'VOLT boost 3 times', stat: 'boosts', goal: 3 },
{ id: 'boost2', text: 'VOLT boost twice in one run', stat: 'boosts', goal: 2, run: true },
{ id: 'smash', text: 'Smash 5 obstacles', stat: 'smash', goal: 5 },
{ id: 'powers', text: 'Grab 6 power-ups', stat: 'powers', goal: 6 },
{ id: 'ramps', text: 'Land 5 ramp jumps', stat: 'ramps', goal: 5 },
{ id: 'dist1', text: 'Sled 1,500 units in one run', stat: 'dist', goal: 1500, run: true },
{ id: 'dist2', text: 'Sled 3,000 units in one run', stat: 'dist', goal: 3000, run: true },
{ id: 'dist3', text: 'Reach the Ice Caves (3,000)', stat: 'dist', goal: 3000, run: true },
{ id: 'dist4', text: 'Reach Aurora Night (4,500)', stat: 'dist', goal: 4500, run: true },
{ id: 'score1', text: 'Score 5,000 in one run', stat: 'score', goal: 5000, run: true },
{ id: 'score2', text: 'Score 20,000 in one run', stat: 'score', goal: 20000, run: true },
{ id: 'clean', text: 'Go 60 s without a stumble', stat: 'clean', goal: 60, run: true },
{ id: 'runs', text: 'Play 5 runs', stat: 'runs', goal: 5 },
{ id: 'daily', text: 'Play the daily run', stat: 'daily', goal: 1 },
{ id: 'coinsAll', text: 'Collect 1,000 coins in total', stat: 'coins', goal: 1000 },
];
/** The run's stats in mission terms. */
export function runStats(r, daily) {
return { coins: r.st.coins, jumps: r.st.jumps, ducks: r.st.ducks, grind: Math.floor(r.st.grind), near: r.st.near, boosts: r.st.boosts,
smash: r.st.smash, powers: r.st.powers, ramps: r.st.ramps, dist: Math.floor(r.d), score: finalScore(r),
clean: Math.floor(r.st.bestClean / TICK), runs: 1, daily: daily ? 1 : 0 };
}
/** Mission pick for a mission level: 3 missions, deterministic, never repeating within the set. */
export function missionSet(level) {
const c = { s: seedOf(level * 104729 + 7) };
const out = [];
while (out.length < 3) { const m = MISSIONS[Math.floor(rnd(c) * MISSIONS.length)]; if (!out.includes(m.id)) out.push(m.id); }
return out;
}
export function missionById(id) { return MISSIONS.find((m) => m.id === id); }
/** Apply one run to mission progress { level, ids, prog }. Returns { prog, done: [ids], levelUp }. */
export function applyMissions(state, stats) {
const s = { level: state.level || 1, ids: state.ids && state.ids.length === 3 ? state.ids.slice() : missionSet(state.level || 1), prog: { ...(state.prog || {}) } };
const done = [];
for (const id of s.ids) {
const m = missionById(id); if (!m) continue;
const before = s.prog[id] || 0;
if (before >= m.goal) continue;
const add = stats[m.stat] || 0;
s.prog[id] = m.run ? Math.max(before, add) : before + add;
if (s.prog[id] >= m.goal) { s.prog[id] = m.goal; done.push(id); }
}
let levelUp = false;
if (s.ids.every((id) => (s.prog[id] || 0) >= (missionById(id) || { goal: 0 }).goal) && s.level < 30) {
s.level += 1; s.ids = missionSet(s.level); s.prog = {}; levelUp = true;
}
return { state: s, done, levelUp };
}
export const SLEDS = [
{ id: 'plank', name: 'Pine Plank', cost: 0, color: '#C98A4B', accent: '#7A4A22' },
{ id: 'comet', name: 'Red Comet', cost: 500, color: '#E8413C', accent: '#FFD24A' },
{ id: 'toboggan', name: 'Frost Toboggan', cost: 1200, color: '#7FD6FF', accent: '#FFFFFF' },
{ id: 'volt', name: 'Volt Board', cost: 2500, color: '#2B2F45', accent: '#4DF2FF' },
{ id: 'saucer', name: 'Snowcat Saucer', cost: 4000, color: '#FF9F2E', accent: '#2B2F45' },
{ id: 'aurora', name: 'Aurora Bob', cost: 7000, color: '#8E5CFF', accent: '#52FFB8' },
];
export const RIDERS = [
{ id: 'juno', name: 'Juno', cost: 0, coat: '#FF7A2E', hat: '#2E6BFF', skin: '#F2C29B' },
{ id: 'bo', name: 'Bo', cost: 400, coat: '#3FA34D', hat: '#8B5A2B', skin: '#8D5B3C', ears: true },
{ id: 'kiko', name: 'Kiko', cost: 900, coat: '#E84C8B', hat: '#2BD3C6', skin: '#F5D0B5' },
{ id: 'marlo', name: 'Marlo', cost: 1800, coat: '#24304F', hat: '#FFCC33', skin: '#C68B5E', shades: true },
{ id: 'tamsin', name: 'Tamsin', cost: 3000, coat: '#6C4BFF', hat: '#FFFFFF', skin: '#E9B892' },
{ id: 'ozzy', name: 'Ozzy', cost: 5000, coat: '#E9F3FF', hat: '#9CC7FF', skin: '#F2D6BF', yeti: true },
];
export const TRAILS = [
{ id: 'powder', name: 'Powder', cost: 0, color: '#FFFFFF' },
{ id: 'sparkle', name: 'Sparkle', cost: 300, color: '#FFE36B' },
{ id: 'ember', name: 'Ember', cost: 800, color: '#FF6A3D' },
{ id: 'leaf', name: 'Leaf', cost: 1500, color: '#6BE37A' },
{ id: 'volt', name: 'Volt', cost: 2500, color: '#4DF2FF' },
{ id: 'aurora', name: 'Aurora', cost: 4500, color: '#B07BFF' },
];
// Looks that come only with a shop pack (web shop, shop.js; Roblox: the Aurora Garage pass gives the same Prism trio).
// They are not bought with coins (cost -1) and, like every look, never change the ride.
export const SHOP_LOOKS = {
sleds: [
{ id: 'kindle', name: 'Kindle Comet', cost: -1, pack: 'starter', color: '#FF4F1F', accent: '#FFE36B' },
{ id: 'prism', name: 'Prism Bob', cost: -1, pack: 'aurora', color: '#FFFFFF', accent: '#FF5CCD' },
],
riders: [{ id: 'nova', name: 'Nova', cost: -1, pack: 'aurora', coat: '#141628', hat: '#FF5CCD', skin: '#E6BEA0', shades: true }],
trails: [{ id: 'prism', name: 'Prism', cost: -1, pack: 'aurora', color: '#FF5CCD' }],
};
/** Race coins for a place (1-based); every finisher gets at least 10. */
export function raceReward(place) { return place === 1 ? 60 : place === 2 ? 40 : place === 3 ? 25 : 10; }