latchkite / core.js
CyberMax-tools's picture
Deploy latchkite
f12281a verified
Raw History Blame Contribute Delete
13 kB
// Latchkite core: deterministic simulation (no DOM). Your kite whirls round a peg on its line; one tap lets go and it
// flies straight along its swing. Reach the next peg's catch ring and the line latches on. Same pegs for everyone each
// UTC day (seed = the date). Fixed 60 Hz ticks, so a run is the same on every device. (c) 2026 CyberMax.
export const HZ = 60;
export const W = 100; // world width in units; y grows upward
export const FRAY = HZ * 3; // a line frays and snaps after 3 s on one peg: no camping
export const FLY_MAX = HZ * 2; // a kite that flies 2 s without a catch is lost
const TAU = Math.PI * 2;
export 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;
};
}
/** Catch-ring radius of peg i: generous early, tighter later (never below 4.6 units). */
export const catchR = (i) => Math.max(4.6, 8 - i * 0.12);
/** Spin speed in radians per second after `score` catches. */
export const spin = (score) => Math.min(4.6, 2.5 + score * 0.07);
/** Flight speed in units per second. */
export const flySpeed = (score) => Math.min(70, 42 + score * 0.9);
/** The peg course for a seed: peg 0 sits at the bottom middle, each next peg higher, left or right. */
export function course(seed, n = 400) {
const r = rng(`latchkite:${seed}`);
const pegs = [{ x: 50, y: 0 }];
for (let i = 1; i < n; i++) {
const p = pegs[i - 1];
const gap = 20 + r() * 7 + Math.min(i, 40) * 0.22;
let dx = (12 + r() * 24) * (r() < 0.5 ? -1 : 1);
if (p.x + dx < 14 || p.x + dx > 86) dx = -dx;
pegs.push({ x: Math.max(14, Math.min(86, p.x + dx)), y: p.y + gap, r: catchR(i) });
}
pegs[0].r = catchR(0);
return pegs;
}
/**
* A run: step(tap) once per tick; returns 'latch', 'win', 'dead' or ''. With no `cfg` this is the daily/practice run
* (unchanged since launch). A level passes `cfg` (see levelCfg): its own pegs, spin, speed, fray, wind, drifting pegs,
* flip pegs, snap pegs and storm clouds, and a goal peg (the last one) that ends the run with 'win'.
*/
export function makeSim(seed, cfg) {
const r0 = rng(`latchkite-dir:${seed}`)();
return mk(cfg ? cfg.pegs : course(seed), r0 < 0.5 ? 1 : -1, cfg || null);
}
function mk(pegs, dir, L) {
const spinOf = L ? (sc) => Math.min(L.spin[1], L.spin[0] + sc * L.spin[2]) : spin;
const flyOf = L ? (sc) => Math.min(L.fly[1], L.fly[0] + sc * L.fly[2]) : flySpeed;
const fray = L ? L.fray : FRAY;
const wind = L ? L.wind || 0 : 0;
const clouds = L ? L.clouds || [] : [];
const goal = L ? pegs.length - 1 : Infinity;
const s = {
pegs, peg: 0, a: -Math.PI / 2, len: 10, dir, score: 0, t: 0, held: 0, cfg: L, goal, clouds, wind,
flying: false, fx: 0, fy: 0, vx: 0, vy: 0, flown: 0, dead: false, won: false, why: '',
/** x of peg j now (drifting pegs slide side to side; other pegs stay put). */
px(j) { const q = this.pegs[j]; return q.ax ? q.x + q.ax * Math.sin(q.ph + (this.t * q.w) / HZ) : q.x; },
/** Ticks this peg lets you hold on before the line snaps. */
limit(j = this.peg) { return L && this.pegs[j].snap ? L.snapT : fray; },
get x() { return this.flying ? this.fx : this.px(this.peg) + Math.cos(this.a) * this.len; },
get y() { return this.flying ? this.fy : this.pegs[this.peg].y + Math.sin(this.a) * this.len; },
clone() {
const c = mk(this.pegs, this.dir, L);
for (const k of ['peg', 'a', 'len', 'score', 't', 'held', 'flying', 'fx', 'fy', 'vx', 'vy', 'flown', 'dead', 'won', 'why']) c[k] = this[k];
return c;
},
step(tap) {
if (this.won) return 'win';
if (this.dead) return 'dead';
this.t++;
if (!this.flying) {
this.a = (this.a + this.dir * spinOf(this.score) / HZ) % TAU;
if (++this.held >= this.limit()) { this.dead = true; this.why = this.pegs[this.peg].snap ? 'snap' : 'fray'; this.flying = true; this.fx = this.x; this.fy = this.y; return 'dead'; }
if (tap) {
const px = this.px(this.peg), p = this.pegs[this.peg];
this.fx = px + Math.cos(this.a) * this.len; this.fy = p.y + Math.sin(this.a) * this.len;
const v = flyOf(this.score);
this.vx = -Math.sin(this.a) * this.dir * v; this.vy = Math.cos(this.a) * this.dir * v;
this.flying = true; this.flown = 0;
}
return '';
}
if (wind) this.vx += wind / HZ;
this.fx += this.vx / HZ; this.fy += this.vy / HZ; this.flown++;
for (let j = this.peg + 1; j <= this.peg + 3 && j < this.pegs.length; j++) {
const q = this.pegs[j], dx = this.fx - this.px(j), dy = this.fy - q.y, d = Math.hypot(dx, dy);
if (d <= q.r) {
this.score += j - this.peg; // skipping a peg counts double
this.peg = j; this.flying = false; this.held = 0;
this.len = Math.max(5, Math.min(11, d < 5 ? 8 : d));
this.a = Math.atan2(dy, dx);
this.dir = dx * this.vy - dy * this.vx >= 0 ? 1 : -1; // keep turning the way the kite was moving
if (q.flip) this.dir = -this.dir; // flip peg: the swing turns the other way
if (j >= this.goal) { this.won = true; return 'win'; }
return 'latch';
}
}
for (const c of clouds) if (Math.hypot(this.fx - c.x, this.fy - c.y) < c.r) { this.dead = true; this.why = 'storm'; return 'dead'; }
const low = this.pegs[this.peg].y - 30;
if (this.flown >= FLY_MAX || this.fx < -6 || this.fx > W + 6 || this.fy < low) { this.dead = true; this.why = 'lost'; return 'dead'; }
return '';
},
};
return s;
}
/** 10-block bar for share text: one block per 3 catches. */
export function bar(n) {
const full = Math.min(10, Math.floor(n / 3));
return '🟦'.repeat(full) + '⬜'.repeat(10 - full);
}
// ------------------------------------------------------------------ 30 levels (own seed space: `latchkite:lv:<n>`)
// Each level is a finite course: catch `pegs` pegs and latch onto the goal peg at the top. A new twist about every
// 5 levels (wind, drifting pegs, flip pegs, snap pegs, storm clouds) and a challenge level every 10th. Stars are for
// time: the level builder flies a bot through every course and only keeps a course the bot clears while every
// release it uses is a window of at least `win` ticks wide, so every level is beatable by a person, not just a robot.
const D = { pegs: 10, r: 8.5, r2: 7.5, spin: [2.2, 3.0, 0.05], fly: [40, 54, 0.6], gap: [19, 6], dx: [10, 20], fray: 3, snapT: 1.7 };
const lv = (name, o) => ({ ...D, name, ...o });
export const LEVELS = [
lv('First Breeze', { pegs: 8, r: 10, r2: 9.5, spin: [1.9, 2.1, 0.02], fly: [36, 40, 0.3], gap: [17, 4], dx: [8, 14] }),
lv('Updraft', { pegs: 8, r: 9.5, r2: 8.5, spin: [2.0, 2.4, 0.04] }),
lv('Zigzag', { pegs: 10, r: 9, r2: 8, spin: [2.1, 2.6, 0.05], dx: [14, 18] }),
lv('Climb', { pegs: 12, r: 8.5, r2: 7.5, spin: [2.2, 2.8, 0.05], gap: [20, 6] }),
lv('Crosswind', { pegs: 10, r: 9, r2: 8, wind: 22, twist: 'Wind bends your flight' }),
lv('Gusty', { pegs: 12, wind: -26 }),
lv('Quickspin', { pegs: 12, spin: [2.6, 3.2, 0.05] }),
lv('Long Haul', { pegs: 16, r: 8, r2: 7, spin: [2.3, 3.0, 0.05], wind: 18 }),
lv('Headwind', { pegs: 14, r: 8.2, r2: 7.2, wind: -32 }),
lv('Gale Ridge', { pegs: 20, r: 8, r2: 6.6, spin: [2.4, 3.3, 0.05], wind: 30, fray: 2.5, boss: true }),
lv('Drift Isles', { pegs: 10, r: 9, r2: 8, drift: 6, twist: 'Pegs drift side to side' }),
lv('Sway', { pegs: 12, drift: 8 }),
lv('Swing Low', { pegs: 12, drift: 8, spin: [2.5, 3.2, 0.05] }),
lv('Drift and Gust', { pegs: 14, drift: 8, wind: 20 }),
lv('Flip Pegs', { pegs: 12, flip: 0.45, twist: 'Violet pegs flip your spin' }),
lv('Mirror', { pegs: 14, flip: 0.5, drift: 6 }),
lv('Twister', { pegs: 14, flip: 0.5, wind: -22 }),
lv('Tight Rings', { pegs: 16, r: 7.5, r2: 6.6, drift: 7, flip: 0.3 }),
lv('Sky Maze', { pegs: 18, drift: 9, flip: 0.4, wind: 18 }),
lv('Tempest', { pegs: 22, r: 7.8, r2: 6.4, drift: 9, flip: 0.4, wind: 26, fray: 2.5, boss: true }),
lv('Snap Pegs', { pegs: 12, snap: 0.4, twist: 'Red pegs snap fast: let go quick' }),
lv('Hot Lines', { pegs: 14, snap: 0.5, drift: 6 }),
lv('Snap Flip', { pegs: 14, snap: 0.4, flip: 0.4 }),
lv('Rush', { pegs: 16, snap: 0.5, wind: 22, spin: [2.5, 3.3, 0.05] }),
lv('Storm Clouds', { pegs: 12, gap: [23, 6], clouds: 5, twist: 'Dark clouds swallow kites' }),
lv('Thunder Run', { pegs: 14, gap: [23, 6], clouds: 7, wind: 20 }),
lv('Squall', { pegs: 16, gap: [23, 6], clouds: 7, drift: 7 }),
lv('Lightning', { pegs: 16, gap: [23, 6], clouds: 8, snap: 0.4, flip: 0.3 }),
lv('Eye of the Storm', { pegs: 18, gap: [23, 6], clouds: 9, drift: 8, flip: 0.4, wind: -22 }),
lv('Storm Crown', { pegs: 26, gap: [23, 6], r: 7.6, r2: 6.2, clouds: 10, drift: 8, flip: 0.4, snap: 0.35, wind: 26, fray: 2.4, boss: true }),
];
/** Narrowest release window (ticks) the level bot may use: 100 ms early on, 67 ms in the last world. */
export const winOf = (n) => (n <= 10 ? 6 : n <= 20 ? 5 : 4);
function levelPegs(L, n, attempt) {
const r = rng(`latchkite:lv:${n}:${attempt}`);
const pegs = [{ x: 50, y: 0, r: L.r }];
for (let i = 1; i <= L.pegs; i++) {
const p = pegs[i - 1];
const gap = L.gap[0] + r() * L.gap[1];
let dx = (L.dx[0] + r() * L.dx[1]) * (r() < 0.5 ? -1 : 1);
if (p.x + dx < 16 || p.x + dx > 84) dx = -dx;
const q = { x: Math.max(16, Math.min(84, p.x + dx)), y: p.y + gap, r: +(L.r + ((L.r2 - L.r) * i) / L.pegs).toFixed(2) };
if (L.drift && r() < 0.75) { q.ax = L.drift * (0.6 + 0.4 * r()); q.w = 0.9 + 0.8 * r(); q.ph = r() * TAU; q.x = Math.max(14 + q.ax, Math.min(86 - q.ax, q.x)); }
if (i < L.pegs && L.flip && r() < L.flip) q.flip = 1;
if (i < L.pegs && L.snap && r() < L.snap) q.snap = 1;
pegs.push(q);
}
const clouds = [];
for (let k = 0, tries = 0; k < (L.clouds || 0) && tries < 400; tries++) {
const i = 1 + Math.floor(r() * (L.pegs - 1)), a = pegs[i], b = pegs[i + 1];
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2, side = r() < 0.5 ? -1 : 1, off = 7 + r() * 9;
const c = { x: mx + side * off, y: my + (r() - 0.5) * 4, r: 3 + r() * 1.8 };
if (c.x < 8 || c.x > 92) continue;
if (pegs.some((q) => Math.hypot(q.x - c.x, q.y - c.y) < (q.ax || 0) * 0.5 + 9 + c.r)) continue; // clear of the catch rings
if (clouds.some((o) => Math.hypot(o.x - c.x, o.y - c.y) < o.r + c.r + 4)) continue;
clouds.push(c); k++;
}
return { pegs, clouds };
}
/**
* The bot: on each peg it waits until letting go would land on a forward peg on `win` ticks in a row, then lets go
* on the first of them. Returns the finished sim (won or dead); sim.taps lists the ticks it let go on.
*/
export function levelBot(sim, win = 5, react = 4) {
const s = sim;
s.taps = [];
const catches = (c) => { const d = c.clone(); d.step(true); for (let i = 0; i < FLY_MAX + 2; i++) { const e = d.step(false); if (e === 'latch' || e === 'win') return true; if (e === 'dead') return false; } return false; };
while (!s.dead && !s.won && s.t < HZ * 300) {
if (s.flying) { s.step(false); continue; }
const look = s.clone(), ok = [];
const lim = s.limit() - s.held;
for (let k = 0; k < lim; k++) { ok.push(k >= react && catches(look)); look.step(false); }
let at = -1;
for (let k = 0; k + win <= ok.length && at < 0; k++) if (ok.slice(k, k + win).every(Boolean)) at = k;
if (at < 0) { while (!s.dead) s.step(false); break; }
for (let k = 0; k < at; k++) s.step(false);
s.taps.push(s.t);
s.step(true);
}
return s;
}
const cache = {};
/** The level config (pegs, clouds, physics, par ticks). Deterministic; the first course the bot clears is kept. */
export function levelCfg(n) {
if (cache[n]) return cache[n];
const L = LEVELS[n - 1];
for (let attempt = 0; attempt < 60; attempt++) {
const { pegs, clouds } = levelPegs(L, n, attempt);
const cfg = { ...L, n, pegs, clouds, fray: Math.round(L.fray * HZ), snapT: Math.round(L.snapT * HZ) };
const b = levelBot(makeSim(`L${n}`, cfg), winOf(n));
if (!b.won) continue;
cfg.par = b.t; cfg.attempt = attempt;
cfg.stars = [9999, +(b.t / HZ * 1.75 + 1.5).toFixed(1), +(b.t / HZ * 1.25 + 0.5).toFixed(1)];
return (cache[n] = cfg);
}
throw new Error(`latchkite level ${n}: no fair course`);
}
/** A fresh run of level n. */
export const makeLevelSim = (n) => makeSim(`L${n}`, levelCfg(n));
/** Seconds (one decimal) of a finished level run. */
export const secs = (ticks) => Math.round((ticks / HZ) * 10) / 10;
/** Stars for a cleared level time in seconds: 1 for clearing, 2 and 3 for beating the level's time marks. */
export function levelStars(n, sec) {
const t = levelCfg(n).stars;
return sec <= t[2] ? 3 : sec <= t[1] ? 2 : 1;
}