// 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:`) // 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; }