// Latchkite: game screen (fixed-tick loop, canvas, 30 levels, daily/practice/archive, share, Fun Pass, Skin Pack). (c) 2026 CyberMax.
import { makeSim, bar, HZ, spin, LEVELS, levelCfg, makeLevelSim, levelBot, levelStars, secs, winOf } from './core.js';
import { dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js';
import * as lv from './levels.js';
import * as skins from './skins.js';
import * as pass from './funpass.js';
const GAME = 'latchkite';
const PATH = '/games/latchkite/';
const GUMROAD = 'https://cybermaxtools.gumroad.com/l/cybermax-latchkite-skins';
const $ = (id) => document.getElementById(id);
const cv = $('c');
const g = cv.getContext('2d');
// tier: free | pass (CyberMax Fun Pass) | pack (Latchkite Skin Pack, one-time)
const SKINS = {
dawn: { name: 'Dawn', tier: 'free', top: '#1B2350', bot: '#E9806B', peg: '#FFF4E0', ring: 'rgba(255,244,224,.28)', line: '#FFF4E0', k1: '#FFCF4A', k2: '#FF5E5B', trail: 'rgba(255,207,74,.55)', text: '#FFFFFF' },
dusk: { name: 'Dusk', tier: 'pass', top: '#0D0B26', bot: '#5B2A86', peg: '#E8E1FF', ring: 'rgba(232,225,255,.25)', line: '#E8E1FF', k1: '#7CF5FF', k2: '#B26BFF', trail: 'rgba(124,245,255,.5)', text: '#FFFFFF' },
meadow: { name: 'Meadow', tier: 'pass', top: '#7FD3FF', bot: '#E7F7C9', peg: '#2E4A1F', ring: 'rgba(46,74,31,.25)', line: '#2E4A1F', k1: '#FF6A3D', k2: '#2E86DE', trail: 'rgba(255,106,61,.45)', text: '#11141B' },
koi: { name: 'Koi', tier: 'pack', top: '#0F3B4C', bot: '#2E8C8C', peg: '#FDF6EC', ring: 'rgba(253,246,236,.25)', line: '#FDF6EC', k1: '#FF7B2E', k2: '#FFFFFF', trail: 'rgba(255,123,46,.5)', text: '#FFFFFF', tail: 'fins' },
aurora: { name: 'Aurora', tier: 'pack', top: '#020814', bot: '#0E4D3C', peg: '#C9FFE9', ring: 'rgba(116,255,196,.28)', line: '#9FFFD8', k1: '#74FFC4', k2: '#8A7CFF', trail: 'rgba(116,255,196,.6)', text: '#E8FFF6', tail: 'ribbon' },
ember: { name: 'Ember', tier: 'pack', top: '#1A0500', bot: '#7A1E00', peg: '#FFD9A8', ring: 'rgba(255,170,80,.3)', line: '#FFB067', k1: '#FFD23F', k2: '#FF3D00', trail: 'rgba(255,90,0,.65)', text: '#FFF1E0', tail: 'sparks' },
paper: { name: 'Paper', tier: 'pack', top: '#F4EFE6', bot: '#E6DCCB', peg: '#2B2B2B', ring: 'rgba(43,43,43,.2)', line: '#2B2B2B', k1: '#D7263D', k2: '#F4EFE6', trail: 'rgba(43,43,43,.25)', text: '#2B2B2B', tail: 'bows' },
neon: { name: 'Neon', tier: 'pack', top: '#07001A', bot: '#1F0B3A', peg: '#FF2E97', ring: 'rgba(255,46,151,.35)', line: '#00F0FF', k1: '#00F0FF', k2: '#FF2E97', trail: 'rgba(0,240,255,.6)', text: '#FFFFFF', tail: 'ribbon' },
gold: { name: 'Gold', tier: 'pack', top: '#14110A', bot: '#3D3218', peg: '#FFE7A3', ring: 'rgba(255,215,0,.3)', line: '#FFE7A3', k1: '#FFD700', k2: '#B8860B', trail: 'rgba(255,215,0,.55)', text: '#FFF6D6', tail: 'sparks' },
};
const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack');
const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same course for everyone on Earth
const todayNum = dayNumber(todayIso);
const FLIP = '#B26BFF', SNAP = '#FF4D4D', GOLD = '#FFD23F';
skins.capture({ game: GAME, name: 'Latchkite', skins: PACK.length, price: '$1.99', gumroad: GUMROAD, preview: PACK.map((s) => s.k1),
perks: [`${PACK.length} kites with their own sky, line and tail: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] });
pass.init({ game: GAME, name: 'Latchkite', perks: ['Levels 21-30: snap pegs, storm clouds and the Storm Crown', 'Every past daily course in the archive', 'Dusk and Meadow skies'] });
const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned());
let skin = SKINS[store('latchkite:skin')] && canUse(SKINS[store('latchkite:skin')]) ? store('latchkite:skin') : 'dawn';
let muted = !!store('latchkite:mute');
const sfx = lv.sfx(() => muted);
lv.css(); // kit styles (tutorial hand, banner, stars) before the first frame
const prog = lv.progress(GAME, { total: LEVELS.length, free: 20, isPro: () => pass.isPro(), lower: true });
const chal = lv.readChallenge(); // ?seed=L7&score=12.3 (seconds) or ?seed=2026-10-08&score=14 (catches): "beat my score"
const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss }));
const WORLDS = ['Dawn Bluffs', 'Drift Isles', 'Storm Crown (Fun Pass)'];
let mode = null; // { kind: 'daily', date } | { kind: 'practice', seed } | { kind: 'level', level, challenge? }
let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shakeAmt = 0, lastScore = 0, camY = -40, pop = 0;
let trail = [], result = null, auto = null, speed = 1, winGlow = 0;
const install = pwa(GAME, $('install'));
themeToggle($('theme'));
function setMute(m) { muted = m; store('latchkite:mute', muted); $('mute').textContent = muted ? '๐' : '๐'; }
$('mute').textContent = muted ? '๐' : '๐';
$('mute').onclick = () => setMute(!muted);
// ------------------------------------------------------------------ modes + stats
const isLv = () => mode.kind === 'level';
const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date);
const dayKey = (m) => `latchkite:day:${m.date}`;
const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0 };
/** Daily streak: days in a row with at least one try on that day's course (kept in the existing per-day stats). */
function streakNow() {
let n = todayNum, k = 0;
if (!(dayStats({ date: todayIso }).tries > 0)) n--; // today not played yet: the streak is still alive from yesterday
while (n >= 1 && dayStats({ date: dateOf(n) }).tries > 0) { k++; n--; }
return k;
}
function returning() {
try { for (let i = 0; i < localStorage.length; i++) if (/^latchkite:(day:|practice-best)/.test(localStorage.key(i))) return true; } catch { /* */ }
return prog.cleared() > 0;
}
function renderModes() {
if (!mode) return;
const el = $('modes'); el.innerHTML = '';
const st = streakNow();
const items = [
{ t: `Daily #${todayNum}${st ? ` ๐ฅ${st}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => setMode({ kind: 'daily', date: todayIso }) },
{ t: `Levels ${prog.cleared()}/${LEVELS.length} โญ${prog.totalStars()}`, on: isLv(), go: showMap },
{ t: 'Practice', on: mode.kind === 'practice', go: () => setMode({ kind: 'practice', seed: `p${Date.now()}` }) },
{ t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive },
];
for (const it of items) {
const b = document.createElement('button');
b.className = 'chip'; b.type = 'button'; b.setAttribute('aria-pressed', String(it.on));
b.innerHTML = `${it.t}${it.lock ? ' ๐' : ''}`;
b.onclick = it.go; el.appendChild(b);
}
}
function archive() {
if (!pass.isPro()) { pass.open('archive'); return; }
const n = todayNum - 1;
if (n < 1) { toast('The archive fills up day by day; the first archived course arrives tomorrow.', 4000); return; }
const k = Number(prompt(`Play a past daily course: enter a number from 1 to ${n}`, String(n)));
if (k >= 1 && k <= n) setMode({ kind: 'daily', date: dateOf(k) });
}
function showMap() {
const el = $('levels');
if (!el.hidden) { el.hidden = true; return; }
lv.renderMap($('lvmap'), prog, MAP, {
current: isLv() ? mode.level : 0, worlds: WORLDS,
onPick: (k) => { setMode({ kind: 'level', level: k }); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); },
onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)),
});
el.hidden = false;
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/** Switch mode. Returns false (and opens the right sheet or toast) when a level is locked. */
function setMode(m, quiet = false) {
if (m.kind === 'level') {
const lk = prog.lock(m.level, { challenge: !!m.challenge });
if (lk) {
if (!quiet) { if (lk === 'pass') pass.open('level'); else toast(`Clear level ${prog.reached()} first.`); }
else if (lk === 'pass') pass.open('level');
return false;
}
prog.open(m.level);
}
mode = m;
$('num').textContent = m.kind === 'practice' ? 'practice' : m.kind === 'level' ? `Level ${m.level}` : `#${dayNumber(m.date)}`;
$('over').hidden = true; $('res').hidden = true; $('levels').hidden = true;
const u = new URL(location.href);
if (!chal || !sameChal()) { u.search = m.kind === 'level' ? `?lv=${m.level}` : m.kind === 'daily' && m.date !== todayIso ? `?d=${m.date}` : ''; history.replaceState(null, '', u.pathname + u.search); }
renderModes(); reset(); hud(); goal(); banner();
if (isLv()) {
const L = LEVELS[m.level - 1];
if (L.boss) sfx.boss();
if (m.level === 1) setTimeout(() => lv.tutorial(GAME, cv, { fx: 0.5, fy: 0.62 }), 250);
}
return true;
}
function hud() {
if (isLv()) {
const b = prog.best(mode.level);
$('stat').innerHTML = `Level ${mode.level}: ${LEVELS[mode.level - 1].name} ยท best ${b === undefined ? 'โ' : `${b} s`} ยท ${lv.starText(prog.stars(mode.level))}`;
return;
}
const s = mode.kind === 'daily' ? dayStats(mode) : { best: store('latchkite:practice-best') || 0, tries: 0 };
const st = mode.kind === 'daily' && mode.date === todayIso ? streakNow() : 0;
$('stat').innerHTML = `${mode.kind === 'daily' ? 'Today\'s' : 'Practice'} best ${s.best} ยท tries ${s.tries || 0}${st ? ` ยท ๐ฅ ${st}-day streak` : ''}`;
}
/** The goal line above the canvas: what this run asks of you, in one glance. */
function goal() {
const bits = [];
if (isLv()) {
const L = LEVELS[mode.level - 1], c = levelCfg(mode.level);
if (L.boss) bits.push('๐ Challenge level');
bits.push(`๐ฏ Catch ${L.pegs} pegs to the ๐ฉ flag`);
bits.push(`โญโญโญ under ${c.stars[2]} s`);
if (L.twist) bits.push(`NEW: ${L.twist}`);
} else {
bits.push(mode.kind === 'daily' ? '๐ฏ Catch as many pegs as you can on today\'s course' : '๐ฏ Endless practice course');
const best = mode.kind === 'daily' ? dayStats(mode).best : 0;
if (best) bits.push(`your best ${best}`);
}
$('goal').innerHTML = bits.join(' ยท ');
}
function sameChal() {
return !!(chal && mode && ((chal.level && isLv() && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date)));
}
/** "Beat my score" banner when the page was opened from a friend's link for this same level or daily. */
function banner() {
const el = $('challenge');
const on = sameChal() && chal.score !== null;
el.hidden = !on;
if (on) el.innerHTML = `๐ฏ A friend ${isLv() ? `cleared level ${chal.level} in ${chal.score} s` : `caught ${chal.score} pegs on daily #${dayNumber(chal.date)}`}. Beat it!`;
}
// ------------------------------------------------------------------ loop
function reset() {
sim = isLv() ? makeLevelSim(mode.level) : makeSim(seedOf(mode));
state = 'ready'; pending = false; acc = 0; trail = []; flash = 0; shakeAmt = 0; camY = -40; result = null; winGlow = 0;
}
function start() {
if (mode.kind === 'practice') mode.seed = `p${Date.now()}`;
reset(); state = 'run'; last = performance.now();
count(GAME, 'play', isLv() ? `level-${mode.level}` : mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice');
$('over').hidden = true; $('res').hidden = true;
}
function input() {
if (state === 'ready') { start(); return; }
if (state === 'run') { pending = true; return; }
if ((state === 'dead' || state === 'won') && performance.now() - deadAt > 250) start();
}
/** Instant restart (R key, Retry): a fresh run that is already flying, no ready screen. */
function restart() { sfx.tap(); start(); }
/** Page point of a world position (for particle bursts). */
function pagePt(x, y) {
const r = cv.getBoundingClientRect(), k = r.width / cv.width;
return [r.left + x * K * k, r.top + (cv.height - (y - camY) * K) * k];
}
function die() {
state = 'dead'; deadAt = performance.now(); flash = 1; shakeAmt = 12; lastScore = sim.score;
sfx.hit(); lv.shake(cv.parentElement, 8, 260);
if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ }
let newBest = false;
if (mode.kind === 'daily') {
const s = dayStats(mode); s.tries = (s.tries || 0) + 1;
if (sim.score > (s.best || 0)) { s.best = sim.score; newBest = true; }
store(dayKey(mode), s);
} else if (mode.kind === 'practice' && sim.score > (store('latchkite:practice-best') || 0)) { store('latchkite:practice-best', sim.score); newBest = true; }
hud(); renderModes(); goal();
if (isLv()) showRes(false); else showOver(newBest);
if (sim.score >= 5) install.offer();
}
function won() {
state = 'won'; deadAt = performance.now(); winGlow = 1;
const sec = secs(sim.t), st = levelStars(mode.level, sec);
result = { ...prog.record(mode.level, st, sec), got: st, sec };
count(GAME, 'win', `level-${mode.level}`);
sfx.win(); for (let k = 0; k < st; k++) sfx.star(k + 1);
const [x, y] = pagePt(sim.x, sim.y);
lv.burst(x, y, { n: 70, speed: 9, colors: [SKINS[skin].k1, SKINS[skin].k2, GOLD, '#FFFFFF'] });
if (navigator.vibrate) try { navigator.vibrate(20); } catch { /* */ }
hud(); renderModes();
showRes(true);
install.offer();
}
function frame(now) {
requestAnimationFrame(frame);
if (state === 'run') {
acc += Math.min(0.25, (now - last) / 1000) * speed; last = now;
let n = 0;
while (acc >= 1 / HZ && n++ < 8 * speed) {
acc -= 1 / HZ;
const tap = auto ? auto.has(sim.t) : pending && !sim.flying; pending = false;
if (tap) sfx.tap();
const ev = sim.step(tap);
if (ev === 'latch') {
sfx.good(isLv() ? sim.peg : sim.score); pop = 1;
const [x, y] = pagePt(sim.px(sim.peg), sim.pegs[sim.peg].y);
lv.burst(x, y, { n: 12, speed: 4, life: 450, colors: [SKINS[skin].k1, SKINS[skin].peg] });
}
if (ev === 'win') { won(); break; }
if (sim.dead) { die(); break; }
}
} else last = now;
draw(now);
}
// ------------------------------------------------------------------ drawing (world units -> canvas: 7.2 px a unit)
const TAU = Math.PI * 2;
const K = 7.2;
function kite(x, y, ang, S, size = 1, ctx = g) {
ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.scale(size, size);
ctx.fillStyle = S.k1; ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(17, 0); ctx.lineTo(0, 30); ctx.lineTo(-17, 0); ctx.closePath(); ctx.fill();
ctx.fillStyle = S.k2; ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(17, 0); ctx.lineTo(0, 0); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(0, 30); ctx.lineTo(-17, 0); ctx.lineTo(0, 0); ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,.25)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, -26); ctx.lineTo(0, 30); ctx.moveTo(-17, 0); ctx.lineTo(17, 0); ctx.stroke();
ctx.restore();
}
function sky(ctx, w, h, S) {
const gr = ctx.createLinearGradient(0, 0, 0, h); gr.addColorStop(0, S.top); gr.addColorStop(1, S.bot);
ctx.fillStyle = gr; ctx.fillRect(-20, -20, w + 40, h + 40);
}
function cloud(ctx, x, y, r, now) {
ctx.fillStyle = 'rgba(28,24,40,.9)';
for (const [dx, dy, k] of [[-0.55, 0.15, 0.7], [0.55, 0.15, 0.7], [0, -0.2, 0.9], [0, 0.25, 0.75]]) { ctx.beginPath(); ctx.arc(x + dx * r, y + dy * r, k * r, 0, TAU); ctx.fill(); }
if (Math.floor(now / 160) % 7 === 0) { ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x - r * 0.1, y + r * 0.5); ctx.lineTo(x + r * 0.15, y + r * 0.9); ctx.lineTo(x - r * 0.05, y + r * 0.95); ctx.lineTo(x + r * 0.2, y + r * 1.35); ctx.stroke(); }
}
function flag(ctx, x, y, now) {
ctx.strokeStyle = GOLD; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 46); ctx.stroke();
const w = Math.sin(now / 160) * 4;
ctx.fillStyle = '#FF5E5B'; ctx.beginPath(); ctx.moveTo(x, y - 46); ctx.lineTo(x + 30, y - 38 + w); ctx.lineTo(x, y - 28); ctx.closePath(); ctx.fill();
}
function draw(now) {
const S = SKINS[skin], Wc = cv.width, Hc = cv.height;
const p = sim.pegs[sim.peg];
const target = (sim.flying ? Math.min(p.y, sim.y) : p.y) - 40;
camY += (target - camY) * 0.12;
const sx = (x) => x * K, sy = (y) => Hc - (y - camY) * K;
g.save();
if (shakeAmt > 0) { g.translate((Math.random() - 0.5) * shakeAmt, (Math.random() - 0.5) * shakeAmt); shakeAmt *= 0.85; if (shakeAmt < 0.5) shakeAmt = 0; }
sky(g, Wc, Hc, S);
// parallax clouds
g.fillStyle = 'rgba(255,255,255,.07)';
for (let i = 0; i < 6; i++) { const cy = ((i * 211 - camY * 2.2) % (Hc + 200) + Hc + 200) % (Hc + 200) - 100; g.beginPath(); g.ellipse((i * 137) % Wc, Hc - cy, 90, 26, 0, 0, TAU); g.fill(); }
// wind streaks (levels with wind): they blow the way your flight bends
if (sim.wind) {
g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 2;
const dir = Math.sign(sim.wind), sp = Math.abs(sim.wind) * 6;
for (let i = 0; i < 9; i++) {
const y = (i * 109 + 40) % Hc, x = (((i * 233 + dir * now * sp / 1000) % (Wc + 160)) + Wc + 160) % (Wc + 160) - 80;
g.beginPath(); g.moveTo(x, y); g.lineTo(x - dir * 60, y); g.stroke();
}
}
// storm clouds
for (const c of sim.clouds) { const y = sy(c.y); if (y > -80 && y < Hc + 80) cloud(g, sx(c.x), y, c.r * K, now + c.x * 37); }
// pegs + catch rings
for (let j = Math.max(0, sim.peg - 1); j < sim.pegs.length; j++) {
const q = sim.pegs[j]; const y = sy(q.y), x = sx(sim.px(j));
if (y < -80) break;
if (y > Hc + 60) continue;
if (q.ax) { g.strokeStyle = S.ring; g.lineWidth = 2; g.beginPath(); g.moveTo(sx(q.x - q.ax), y); g.lineTo(sx(q.x + q.ax), y); g.stroke(); }
const isGoal = j === sim.goal;
if (j > sim.peg) {
g.strokeStyle = isGoal ? GOLD : j === sim.peg + 1 ? S.line : S.ring; g.globalAlpha = j === sim.peg + 1 && !isGoal ? 0.55 : 1;
g.lineWidth = isGoal ? 4 : 3; g.setLineDash([8, 8]); g.beginPath(); g.arc(x, y, q.r * K, 0, TAU); g.stroke(); g.setLineDash([]); g.globalAlpha = 1;
}
g.fillStyle = q.flip ? FLIP : q.snap ? SNAP : isGoal ? GOLD : S.peg; g.beginPath(); g.arc(x, y, j === sim.peg ? 9 : q.flip || q.snap ? 9 : 7, 0, TAU); g.fill();
if (q.flip) { g.strokeStyle = FLIP; g.lineWidth = 3; g.beginPath(); g.arc(x, y, 16, 0.3, Math.PI - 0.3); g.stroke(); g.beginPath(); g.arc(x, y, 16, Math.PI + 0.3, TAU - 0.3); g.stroke(); }
if (isGoal) flag(g, x, y, now);
}
// line, fray warning, aim guide on the first levels
const kx = sx(sim.x), ky = sy(sim.y);
if (!sim.flying) {
const px = sx(sim.px(sim.peg)), fr = state === 'run' ? sim.held / sim.limit() : 0;
g.strokeStyle = fr > 0.6 && Math.floor(now / 90) % 2 ? '#FF4D4D' : S.line; g.lineWidth = 3;
g.beginPath(); g.moveTo(px, sy(p.y)); g.lineTo(kx, ky); g.stroke();
if (state === 'run') { g.strokeStyle = fr > 0.6 || p.snap ? '#FF4D4D' : S.line; g.globalAlpha = 0.6; g.lineWidth = 4; g.beginPath(); g.arc(px, sy(p.y), 16, -Math.PI / 2, -Math.PI / 2 + TAU * (1 - fr)); g.stroke(); g.globalAlpha = 1; }
if (isLv() && mode.level <= 3 && state !== 'won') {
const tx = -Math.sin(sim.a) * sim.dir, ty = Math.cos(sim.a) * sim.dir;
g.strokeStyle = S.line; g.globalAlpha = 0.35; g.lineWidth = 2; g.setLineDash([4, 10]);
g.beginPath(); g.moveTo(kx, ky); g.lineTo(kx + tx * 26 * K, ky - ty * 26 * K); g.stroke(); g.setLineDash([]); g.globalAlpha = 1;
}
}
// trail
trail.push([sim.x, sim.y]); if (trail.length > 16) trail.shift();
for (let i = 0; i < trail.length; i++) {
const [x, y] = trail[i]; const a = i / trail.length;
g.fillStyle = S.trail; g.globalAlpha = a * 0.8;
const r = S.tail === 'sparks' ? 3 + (i % 3) * 2 : S.tail === 'bows' ? (i % 4 === 0 ? 7 : 2) : 4 + a * 7;
g.beginPath(); g.arc(sx(x) + (S.tail === 'ribbon' ? Math.sin(now / 90 + i) * 6 : 0), sy(y), r, 0, TAU); g.fill();
}
g.globalAlpha = 1;
const ang = sim.flying ? Math.atan2(sim.vx, sim.vy) : Math.PI / 2 - sim.a; // nose along the flight, or outward on the line
if (state !== 'dead' || (now - deadAt) % 280 < 140) kite(kx, ky, ang, S, 1 + pop * 0.35);
pop *= 0.86;
// text
g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle';
if (state === 'ready') {
if (isLv()) { g.font = '800 30px system-ui, sans-serif'; g.globalAlpha = 0.9; g.fillText(`${LEVELS[mode.level - 1].boss ? '๐ ' : ''}Level ${mode.level} ยท ${LEVELS[mode.level - 1].name}`, Wc / 2, Hc * 0.3 - 70); g.globalAlpha = 1; }
g.font = '900 64px system-ui, sans-serif'; g.fillText('TAP', Wc / 2, Hc * 0.3);
g.font = '700 26px system-ui, sans-serif'; g.globalAlpha = 0.85; g.fillText('to let go ยท fly into the next ring', Wc / 2, Hc * 0.3 + 52); g.globalAlpha = 1;
} else if (isLv()) {
g.font = '900 96px system-ui, sans-serif'; g.globalAlpha = 0.9; g.fillText(`${sim.peg}/${sim.goal}`, Wc / 2, 100); g.globalAlpha = 1;
// progress bar toward the flag + run time
g.fillStyle = 'rgba(0,0,0,.25)'; g.fillRect(60, 164, Wc - 120, 10);
g.fillStyle = GOLD; g.fillRect(60, 164, (Wc - 120) * (sim.peg / sim.goal), 10);
g.fillStyle = S.text; g.font = '700 26px system-ui, sans-serif'; g.textAlign = 'right'; g.fillText(`${secs(sim.t).toFixed(1)} s`, Wc - 24, 36); g.textAlign = 'center';
if (state === 'dead') { g.font = '800 28px system-ui, sans-serif'; g.fillText(`${why()} ยท tap to retry`, Wc / 2, 210); }
} else {
g.font = '900 110px system-ui, sans-serif'; g.globalAlpha = 0.9; g.fillText(String(sim.score), Wc / 2, 110); g.globalAlpha = 1;
if (state === 'dead') { g.font = '800 28px system-ui, sans-serif'; g.fillText(`${why()} ยท tap to retry`, Wc / 2, 185); }
}
if (flash > 0) { g.fillStyle = `rgba(255,77,77,${flash * 0.3})`; g.fillRect(0, 0, Wc, Hc); flash *= 0.88; if (flash < 0.02) flash = 0; }
if (winGlow > 0) { g.fillStyle = `rgba(255,210,63,${winGlow * 0.25})`; g.fillRect(0, 0, Wc, Hc); winGlow *= 0.94; if (winGlow < 0.02) winGlow = 0; }
g.restore();
}
const why = () => ({ fray: 'the line frayed', snap: 'the red peg snapped', storm: 'lost in the storm', lost: 'lost in the wind' }[sim.why] || 'lost in the wind');
// ------------------------------------------------------------------ results, share, card
function shareText() {
if (isLv()) {
const sec = result ? result.sec : prog.best(mode.level);
return `Latchkite level ${mode.level} ๐ช ${lv.starText(prog.stars(mode.level))}\nCleared ${LEVELS[mode.level - 1].name} in ${sec} s\nCan you beat it?`;
}
const head = mode.kind === 'practice' ? 'Latchkite practice ๐ช' : `Latchkite #${dayNumber(mode.date)} ๐ช`;
const best = mode.kind === 'daily' ? dayStats(mode).best : lastScore;
const st = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` ๐ฅ${streakNow()}` : '';
return `${head} ${best} catches${mode.kind === 'daily' ? ` (${dayStats(mode).tries} tries)` : ''}${st}\n${bar(best)}\nCan you fly further?`;
}
/** The "beat my score" link: the same level (my time) or the same daily course (my best catches). */
function beatLink() {
if (isLv()) return lv.challengeLink(PATH, GAME, { level: mode.level, score: result ? result.sec : prog.best(mode.level) });
if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: dayStats(mode).best });
return `https://cybermaxtools.com${PATH}`;
}
function vsFriend() {
if (!sameChal() || chal.score === null) return '';
if (isLv()) {
if (!result) return '';
return result.sec < chal.score ? `๐ฏ You beat your friend's ${chal.score} s!` : result.sec === chal.score ? `๐ฏ Tied with your friend (${chal.score} s)` : `๐ฏ ${(result.sec - chal.score).toFixed(1)} s slower than your friend. Retry?`;
}
return lastScore > chal.score ? `๐ฏ You beat your friend's ${chal.score}!` : lastScore === chal.score ? `๐ฏ Tied with your friend (${chal.score})` : `๐ฏ ${chal.score - lastScore} short of your friend's ${chal.score}. Retry?`;
}
/** Compact result over the canvas (stars, next level, retry, challenge) so a phone player never has to scroll. */
function showRes(win) {
const el = $('res');
const n = isLv() ? mode.level : 0;
$('res-stars').hidden = !win;
if (win) $('res-stars').innerHTML = lv.starsHtml(result.got);
if (isLv()) {
$('res-title').textContent = win ? (result.got === 3 ? `Level ${n}: perfect!` : `Level ${n} cleared!`) : `Peg ${sim.peg}/${sim.goal}: ${why()}`;
const c = levelCfg(n);
let line = win ? `${result.sec} s${result.improved && !result.first ? ' ยท new best!' : ''} ยท โญโญโญ under ${c.stars[2]} s, โญโญ under ${c.stars[1]} s` : `Tap the sky or โป to retry at once.`;
const vs = vsFriend(); if (vs && win) line += ` ยท ${vs}`;
$('res-line').textContent = line;
} else {
$('res-title').textContent = `${lastScore} catch${lastScore === 1 ? '' : 'es'}`;
$('res-line').textContent = vsFriend() || 'Tap the sky or โป to fly again.';
}
const nx = $('next-lv');
nx.hidden = !(isLv() && win && n < LEVELS.length);
if (!nx.hidden) nx.textContent = prog.lock(n + 1) === 'pass' ? `Level ${n + 1} ๐ Fun Pass` : `Next: level ${n + 1} โถ`;
$('beat').hidden = isLv() ? !win : mode.kind !== 'daily';
$('to-daily').hidden = !(isLv() && win && n === LEVELS.length);
el.hidden = false;
if (isLv() && win) showOver(false);
}
function showOver(newBest) {
const s = mode.kind === 'daily' ? dayStats(mode) : null;
if (isLv()) {
$('over-title').textContent = `Level ${mode.level} ${lv.starText(result.got)}`;
$('over-line').textContent = `${result.sec} s. Share it and dare a friend to fly it faster.`;
} else {
$('over-title').textContent = newBest && lastScore > 0 ? `New best: ${lastScore}!` : `${lastScore} catch${lastScore === 1 ? '' : 'es'}`;
const st = mode.kind === 'daily' && mode.date === todayIso ? streakNow() : 0;
$('over-line').textContent = s ? `Today's best ${s.best} after ${s.tries} tries${st ? ` ยท ๐ฅ ${st}-day streak` : ''}. Tap the game to fly again.${vsFriend() ? ` ${vsFriend()}` : ''}` : 'Practice course. Tap the game to fly again.';
showRes(false);
}
const it = intents(GAME, mode.kind === 'practice' ? shareText() : `${shareText()}\n๐ฏ ${beatLink()}`, PATH);
$('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
$('over').hidden = false;
pass.afterWin($('over'), 'over');
}
function card() {
const S = SKINS[skin], c = document.createElement('canvas');
c.width = 1080; c.height = 1350;
const x = c.getContext('2d');
sky(x, 1080, 1350, S);
x.fillStyle = S.peg;
[[300, 1040], [700, 820], [420, 560]].forEach(([a, b]) => { x.beginPath(); x.arc(a, b, 14, 0, TAU); x.fill(); });
x.strokeStyle = S.ring; x.lineWidth = 5; x.setLineDash([12, 12]); x.beginPath(); x.arc(420, 560, 70, 0, TAU); x.stroke(); x.setLineDash([]);
x.strokeStyle = S.line; x.lineWidth = 5; x.beginPath(); x.moveTo(700, 820); x.lineTo(610, 700); x.stroke();
x.fillStyle = S.trail; for (let i = 0; i < 10; i++) { x.globalAlpha = i / 12; x.beginPath(); x.arc(780 - i * 10, 930 - i * 12, 6 + i, 0, TAU); x.fill(); } x.globalAlpha = 1;
kite(610, 690, -0.6, S, 2.6, x);
const lvl = isLv();
const big = lvl ? `${result ? result.sec : prog.best(mode.level)}s` : String(mode.kind === 'daily' ? dayStats(mode).best : lastScore);
x.fillStyle = S.text; x.textAlign = 'center';
x.font = `900 ${lvl ? 190 : 230}px system-ui, sans-serif`; x.fillText(big, 540, 420);
x.font = '700 46px system-ui, sans-serif'; x.fillText(lvl ? `${lv.starText(prog.stars(mode.level))} to the flag` : 'catches in a row', 540, 485);
x.textAlign = 'left'; x.font = '900 66px system-ui, sans-serif'; x.fillText('LATCHKITE', 80, 130);
x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif';
x.fillText(lvl ? `Level ${mode.level} ยท ${LEVELS[mode.level - 1].name}` : mode.kind === 'daily' ? `Daily course #${dayNumber(mode.date)} ยท ${mode.date}` : 'Practice course', 80, 190);
x.globalAlpha = 1; x.font = '800 48px system-ui, sans-serif'; x.fillText(lvl ? 'Can you fly it faster?' : 'Can you fly further?', 80, 1190);
x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/latchkite', 80, 1290);
return c;
}
const fileName = () => `latchkite-${isLv() ? `level-${mode.level}` : mode.date || 'practice'}.png`;
$('share').onclick = async () => share(GAME, mode.kind === 'practice' ? shareText() : `${shareText()}\n๐ฏ Beat me: ${beatLink()}`, PATH, await canvasBlob(card()), fileName());
['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
$('card').onclick = async () => { download(await canvasBlob(card()), fileName()); count(GAME, 'share', 'png'); };
$('beat').onclick = async () => {
const what = isLv() ? `cleared Latchkite level ${mode.level} in ${result ? result.sec : prog.best(mode.level)} s` : `caught ${dayStats(mode).best} pegs on Latchkite #${dayNumber(mode.date)}`;
const r = await lv.shareChallenge(`I ${what}. Can you beat me? ๐ช`, beatLink(), await canvasBlob(card()), fileName());
if (r === 'copied') toast('Challenge link copied. Send it to a friend!');
if (r !== 'cancelled') count(GAME, 'share', 'challenge');
};
$('retry').onclick = () => restart();
$('next-lv').onclick = () => { if (setMode({ kind: 'level', level: mode.level + 1 })) cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
$('to-daily').onclick = () => setMode({ kind: 'daily', date: todayIso });
// ------------------------------------------------------------------ input, skins, start
cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); });
cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowUp') { e.preventDefault(); input(); } });
document.addEventListener('keydown', (e) => {
if (e.target.closest && e.target.closest('input,textarea,dialog')) return;
if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); }
else if ((e.key === 'r' || e.key === 'R') && !e.ctrlKey && !e.metaKey) { e.preventDefault(); restart(); }
else if (e.key === 'm' || e.key === 'M') setMute(!muted);
});
$('pass-btn').onclick = () => pass.open('header');
$('skins-btn').onclick = () => skins.open('header');
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass);
const k = e.target.closest('[data-skins]'); if (k) skins.open(k.dataset.skins);
});
document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'run') last = performance.now(); });
function renderSkins() {
const el = $('skins'); el.innerHTML = '';
for (const [id, s] of Object.entries(SKINS)) {
const b = document.createElement('button');
const lock = !canUse(s);
b.type = 'button'; b.title = `${s.name}${s.tier === 'pass' ? ' (Fun Pass)' : s.tier === 'pack' ? ' (Skin Pack)' : ''}`; b.setAttribute('aria-label', b.title);
b.style.background = `linear-gradient(135deg, ${s.top} 50%, ${s.k1} 50%)`;
b.setAttribute('aria-pressed', String(id === skin));
if (lock) b.classList.add('locked');
b.onclick = () => {
if (lock) { if (s.tier === 'pass') pass.open('theme'); else skins.open('dot'); return; }
skin = id; store('latchkite:skin', id); renderSkins();
};
el.appendChild(b);
}
$('skins-btn').textContent = skins.owned() ? '๐ช Skins' : '๐ช Skin Pack $1.99';
}
const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'dawn'; store('latchkite:skin', 'dawn'); } renderSkins(); renderModes(); };
pass.onChange(refresh);
skins.onChange(refresh);
// Screenshot helper for scripts/games/render_assets.mjs (a mid-run frame; no effect on normal play).
window.__cmxShot = async (stage = 1) => {
if (isLv()) setMode({ kind: 'daily', date: todayIso });
start();
const s = makeSim(todayIso);
const peek = () => { const c = makeSim(todayIso); Object.assign(c, { peg: s.peg, a: s.a, len: s.len, dir: s.dir, score: s.score, held: 0 }); c.step(true); for (let i = 0; i < 120; i++) { const e = c.step(false); if (e) return e === 'latch'; } return false; };
while (s.score < 6 && !s.dead) s.step(!s.flying && s.held > 6 && peek());
for (let i = 0; i < 25; i++) s.step(false);
sim = s; camY = s.pegs[s.peg].y - 40; state = 'shot';
if (stage === 2) die();
};
window.__cmx = { spin };
// Demo recorder helper (scripts/studio/demo_video.mjs): plays real taps (pointer events) at catchable moments for n
// catches, then stops tapping so the round ends on its own. Cosmetic games only; there is no leaderboard to game.
window.__cmxDemo = (n = 8) => {
const fits = () => { const c = sim.clone(); c.held = 0; c.step(true); for (let i = 0; i < HZ * 2; i++) { const e = c.step(false); if (e) return (e === 'latch' || e === 'win') && c.peg === sim.peg + 1; } return false; };
const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
if (state !== 'run') tap();
const iv = setInterval(() => { if (state !== 'run' && state !== 'ready') { clearInterval(iv); return; } if (state === 'run' && sim.score < n && !sim.flying && !pending && sim.held > 8 && fits()) tap(); }, 4);
};
// Test hook (tests/e2e.mjs): auto-fly the current level with the level bot's own release ticks, optionally faster.
window.__cmxTest = {
autoLevel(fast = 1) {
if (!isLv()) return false;
const b = levelBot(makeLevelSim(mode.level), winOf(mode.level));
auto = new Set(b.taps); speed = fast;
start(); return b.won;
},
manual() { auto = null; speed = 1; },
state: () => ({ state, mode: { ...mode }, peg: sim.peg, goal: sim.goal, score: sim.score }),
};
/** Where a visit lands: a friend's link, ?lv= / ?d=, a first visit (level 1 + tutorial), else today's daily. */
function fromUrl() {
const q = new URLSearchParams(location.search);
if (chal && chal.level && chal.level <= LEVELS.length) return { kind: 'level', level: chal.level, challenge: true };
if (chal && chal.date) return { kind: 'daily', date: pass.isPro() || chal.date === todayIso ? chal.date : todayIso };
const lq = Number(q.get('lv'));
if (lq >= 1 && lq <= LEVELS.length) return { kind: 'level', level: lq };
const d = q.get('d');
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d) && dayNumber(d) >= 1 && d <= todayIso && pass.isPro()) return { kind: 'daily', date: d };
if (!returning()) return { kind: 'level', level: 1 };
return { kind: 'daily', date: todayIso };
}
// a locked link (a Fun Pass level, a level not reached yet) falls back to the next open level, then today's daily
setMode(fromUrl(), true) || (!prog.lock(prog.reached()) && setMode({ kind: 'level', level: prog.reached() }, true)) || setMode({ kind: 'daily', date: todayIso });
requestAnimationFrame(frame);