// Semantique — three.js paper scene inside a single gr.HTML component. // `element`, `props`, `server` are provided by Gradio; `gsap` is global (head). (async () => { // Gradio ships its own viewport meta; update that single tag (don't add a // second) to lock zoom so swipes can't pinch/double-tap-zoom the board. { let vp = document.querySelector('meta[name="viewport"]'); if (!vp) { vp = document.createElement("meta"); vp.name = "viewport"; document.head.appendChild(vp); } vp.content = "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"; } // ---- embed fit (HF Spaces) ---- // HF serves the Space inside an iframe driven by iframe-resizer, which sizes // the iframe to its content. Our `100dvh` root then resolves to the iframe's // OWN height, so every measure-and-grow cycle makes the content taller — the // iframe balloons without bound and the (vertically centered) board ends up // thousands of px below the fold: "it all drags down". Localhost has no // iframe, so `100dvh` is the real window and this branch never runs. // Fix: let the document be content-height and pin the game to a fixed pixel // height — the parent's visible viewport — so the iframe settles instead of // feeding back on itself. if (window.self !== window.top) { const rootEl = element.querySelector(".sq-root"); document.documentElement.style.height = document.body.style.height = "auto"; const fit = (h) => h > 0 && (rootEl.style.height = Math.round(h) + "px"); // Instant pin, before the resizer API is ready; cap by the screen so any // runaway that already started can't lock in a giant value. fit(Math.min(window.innerHeight, window.screen.height || Infinity)); // Gradio's shell (.main.fillable / .wrap / .contain) sits a few constant px // taller than our component, so the iframe-resizer measures `root + chrome`. // Size root so the WHOLE document equals the slot: subtract that measured // excess. Cached so repeat getPageInfo events don't oscillate. let chrome = 0, lastKey = ""; const apply = (info) => { // Available slot = parent viewport minus how far the iframe sits below the // top (HF's header). Deliberately SCROLL-INVARIANT: clientHeight and // offsetTop don't change as the parent scrolls, only scrollTop does — so // folding scrollTop in (as we used to) refit the board on every scroll // tick, resizing and REALLOCATING the WebGL drawing buffer each time. That // was the "lags much more on HF" jank, and the board reframing mid-scroll // is what clipped the keycap text. Keep avail off scrollTop and the // constant getPageInfo scroll events collapse into no-ops below. const avail = Math.max(0, info.clientHeight - info.offsetTop); const key = avail + "x" + info.clientWidth; if (key === lastKey) return; // viewport unchanged — a pure scroll, skip lastKey = key; fit(avail - chrome); requestAnimationFrame(() => { const excess = document.body.offsetHeight - rootEl.offsetHeight; if (excess >= 0 && excess !== chrome) { chrome = excess; fit(avail - chrome); } }); }; // getPageInfo re-fires on parent scroll/resize; a genuine viewport change // (rotating phone, collapsing mobile URL bar) shifts clientHeight and refits, // while plain scrolling now no-ops via the key guard above. const poll = setInterval(() => { if (!window.parentIFrame) return; clearInterval(poll); window.parentIFrame.getPageInfo(apply); }, 50); setTimeout(() => clearInterval(poll), 5000); // give up waiting for the API } const THREE = await import("https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js"); // props.value: { levels: [clientValue...], order: [id...], home: id } const data = props.value; const LEVELS = Object.fromEntries(data.levels.map((l) => [l.id, l])); const ORDER = data.order; let level = LEVELS[data.home]; // the active board; swapped by loadLevel() let ROWS = level.grid.length; let COLS = level.grid[0].length; // Checked-off targets persist per board across switches and rounds. const checkedByLevel = Object.fromEntries(data.levels.map((l) => [l.id, new Set()])); const root = element.querySelector(".sq-root"); const stage = element.querySelector(".sq-stage"); const INK = "#1c1b18"; const INK_SOFT = "#5a564c"; const ACCENT = "#b3402e"; // A submit key ("⏎") ships the sentence to the judge. The glitch bonus board // has none — its only submit is the prompt injection (see injectionSubmit). const isSubmit = (w) => w === "⏎"; // The "shrink" tile (a reserved wordless icon) shrinks the doodle. const isShrink = (w) => w === "shrink"; // A "file" tile (name.ext) is a context bomb: stand on it and its bytes flood // the window — instant "context window exceeded" death (see fileDeath). const isFile = (w) => /\.[a-z0-9]{2,4}$/i.test(w); // An image file (dog.png and friends) gets a photo glyph instead of ruled text. const isImageFile = (w) => /\.(png|jpe?g|gif|webp|svg|bmp|avif)$/i.test(w); // A grid cell appends its word unless it's structural (start / blank / ⏎ / // wings / portal / shift) or a special glitch tile (shrink / file bomb). const appendsWord = (w) => w && w !== "start" && !isSubmit(w) && !isShrink(w) && !isFile(w) && w !== "wings" && w !== "portal" && w !== "shift"; // Persistent doodle state (carries across boards + the critters round-trip): // `shrunk` after the "?" tile, `hasHug` after clearing critters. let shrunk = false; let hasHug = false; const SHRINK_SCALE = 0.56; // Breadcrumb for the cross-level portal: the board to offer a way back to. let returnTo = null; // Run-wide tally surfaced on the win screen — the story of the whole session. // Spans every board AND survives the "delete all data" wipe (a crash is itself // a stat), so it is deliberately NEVER reset; only `loadGame`/page reload // starts it over. `startMs` is stamped on the first hop so the clock measures // time spent playing, not time spent reading the welcome card. const stats = { hops: 0, // every tile-to-tile hop the doodle makes, all boards startMs: 0, // performance.now() at the first hop; 0 until then hints: 0, // hint reveals ("yes, show me the hint") crashes: 0, // "delete all data" 404 wipes triggered wins: 0, // targets collected (judge wins + the hackathon finale) losses: 0, // judged sentences that missed their target deaths: 0, // ran out of hops / face-planted a context bomb }; // Each injection target needs its unlock: "build" is free, "small" needs the // shrink, "hackathon" needs the 🤗 earned by clearing critters. `submitPressable` // is whether a target's lane tile can be shipped right now (unlocked + unshipped). const targetUnlocked = (t) => t === "build" || (t === "small" && shrunk) || (t === "hackathon" && hasHug); const submitPressable = (t) => targetUnlocked(t) && !checkedOnThisLevel().has(t); // a lane key's render state: shipped → "broken" (walk over it), unlocked → // "ready" (pressable), else "locked". const submitTileState = (t) => checkedOnThisLevel().has(t) ? "broken" : targetUnlocked(t) ? "ready" : "locked"; // ---- HUD ---- // The targets checklist: every label to collect. Checks persist per board // across rounds ("hop again" keeps them), so the meta-game is collecting all. const targetListEl = element.querySelector(".sq-target-list"); let targetItems = {}; // label -> chip element, rebuilt per board const checkedOnThisLevel = () => checkedByLevel[level.id]; const remainingTargets = () => level.targets.filter((t) => !checkedOnThisLevel().has(t)); // (Re)build the checklist for the active board, restoring any persisted checks. function buildTargets() { targetListEl.innerHTML = ""; targetItems = {}; const checked = checkedOnThisLevel(); for (const label of level.targets) { const item = document.createElement("span"); item.className = "sq-target-item"; item.style.setProperty("--tilt", `${(Math.random() * 4 - 2.5).toFixed(1)}deg`); item.appendChild(document.createTextNode(label)); if (checked.has(label)) item.classList.add("sq-checked"); targetListEl.appendChild(item); targetItems[label] = item; } element.querySelector(".sq-ctx-cap").textContent = level.budget; } function checkOff(label) { checkedOnThisLevel().add(label); const item = targetItems[label]; item.classList.add("sq-checked"); // fades + strikes the word through gsap.fromTo(item, { scale: 1.3 }, { scale: 1, duration: 0.3, ease: "back.out(2)" }); // collecting the last target completes the board — reveal the forward swap tile if (remainingTargets().length === 0) { revealForwardSwap(); if (level.id === "animal") grantHug(); // clearing critters earns the 🤗 if (level.id === "bonus") gsap.delayedCall(0.9, showVictory); // all three guessed } } // Clearing critters earns the 🤗 — worn on the head from then on (drawCharCanvas). function grantHug() { if (hasHug) return; hasHug = true; setPose(charPose); // redraw the doodle wearing the trophy sfx.pop(); gsap.fromTo(charMesh.scale, { x: 1.35, y: 1.35 }, { x: 1, y: 1, duration: 0.5, ease: "elastic.out(1,0.5)" }); } // The hop counter: a plain "N/budget" counter that ticks up each hop. // (The cap text is set per board in buildTargets.) const countEl = element.querySelector(".sq-context-count"); const usedEl = element.querySelector(".sq-ctx-used"); const hud = { update(used) { usedEl.textContent = used; }, warnFull() { sfx.warn(); countEl.classList.add("sq-full"); gsap.to(countEl, { x: "+=3", yoyo: true, repeat: 5, duration: 0.05 }); }, overflow() { usedEl.textContent = level.budget + 1; countEl.classList.add("sq-full"); }, reset() { usedEl.textContent = 0; countEl.classList.remove("sq-full"); }, }; // Fonts must be loaded before we bake Patrick Hand into canvas textures AND // before the HUD/prompt/card lay out their Caveat text — otherwise the DOM // text renders in a fallback face, then reflows when the webfont lands. // document.fonts.ready alone is unreliable here: the Google Fonts stylesheet // arrives async, and `ready` only waits for faces already in active use, so // Caveat 600 (the verdict card) isn't covered. Request every weight we use. await Promise.all([ document.fonts.load('400 80px "Patrick Hand"'), document.fonts.load('700 42px "Caveat"'), document.fonts.load('600 34px "Caveat"'), document.fonts.ready, ]); // ---- hand-drawn canvas helpers ---- const rand = (a, b) => a + Math.random() * (b - a); // A label that mostly reads straight but every so often corrupts into glyphs — // the "bonus" swap tile flickers between its name and garbage to advertise the // glitch board behind it. Called fresh each boil tick, so the corruption is // transient: calm most frames, a burst of glyphs now and then. const GLITCH_GLYPHS = "▚▞▓▒░█◣◢◤◥⌗⟁¥§Ø∆#%&@?!"; const glitchGlyph = () => GLITCH_GLYPHS[(Math.random() * GLITCH_GLYPHS.length) | 0]; function glitchLabel(text) { if (Math.random() < 0.62) return text; // most ticks it just says its name let out = ""; for (const ch of text) out += Math.random() < 0.5 ? glitchGlyph() : ch; return out; } // The glitch BOARD's own word tiles use a gentler flicker than the loud swap // tile: most ticks they read straight, and when one does corrupt it nibbles // just a letter or two, so the word stays legible while the board still feels // unstable. function glitchWord(text) { if (!text || Math.random() < 0.7) return text; // mostly its name const chars = [...text]; const n = chars.length > 1 && Math.random() < 0.5 ? 2 : 1; // a letter or two for (let k = 0; k < n; k++) chars[(Math.random() * chars.length) | 0] = glitchGlyph(); return chars.join(""); } // A rounded-rect path drawn as short jittered segments — wobbly ink line. function wobblyRoundRect(ctx, x, y, w, h, r, jitter) { const pts = []; const seg = 14; // sample points per edge const corner = (cx, cy, a0, a1) => { for (let i = 0; i <= 6; i++) { const a = a0 + (a1 - a0) * (i / 6); pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); } }; const edge = (x0, y0, x1, y1) => { for (let i = 1; i < seg; i++) { const t = i / seg; pts.push([x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]); } }; corner(x + r, y + r, Math.PI, Math.PI * 1.5); edge(x + r, y, x + w - r, y); corner(x + w - r, y + r, Math.PI * 1.5, Math.PI * 2); edge(x + w, y + r, x + w, y + h - r); corner(x + w - r, y + h - r, 0, Math.PI * 0.5); edge(x + w - r, y + h, x + r, y + h); corner(x + r, y + h - r, Math.PI * 0.5, Math.PI); edge(x, y + h - r, x, y + r); ctx.beginPath(); pts.forEach(([px, py], i) => { const jx = px + rand(-jitter, jitter); const jy = py + rand(-jitter, jitter); i === 0 ? ctx.moveTo(jx, jy) : ctx.lineTo(jx, jy); }); ctx.closePath(); } const TILE_PX = 384; // logical texture units per tile // Canvas textures are baked at devicePixelRatio so words and ink lines stay // crisp on hidpi screens (all drawing keeps using logical coordinates). const TEX_SCALE = Math.min(window.devicePixelRatio || 1, 2); // The bare keycap face: opaque paper base + the wobbly double-stroke rim that // doubles as the 3D silhouette (the extruded body follows this outline). // `special` softens the ink (start / ⏎ / swap); `dashed` adds the "press me" // dashed rim (the submit and swap tiles — the start tile stays solid). function drawKeycapBase(ctx, special, dashed) { ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); // opaque paper base — the keycap body is the rounded outline itself now, // so this whole canvas IS the cap face (extruded silhouette clips it) ctx.fillStyle = "#faf8f2"; ctx.fillRect(0, 0, TILE_PX, TILE_PX); ctx.lineJoin = "round"; ctx.lineCap = "round"; // the ink line sits AT the geometry's rim — outer jitter clips against the // extruded silhouette, so the drawn outline and the 3D edge are one line const inset = 5; const span = TILE_PX - inset * 2; const cr = Math.round(span * 0.17); wobblyRoundRect(ctx, inset, inset, span, span, cr, 3); ctx.fillStyle = "rgba(255, 253, 247, 0.92)"; ctx.fill(); // double ink stroke = "traced twice" feel ctx.strokeStyle = special ? INK_SOFT : INK; ctx.setLineDash(dashed ? [16, 13] : []); ctx.lineWidth = 7; ctx.stroke(); ctx.setLineDash([]); wobblyRoundRect(ctx, inset, inset, span, span, cr, 4.5); ctx.strokeStyle = special ? "rgba(90,86,76,0.35)" : "rgba(28,27,24,0.35)"; ctx.lineWidth = 4; ctx.stroke(); } // A wings keycap: a stamped pair of spread wings in accent ink — the "lift // off" tile. Hopping onto it sprouts the doodle's wings and sends it airborne. function drawWingsKeyIcon(ctx) { const cx = TILE_PX / 2, cy = 200; ctx.lineJoin = ctx.lineCap = "round"; for (const sign of [-1, 1]) { const ax = cx + sign * 14, ay = cy + 6; // wing root, near the centre const tipx = ax + sign * 128, tipy = cy - 70; const elbx = cx + sign * 84, elby = cy - 40; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.quadraticCurveTo(elbx, elby - 40, tipx, tipy); // leading edge up to the tip // scalloped trailing edge (feathers) back to the root ctx.quadraticCurveTo(cx + sign * 96, cy + 6, cx + sign * 70, cy + 24); ctx.quadraticCurveTo(cx + sign * 58, cy + 4, cx + sign * 44, cy + 32); ctx.quadraticCurveTo(cx + sign * 34, cy + 10, ax, ay); ctx.closePath(); ctx.fillStyle = "rgba(255,253,247,0.92)"; ctx.fill(); ctx.strokeStyle = ACCENT; ctx.lineWidth = 9; ctx.stroke(); // a couple of feather divider strokes ctx.lineWidth = 4.5; wobblyLine(ctx, ax + sign * 10, ay - 8, elbx, elby + 4, 2); ctx.stroke(); wobblyLine(ctx, ax + sign * 26, ay + 2, elbx + sign * 8, elby + 22, 2); ctx.stroke(); } } // A shift keycap: two stacked accent ⇨ arrows — the "shuffle the rows" key. // Hopping onto it rotates every row under the top one a column to the right. function drawShiftKeyIcon(ctx) { ctx.strokeStyle = ACCENT; ctx.lineJoin = ctx.lineCap = "round"; const cx = TILE_PX / 2; const half = 70; // arrow half-length const barb = 42; // barb reach for (const ay of [150, 234]) { // two arrows, stacked const tip = cx + half; ctx.lineWidth = 16; wobblyLine(ctx, cx - half, ay, tip, ay, 3); ctx.stroke(); // shaft wobblyLine(ctx, tip, ay, tip - barb, ay - barb * 0.82, 3); ctx.stroke(); // upper barb wobblyLine(ctx, tip, ay, tip - barb, ay + barb * 0.82, 3); ctx.stroke(); // lower barb } } // A file keycap: a dog-eared sheet of paper stamped with the filename — the // context-bomb tiles (dog.png / diary.txt / receipt.pdf). Hopping one floods // the window and kills you (see fileDeath); the page reads "a big file" at a glance. function drawFileKeyIcon(ctx, name) { const cx = TILE_PX / 2; ctx.lineJoin = ctx.lineCap = "round"; const pw = 116, ph = 132, px = cx - pw / 2, py = 58, fold = 34; ctx.beginPath(); // sheet outline with a folded top-right corner ctx.moveTo(px, py); ctx.lineTo(px + pw - fold, py); ctx.lineTo(px + pw, py + fold); ctx.lineTo(px + pw, py + ph); ctx.lineTo(px, py + ph); ctx.closePath(); ctx.fillStyle = "rgba(255,253,247,0.92)"; ctx.fill(); ctx.strokeStyle = ACCENT; ctx.lineWidth = 7; ctx.stroke(); ctx.beginPath(); // the dog-ear ctx.moveTo(px + pw - fold, py); ctx.lineTo(px + pw - fold, py + fold); ctx.lineTo(px + pw, py + fold); ctx.stroke(); if (isImageFile(name)) { // an image file: a framed photo — sun over two mountains — so dog.png // reads as a picture, not a text document. ctx.strokeStyle = INK_SOFT; const fx = px + 14, fy = py + 16, fw = pw - 28, fh = ph - 30, base = fy + fh; ctx.lineWidth = 5; ctx.strokeRect(fx, fy, fw, fh); // the photo's frame ctx.beginPath(); // the sun ctx.arc(fx + 24, fy + 24, 12, 0, Math.PI * 2); ctx.stroke(); ctx.lineWidth = 6; // two mountains rising from the frame floor wobblyLine(ctx, fx + 2, base, fx + 34, base - 60, 2); ctx.stroke(); wobblyLine(ctx, fx + 34, base - 60, fx + 58, base, 2); ctx.stroke(); wobblyLine(ctx, fx + 40, base, fx + 64, base - 42, 2); ctx.stroke(); wobblyLine(ctx, fx + 64, base - 42, fx + fw - 2, base, 2); ctx.stroke(); } else { ctx.strokeStyle = INK_SOFT; // ruled text lines on the page ctx.lineWidth = 4; for (let i = 0; i < 4; i++) { const yy = py + 52 + i * 18; wobblyLine(ctx, px + 16, yy, px + pw - 16, yy, 1.4); ctx.stroke(); } } ctx.fillStyle = INK; // filename below the sheet const size = name.length > 10 ? 50 : 58; ctx.font = `400 ${size}px "Patrick Hand"`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(name, cx + rand(-2, 2), 256 + rand(-1, 2)); } // A shrink keycap: four accent arrows converging on the centre — the "make me // small" key. Hopping onto it shrinks the doodle. function drawShrinkKeyIcon(ctx) { ctx.strokeStyle = ACCENT; ctx.lineJoin = ctx.lineCap = "round"; const c = TILE_PX / 2, out = 96, inn = 30, barb = 30; // one inward arrow per corner: shaft from the corner toward the centre, // arrowhead (two barbs) at the inner tip, splaying back toward the corner. for (const [sx, sy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { const ox = c + sx * out, oy = c + sy * out; // outer (corner) end const ix = c + sx * inn, iy = c + sy * inn; // inner tip (toward centre) ctx.lineWidth = 15; wobblyLine(ctx, ox, oy, ix, iy, 3); ctx.stroke(); // shaft ctx.lineWidth = 13; wobblyLine(ctx, ix, iy, ix + sx * barb, iy, 2); ctx.stroke(); // horizontal barb wobblyLine(ctx, ix, iy, ix, iy + sy * barb, 2); ctx.stroke(); // vertical barb } } function drawTileCanvas(ctx, word) { const special = word === "start" || isSubmit(word) || isShrink(word) || word === "wings" || word === "shift" || word === "portal"; // the submit / shift / shrink tiles keep a dashed "press me" rim; start, // wings and portal read as normal solid keycaps. drawKeycapBase(ctx, special, isSubmit(word) || word === "shift" || isShrink(word)); // wordless icon tiles — the icon IS the cue (never glitched). if (word === "wings") return drawWingsKeyIcon(ctx); if (word === "shift") return drawShiftKeyIcon(ctx); if (isShrink(word)) return drawShrinkKeyIcon(ctx); if (isFile(word)) return drawFileKeyIcon(ctx, word); // a context-bomb file // the word — start/empty/portal tiles are blank squares (the portal's spiral // is a separate spinning mesh), so they stay wordless const blank = !word || word === "start" || word === "portal"; if (!blank) { // the delete-chain's `data` key, armed: redraw its rim + word in red (NO // fill — the cap face stays the glitch dark). The keycap base (paper + ink // rim) was already laid down above, so the thicker red rim paints straight // over the ink one. Pre-invert so the red lands true under the glitch // board's CSS negative (the same trick the 🤗 / rainbow-finale keys use). if (dataArmed && level.glitch && word === "data") { ctx.save(); ctx.filter = "hue-rotate(180deg) invert(1)"; ctx.strokeStyle = ctx.fillStyle = "#d23a2a"; ctx.lineJoin = ctx.lineCap = "round"; const ins = 5, span = TILE_PX - ins * 2, cr = Math.round(span * 0.17); ctx.lineWidth = 9; wobblyRoundRect(ctx, ins, ins, span, span, cr, 3.5); ctx.stroke(); ctx.font = `400 88px "Patrick Hand"`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(glitchWord(word), TILE_PX / 2 + rand(-2, 2), TILE_PX / 2 + rand(-1, 3)); ctx.restore(); return; } ctx.fillStyle = special ? INK_SOFT : INK; // on a glitch board the word tiles flicker a letter or two into glyphs // (logic still uses the real word — this is purely the rendered face) const shown = level.glitch ? glitchWord(word) : word; // bigger, more legible keycap text; the longest words (≥9 chars) drop a // tier so they still clear the cap's rounded rim (safe width ≈ 320px). let size = word.length > 8 ? 72 : word.length > 5 ? 80 : 88; ctx.font = `400 ${size}px "Patrick Hand"`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(shown, TILE_PX / 2 + rand(-2, 2), TILE_PX / 2 + rand(-1, 3)); } } // A board-swap keycap: a special dashed cap stamped with a big accent arrow // (↓, the world tile sits below the board — you hop down onto it) and the // destination board's title beneath the arrow. function drawSwapTileCanvas(ctx, label) { drawKeycapBase(ctx, true, true); const cx = TILE_PX / 2; const top = 86, tip = 212; // vertical shaft, pointing down const barb = 52; ctx.strokeStyle = ACCENT; ctx.lineJoin = ctx.lineCap = "round"; ctx.lineWidth = 20; wobblyLine(ctx, cx, top, cx, tip, 4); ctx.stroke(); // shaft wobblyLine(ctx, cx, tip, cx - barb, tip - 50, 3); ctx.stroke(); // left barb wobblyLine(ctx, cx, tip, cx + barb, tip - 50, 3); ctx.stroke(); // right barb if (label) { ctx.fillStyle = INK; const size = label.length > 7 ? 56 : 64; ctx.font = `400 ${size}px "Patrick Hand"`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(label, cx + rand(-2, 2), 288 + rand(-1, 2)); } } // A jagged ink fault across a keycap — a "shipped, now broken" key you step over. function drawCracks(ctx) { ctx.strokeStyle = INK; ctx.lineJoin = ctx.lineCap = "round"; const cx = TILE_PX / 2; const jag = (pts, w) => { ctx.lineWidth = w; ctx.beginPath(); pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke(); }; jag([[cx - 46, 46], [cx + 24, 150], [cx - 28, 214], [cx + 44, 300], [cx - 8, 340]], 7); // main fault jag([[cx + 24, 150], [TILE_PX - 56, 126]], 5); // branch right jag([[cx - 28, 214], [56, 248]], 5); // branch left jag([[cx + 44, 300], [TILE_PX - 72, 334]], 3); // hairline } // A horizontal rainbow sweeping across `w`, its phase advancing with the clock // so a tile redrawn each boil tick reads as an animated shimmer. The hackathon // finale key uses this once the 🤗 unlocks it. const rainbowPhase = () => (performance.now() / 9) % 360; // ~40°/s drift function rainbowGradient(ctx, x0, x1, phase) { const g = ctx.createLinearGradient(x0, 0, x1, 0); for (let i = 0; i <= 6; i++) g.addColorStop(i / 6, `hsl(${(phase + i * 64) % 360}, 92%, 56%)`); return g; } // An injection-payoff keycap — "build" / "small" / "hackathon", risen in a lane // beside output (see injectionSubmit). `state`: "ready" (collectable) → solid // ink + dashed "press me" rim; "locked" (behind shrink/🤗) → soft ink, plain // rim; "broken" (already shipped) → soft + cracked, so you step over it. // The hackathon key, once READY (you're wearing the 🤗), glows in an animated // rainbow — the earned-finale tile, the one win that needs no judge. function drawSubmitTileCanvas(ctx, label, state) { const ready = state === "ready"; drawKeycapBase(ctx, !ready, ready); const size = label.length > 6 ? 64 : 88; // "hackathon" drops a tier ctx.font = `400 ${size}px "Patrick Hand"`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; const tx = TILE_PX / 2 + rand(-2, 2), ty = TILE_PX / 2 + rand(-1, 3); if (ready && label === "hackathon") { // The glitch board CSS-inverts the scene (invert(1) hue-rotate(180)); pre- // apply the exact inverse here so the rainbow lands in true colours (the // same trick the 🤗 emoji uses), instead of going negative. ctx.save(); if (level.glitch) ctx.filter = "hue-rotate(180deg) invert(1)"; ctx.fillStyle = rainbowGradient(ctx, size * 0.6, TILE_PX - size * 0.6, rainbowPhase()); ctx.fillText(label, tx, ty); ctx.restore(); } else { ctx.fillStyle = ready ? INK : INK_SOFT; ctx.fillText(label, tx, ty); } if (state === "broken") drawCracks(ctx); // shipped — cracked over the word } // Keycap sides: same paper as the cap so the button reads as ONE drawn // object — hatch shading for depth and a bold ink line along the bottom // silhouette (the cap's ring already inks the top edge; no second rim). const SIDE_PX_W = 384; const SIDE_PX_H = 128; function drawTileSideCanvas(ctx) { ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); ctx.fillStyle = "#faf8f2"; ctx.fillRect(0, 0, SIDE_PX_W, SIDE_PX_H); ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.strokeStyle = "rgba(28,27,24,0.13)"; ctx.lineWidth = 5; for (let x = rand(8, 44); x < SIDE_PX_W; x += rand(52, 84)) { wobblyLine(ctx, x, SIDE_PX_H - 12, x + 67, 14, 2); // ~-55° up-right ctx.stroke(); } // bottom outline — wraps the whole silhouette via the side-wall UVs ctx.strokeStyle = INK; ctx.lineWidth = 7; wobblyLine(ctx, -6, SIDE_PX_H - 7, SIDE_PX_W + 6, SIDE_PX_H - 7, 2.5); ctx.stroke(); } // The portal spiral: a hand-drawn Archimedean coil on a transparent square, // baked once and worn by every portal keycap (a disc that spins it in place — // see the animation loop). Drawn ink-on-paper to match the keycaps: an accent // under-stroke, a double ink pass for the "traced twice" feel, and a filled // dot at the eye so the swirl reads as a hole to drop into. const SPIRAL_PX = 256; function drawSpiralCanvas(ctx) { ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); ctx.clearRect(0, 0, SPIRAL_PX, SPIRAL_PX); ctx.lineJoin = ctx.lineCap = "round"; const cx = SPIRAL_PX / 2, cy = SPIRAL_PX / 2; const path = (jit) => { const turns = 3.0, maxR = SPIRAL_PX * 0.4, steps = 200; ctx.beginPath(); for (let i = 0; i <= steps; i++) { const t = i / steps; const a = t * Math.PI * 2 * turns; const r = 7 + t * maxR; const x = cx + r * Math.cos(a) + rand(-jit, jit); const y = cy + r * Math.sin(a) + rand(-jit, jit); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } }; ctx.globalAlpha = 0.5; // accent under-spiral, slightly fatter ctx.strokeStyle = ACCENT; ctx.lineWidth = 11; path(2); ctx.stroke(); ctx.globalAlpha = 1; // ink spiral, traced twice ctx.strokeStyle = INK; ctx.lineWidth = 6; path(2.4); ctx.stroke(); ctx.strokeStyle = "rgba(28,27,24,0.4)"; ctx.lineWidth = 3; path(1.4); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, 7, 0, Math.PI * 2); // the eye of the portal ctx.fillStyle = INK; ctx.fill(); } // ---- three.js paper scene ---- const scene = new THREE.Scene(); // transparent: CSS paper + ruled lines show through const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100); camera.position.set(0, -0.9, 11.5); // slightly below-front: keycap sides read camera.lookAt(0, 0.25, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); // Render the framebuffer at the device's pixel density (capped at 3 to bound // fill-rate). Phones are dpr 3, so the old cap of 2 rendered at 2x and let the // browser upscale to the 3x screen — the whole board looked soft ("low-rez") // on mobile, more so now the board fills more of the width. The keycap TEXTURES // stay capped at 2x (TEX_SCALE): a 768px tile texture already oversamples the // on-screen tile, so a 3x texture would only add memory + boil-redraw cost. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 3)); stage.appendChild(renderer.domElement); // Tilt group: the whole board leans away from the viewer, 22° back from // vertical, like a keyboard propped up on a desk. const boardTilt = new THREE.Group(); boardTilt.rotation.x = THREE.MathUtils.degToRad(68); scene.add(boardTilt); // Board group: everything sits here, slightly slanted like a sketch. const board = new THREE.Group(); board.rotation.z = THREE.MathUtils.degToRad(1.5); boardTilt.add(board); // ---- tiles ---- const SPACING = 1.5; const TILE_SIZE = 1.32; const tileAt = (r, c) => ({ x: (c - (COLS - 1) / 2) * SPACING, z: (r - (ROWS - 1) / 2) * SPACING, }); // Tiles are keycaps: an extruded rounded-rect silhouette — the 3D body // follows the drawn outline directly (no square box behind it). The TOP // FACE sits at y=0, so the old "tile surface" coordinates still mean the // same thing. const TILE_DEPTH = 0.42; const TILE_R = TILE_SIZE * 0.17; // corner radius — the ink line traces it const tileShape = new THREE.Shape(); { const h = TILE_SIZE / 2; tileShape.moveTo(-h + TILE_R, -h); tileShape.lineTo(h - TILE_R, -h); tileShape.quadraticCurveTo(h, -h, h, -h + TILE_R); tileShape.lineTo(h, h - TILE_R); tileShape.quadraticCurveTo(h, h, h - TILE_R, h); tileShape.lineTo(-h + TILE_R, h); tileShape.quadraticCurveTo(-h, h, -h, h - TILE_R); tileShape.lineTo(-h, -h + TILE_R); tileShape.quadraticCurveTo(-h, -h, -h + TILE_R, -h); } const tileGeometry = new THREE.ExtrudeGeometry(tileShape, { depth: TILE_DEPTH, bevelEnabled: false, curveSegments: 6, }); // Extrude UVs come out in shape units, not 0..1 — remap the caps to span // the texture, and the side wall so v runs bottom→top (ink rim at the top). { const pos = tileGeometry.attributes.position; const uv = tileGeometry.attributes.uv; const [caps, walls] = tileGeometry.groups; for (let i = caps.start; i < caps.start + caps.count; i++) { uv.setXY(i, pos.getX(i) / TILE_SIZE + 0.5, pos.getY(i) / TILE_SIZE + 0.5); } for (let i = walls.start; i < walls.start + walls.count; i++) { uv.setXY(i, uv.getX(i) / TILE_SIZE + 0.5, pos.getZ(i) / TILE_DEPTH); } } // Shape lies in XY, extruded toward +z — stand it up so the cap faces +y // with the top surface at local y=+TILE_DEPTH/2 (mesh y stays -TILE_DEPTH/2). tileGeometry.rotateX(-Math.PI / 2); tileGeometry.translate(0, -TILE_DEPTH / 2, 0); // One shared side texture/material for all 16 keycaps. const sideCanvas = document.createElement("canvas"); sideCanvas.width = SIDE_PX_W * TEX_SCALE; sideCanvas.height = SIDE_PX_H * TEX_SCALE; const sideCtx = sideCanvas.getContext("2d"); drawTileSideCanvas(sideCtx); const sideTexture = new THREE.CanvasTexture(sideCanvas); sideTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); sideTexture.colorSpace = THREE.SRGBColorSpace; const sideMaterial = new THREE.MeshBasicMaterial({ map: sideTexture }); // One shared spiral disc, reused by every portal keycap. A flat plane wearing // the baked spiral texture, lying face-up just above the cap top; each portal // gets its own Mesh (so it can spin independently) but they share this // geometry/texture/material — the spin lives on the mesh, not the texture. const SPIRAL_LIFT = 0.02; // height above the keycap top, so it never z-fights const spiralCanvas = document.createElement("canvas"); spiralCanvas.width = spiralCanvas.height = SPIRAL_PX * TEX_SCALE; drawSpiralCanvas(spiralCanvas.getContext("2d")); const spiralTexture = new THREE.CanvasTexture(spiralCanvas); spiralTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); spiralTexture.colorSpace = THREE.SRGBColorSpace; const spiralMaterial = new THREE.MeshBasicMaterial({ map: spiralTexture, transparent: true, depthWrite: false, side: THREE.DoubleSide, }); const spiralGeometry = new THREE.PlaneGeometry(TILE_SIZE * 0.9, TILE_SIZE * 0.9); spiralGeometry.rotateX(-Math.PI / 2); // lie flat, facing +y like the keycap top let tiles = []; // { mesh, ctx, texture, word, row, col, spinner } let portalSpinners = []; // the spinning spiral discs, spun by the render loop let portalDest = {}; // "r,c" -> [row,col] of the next portal clockwise const maxAniso = renderer.capabilities.getMaxAnisotropy(); // (Re)build the keycaps for the active board, disposing the previous set. function buildTiles() { for (const t of tiles) { gsap.killTweensOf(t.mesh.position); // drop any in-flight shuffle tween gsap.killTweensOf(t.mesh.material[0]); // and any fade tween (glitch board) gsap.killTweensOf(t.mesh.material[1]); board.remove(t.mesh); t.texture.dispose(); t.mesh.material[0].dispose(); // per-tile cap material if (t.mesh.material[1] !== sideMaterial) t.mesh.material[1].dispose(); // per-tile side clone (glitch) } tiles = []; portalSpinners = []; // children of their tile meshes — disposed with them disposeSubmitTiles(); // drop any leftover injection tiles from a prior run ROWS = level.grid.length; COLS = level.grid[0].length; for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { const word = level.grid[r][c]; const canvas = document.createElement("canvas"); canvas.width = canvas.height = TILE_PX * TEX_SCALE; const ctx = canvas.getContext("2d"); drawTileCanvas(ctx, word); const texture = new THREE.CanvasTexture(canvas); texture.anisotropy = maxAniso; texture.colorSpace = THREE.SRGBColorSpace; // ExtrudeGeometry groups: [0] = caps (top + hidden bottom), [1] = side wall. // The shared side material is fine everywhere except the glitch board, // where tiles fade independently (the injection dim) — so clone it there. const mesh = new THREE.Mesh(tileGeometry, [ new THREE.MeshBasicMaterial({ map: texture, transparent: false }), level.glitch ? sideMaterial.clone() : sideMaterial, ]); const { x, z } = tileAt(r, c); mesh.position.set(x, -TILE_DEPTH / 2, z); // top face flush with y=0 board.add(mesh); // a portal wears a spinning spiral disc just above its keycap top. It's // a child of the keycap, so it drops/presses/disposes with the tile. let spinner = null; if (word === "portal") { spinner = new THREE.Mesh(spiralGeometry, spiralMaterial); spinner.position.y = TILE_DEPTH / 2 + SPIRAL_LIFT; // above the top face (mesh-local) mesh.add(spinner); portalSpinners.push(spinner); } tiles.push({ mesh, ctx, texture, word, row: r, col: c, spinner }); } } buildPortalLinks(); } const tile = (r, c) => tiles[r * COLS + c]; // Wire each portal to the next one clockwise around the board centre. Angle is // measured in screen space (x = col, y = up = -row); clockwise is decreasing // angle, so portals sorted by descending angle each point at the one after. function buildPortalLinks() { portalDest = {}; const cells = []; for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (level.grid[r][c] === "portal") cells.push([r, c]); if (cells.length < 2) return; // a lone portal links nowhere const cr = (ROWS - 1) / 2, cc = (COLS - 1) / 2; const ang = ([r, c]) => Math.atan2(cr - r, c - cc); cells.sort((a, b) => ang(b) - ang(a)); // descending angle = clockwise for (let i = 0; i < cells.length; i++) { const [fr, fc] = cells[i]; portalDest[fr + "," + fc] = cells[(i + 1) % cells.length]; } } // ---- board-swap tiles ---- // A lone keycap just below the grid: hop down onto it and it loads the next // board. It lives OUTSIDE the rectangular `tiles` array (so the r*COLS+c index // math is untouched) at the virtual cell (ROWS, startCol) — the next grid row, // straight under the start column, flush against the board (no extra gap). // Below (not off the right edge) so it never overflows the narrow phone width, // and so every board can reach it: a board's right edge may be a portal or // other non-restable tile, but the cell above the bottom edge is always // walkable. let swapTiles = []; // { mesh, ctx, texture, row, col, targetId, active, x, z, label } const swapTileAt = (r, c) => swapTiles.find((s) => s.active && s.row === r && s.col === c) || null; function addSwapTile(row, col, targetId, active) { const label = LEVELS[targetId].title; const canvas = document.createElement("canvas"); canvas.width = canvas.height = TILE_PX * TEX_SCALE; const ctx = canvas.getContext("2d"); drawSwapTileCanvas(ctx, label); const texture = new THREE.CanvasTexture(canvas); texture.anisotropy = maxAniso; texture.colorSpace = THREE.SRGBColorSpace; const mesh = new THREE.Mesh(tileGeometry, [ new THREE.MeshBasicMaterial({ map: texture, transparent: false }), sideMaterial, ]); const base = tileAt(row, col); // (ROWS, startCol): the next grid row, flush const x = base.x; const z = base.z; mesh.position.set(x, -TILE_DEPTH / 2, z); mesh.visible = active; board.add(mesh); swapTiles.push({ mesh, ctx, texture, row, col, targetId, active, x, z, label }); } // (Re)build the swap tile for the active board: a single forward tile, shown // once every target here is collected, that progresses to the next board. // Progression is one-way — there is no back tile. It hangs just below the // start column without shifting the grid, so the board reads centred from the // start. function buildSwapTiles() { for (const s of swapTiles) { board.remove(s.mesh); s.texture.dispose(); s.mesh.material[0].dispose(); // per-tile cap material (side is shared) } swapTiles = []; if (level.glitch) { // the bonus board has no swap tile: it exits via the portal + injection } else if (returnTo && returnTo !== level.id) { // a board reached through a cross-level portal gets a tile back to its origin addSwapTile(ROWS, level.start[1], returnTo, remainingTargets().length === 0); } else { const i = ORDER.indexOf(level.id); if (i < ORDER.length - 1) { addSwapTile(ROWS, level.start[1], ORDER[i + 1], remainingTargets().length === 0); } } board.position.x = 0; // grid stays centred; the swap tile never decentres it } // Board complete: poof the forward swap tile in so the doodle can hop onward. function revealForwardSwap() { const s = swapTiles.find((t) => !t.active); if (!s) return; s.active = true; s.mesh.visible = true; sfx.pop(); gsap.fromTo( s.mesh.scale, { x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1, duration: 0.45, ease: "back.out(2)" } ); } // ---- the injection submit tiles ---- // The prompt injection conjures the build / small / hackathon keycaps in a lane // beside "output" (see injectionSubmit). They live outside the `tiles` array // like swap tiles; the boil re-jitters them, and tryMove/land route the doodle // along them to ship a word to the judge. let submitTiles = []; // [{ mesh, ctx, texture, row, col, target }] const submitTileAt = (r, c) => submitTiles.find((s) => s.row === r && s.col === c) || null; function disposeSubmitTiles() { for (const s of submitTiles) { board.remove(s.mesh); s.texture.dispose(); s.mesh.material[0].dispose(); // per-tile cap material (side is shared) } submitTiles = []; } function addSubmitTile(row, col, target) { const canvas = document.createElement("canvas"); canvas.width = canvas.height = TILE_PX * TEX_SCALE; const ctx = canvas.getContext("2d"); drawSubmitTileCanvas(ctx, target, submitTileState(target)); const texture = new THREE.CanvasTexture(canvas); texture.anisotropy = maxAniso; texture.colorSpace = THREE.SRGBColorSpace; const mesh = new THREE.Mesh(tileGeometry, [ new THREE.MeshBasicMaterial({ map: texture, transparent: false }), sideMaterial, ]); const { x, z } = tileAt(row, col); mesh.position.set(x, -TILE_DEPTH / 2, z); mesh.scale.set(0, 0, 0); // rises in via injectionSubmit board.add(mesh); const t = { mesh, ctx, texture, row, col, target }; submitTiles.push(t); return t; } // Depress a keycap and let it spring back, like the doodle typed it. function pressTile(t) { sfx.press(); gsap.timeline() .to(t.mesh.position, { y: -TILE_DEPTH / 2 - 0.14, duration: 0.07, ease: "power2.out" }) .to(t.mesh.position, { y: -TILE_DEPTH / 2, duration: 0.3, ease: "back.out(2.2)" }); } // ---- character: a line-doodle on a billboarded plane ---- function wobblyCircle(ctx, cx, cy, r, jitter) { ctx.beginPath(); for (let i = 0; i <= 26; i++) { const a = (i / 26) * Math.PI * 2; const rr = r + rand(-jitter, jitter); const x = cx + rr * Math.cos(a); const y = cy + rr * Math.sin(a); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.closePath(); } function wobblyLine(ctx, x0, y0, x1, y1, jitter) { ctx.beginPath(); ctx.moveTo(x0 + rand(-jitter, jitter), y0 + rand(-jitter, jitter)); const mx = (x0 + x1) / 2 + rand(-jitter * 2, jitter * 2); const my = (y0 + y1) / 2 + rand(-jitter * 2, jitter * 2); ctx.quadraticCurveTo(mx, my, x1 + rand(-jitter, jitter), y1 + rand(-jitter, jitter)); } const CHAR_PX_W = 256; const CHAR_PX_H = 320; // One outlined "tube" limb: a jittered quadratic stroked twice — fat ink, // then a thin paper core — so arms/legs read as drawn shapes, not sticks. function limb(ctx, x0, y0, cx, cy, x1, y1) { const J = 2.5; ctx.beginPath(); ctx.moveTo(x0 + rand(-J, J), y0 + rand(-J, J)); ctx.quadraticCurveTo(cx + rand(-J, J), cy + rand(-J, J), x1 + rand(-J, J), y1 + rand(-J, J)); ctx.strokeStyle = INK; ctx.lineWidth = 16; ctx.stroke(); ctx.strokeStyle = "#fffdf7"; ctx.lineWidth = 9; ctx.stroke(); ctx.strokeStyle = INK; } // Flat doodle shoe: a small ink-filled ellipse at the end of a leg. function charFoot(ctx, x, y) { ctx.beginPath(); ctx.ellipse(x + rand(-2, 2), y + rand(-1.5, 1.5), 9, 5, 0, 0, Math.PI * 2); ctx.fillStyle = INK; ctx.fill(); } // Mitten hand: a paper-filled wobbly circle with an ink outline. function charHand(ctx, x, y) { wobblyCircle(ctx, x, y, 9, 1.5); ctx.fillStyle = "#fffdf7"; ctx.fill(); ctx.lineWidth = 5; ctx.strokeStyle = INK; ctx.stroke(); } // Dazed X eye: two short crossed strokes. function xEye(ctx, cx, cy) { ctx.lineWidth = 4.5; wobblyLine(ctx, cx - 7, cy - 7, cx + 7, cy + 7, 1); ctx.stroke(); wobblyLine(ctx, cx - 7, cy + 7, cx + 7, cy - 7, 1); ctx.stroke(); } // One feathered wing for the airborne doodle. `phase` 0..3 is the slow flap // frame (0 = wings low/spread, 3 = wings raised); `sign` mirrors left/right // about the body centre (x≈128). Drawn behind the torso so the roots tuck in. let wingPhase = 0; function oneWing(ctx, sign, phase) { const up = phase / 3; const ax = 128 + sign * 16, ay = 150; // root near the shoulder const tipx = ax + sign * (54 + up * 6); const tipy = ay - 18 - up * 66; // the tip swings up as it flaps const elbx = ax + sign * 40, elby = ay - 8 - up * 30; ctx.lineJoin = ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(ax, ay - 4); ctx.quadraticCurveTo(elbx, elby - 24, tipx, tipy); // leading edge → tip ctx.quadraticCurveTo(elbx - sign * 2, elby + 14, ax + sign * 4, ay + 18); // trailing edge back ctx.closePath(); ctx.fillStyle = "rgba(255,253,247,0.95)"; ctx.fill(); ctx.strokeStyle = INK; ctx.lineWidth = 6; ctx.stroke(); ctx.lineWidth = 3; // two feather strokes wobblyLine(ctx, ax + sign * 8, ay - 2, elbx, elby - 6, 1.5); ctx.stroke(); wobblyLine(ctx, ax + sign * 16, ay + 4, elbx + sign * 4, elby + 6, 1.5); ctx.stroke(); } function drawWings(ctx, phase) { oneWing(ctx, -1, phase); oneWing(ctx, 1, phase); } function drawCharCanvas(ctx, pose) { const J = 2.5; ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0); ctx.clearRect(0, 0, CHAR_PX_W, CHAR_PX_H); ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.strokeStyle = INK; ctx.fillStyle = INK; ctx.lineWidth = 7; // wings ride behind the body, so paint them before the limbs/torso cover the roots if (pose === "fly") drawWings(ctx, wingPhase); // limbs first, so the torso fill covers the shoulder/hip joins. // shoulders ~(104,142)/(152,142), hips ~(112,205)/(144,205), feet base y≈308 if (pose === "think") { limb(ctx, 104, 142, 88, 180, 86, 210); // arm down limb(ctx, 152, 142, 178, 138, 146, 118); // hand up to the chin limb(ctx, 112, 205, 106, 255, 104, 300); // legs straight limb(ctx, 144, 205, 150, 255, 152, 300); charFoot(ctx, 102, 303); charFoot(ctx, 154, 303); charHand(ctx, 86, 210); charHand(ctx, 146, 118); } else if (pose === "hop-up") { // launch crouch: knees bent, feet still low, arms swung back limb(ctx, 104, 145, 80, 170, 70, 212); limb(ctx, 152, 145, 176, 170, 186, 212); limb(ctx, 112, 205, 86, 252, 104, 294); limb(ctx, 144, 205, 170, 252, 152, 294); charFoot(ctx, 102, 298); charFoot(ctx, 154, 298); charHand(ctx, 70, 212); charHand(ctx, 186, 212); } else if (pose === "hop-mid") { // peak tuck: knees out, feet pulled up, arms in a V overhead limb(ctx, 104, 142, 80, 92, 70, 46); limb(ctx, 152, 142, 176, 92, 186, 46); limb(ctx, 112, 205, 82, 232, 108, 252); limb(ctx, 144, 205, 174, 232, 148, 252); charFoot(ctx, 106, 254); charFoot(ctx, 150, 254); charHand(ctx, 70, 46); charHand(ctx, 186, 46); } else if (pose === "hop-land") { // deep landing bend: feet wide, arms thrown forward at chest height limb(ctx, 104, 148, 80, 152, 58, 150); limb(ctx, 152, 148, 176, 152, 198, 150); limb(ctx, 112, 208, 82, 256, 88, 300); limb(ctx, 144, 208, 174, 256, 168, 300); charFoot(ctx, 86, 303); charFoot(ctx, 170, 303); charHand(ctx, 58, 150); charHand(ctx, 198, 150); } else if (pose === "dead-dizzy") { // dazed: arms dangle straight down, legs slightly crossed limb(ctx, 104, 145, 100, 182, 98, 218); limb(ctx, 152, 145, 156, 182, 158, 218); limb(ctx, 112, 205, 130, 255, 140, 300); limb(ctx, 144, 205, 124, 255, 116, 300); charFoot(ctx, 140, 303); charFoot(ctx, 114, 303); charHand(ctx, 98, 218); charHand(ctx, 158, 218); } else if (pose === "dead") { // crumpled sprawl: everything flung wide and low limb(ctx, 104, 150, 70, 158, 40, 176); limb(ctx, 152, 150, 186, 158, 216, 176); limb(ctx, 112, 208, 76, 252, 44, 290); limb(ctx, 144, 208, 180, 252, 212, 290); charFoot(ctx, 42, 293); charFoot(ctx, 214, 293); charHand(ctx, 40, 176); charHand(ctx, 216, 176); } else if (pose === "fly") { // floating upright: legs dangle loose, arms drift down-out (wings do the work) limb(ctx, 108, 150, 96, 196, 92, 246); limb(ctx, 148, 150, 160, 196, 164, 246); limb(ctx, 106, 150, 88, 180, 80, 206); limb(ctx, 150, 150, 168, 180, 176, 206); charFoot(ctx, 90, 248); charFoot(ctx, 166, 248); charHand(ctx, 80, 206); charHand(ctx, 176, 206); } else { // idle: arms down-out, legs straight limb(ctx, 104, 142, 90, 178, 82, 206); limb(ctx, 152, 142, 166, 178, 174, 206); limb(ctx, 112, 205, 106, 255, 104, 300); limb(ctx, 144, 205, 150, 255, 152, 300); charFoot(ctx, 102, 303); charFoot(ctx, 154, 303); charHand(ctx, 82, 206); charHand(ctx, 174, 206); } // torso: a paper-filled bean drawn over the limb joins wobblyRoundRect(ctx, 96, 118, 64, 92, 30, J); ctx.fillStyle = "rgba(255,253,247,0.95)"; ctx.fill(); ctx.strokeStyle = INK; ctx.lineWidth = 7; ctx.stroke(); // head, after the torso so it overlaps the neck wobblyCircle(ctx, 128, 78, 44, 2); ctx.fillStyle = "rgba(255,253,247,0.95)"; ctx.fill(); ctx.stroke(); // one flat accent detail: a little scarf at the neck ctx.strokeStyle = ACCENT; ctx.lineWidth = 8; wobblyLine(ctx, 106, 126, 150, 126, 2); ctx.stroke(); ctx.strokeStyle = INK; ctx.fillStyle = INK; // face: eyes + mouth — skipped entirely once the 🤗 trophy replaces it (below) if (!hasHug) { // eyes if (pose === "dead" || pose === "dead-dizzy") { xEye(ctx, 110, 74); xEye(ctx, 146, 74); } else if (pose === "idle" && Math.random() < 0.18) { ctx.lineWidth = 5; // blink wobblyLine(ctx, 102, 74, 118, 74, 1); ctx.stroke(); wobblyLine(ctx, 138, 74, 154, 74, 1); ctx.stroke(); } else { ctx.beginPath(); ctx.arc(110, 74, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(146, 74, 5.5, 0, Math.PI * 2); ctx.fill(); } // mouth ctx.lineWidth = 6; if (pose === "think" || pose === "hop-up") { ctx.beginPath(); ctx.arc(128, 100, pose === "think" ? 9 : 7, 0, Math.PI * 2); // little "o" ctx.stroke(); } else if (pose === "hop-mid") { ctx.beginPath(); ctx.arc(128, 96, 16, 0, Math.PI); // big open grin ctx.closePath(); ctx.fill(); } else if (pose === "hop-land") { wobblyLine(ctx, 108, 102, 148, 102, 1.5); ctx.stroke(); // gritted teeth } else if (pose === "dead-dizzy") { ctx.beginPath(); // wavy dazed squiggle for (let i = 0; i <= 16; i++) { const x = 108 + (i / 16) * 40; const y = 102 + Math.sin((i / 16) * Math.PI * 3) * 4 + rand(-1, 1); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); } else if (pose === "dead") { wobblyLine(ctx, 112, 100, 144, 100, 1.5); ctx.stroke(); ctx.beginPath(); // little tongue blob ctx.ellipse(136 + rand(-1, 1), 110, 6, 9, 0, 0, Math.PI * 2); ctx.fill(); } else { ctx.beginPath(); ctx.arc(128, 88, 20, Math.PI * 0.18, Math.PI * 0.82); // smile ctx.stroke(); } } // end !hasHug (face) // pose extras if (pose === "think") { ctx.font = '400 56px "Patrick Hand"'; ctx.textAlign = "center"; ctx.fillText("?", 196 + rand(-2, 2), 56 + rand(-2, 2)); } else if (pose === "hop-mid") { ctx.lineWidth = 5; // motion dashes below the feet wobblyLine(ctx, 106, 272, 100, 292, 1.5); ctx.stroke(); wobblyLine(ctx, 128, 276, 128, 298, 1.5); ctx.stroke(); wobblyLine(ctx, 150, 272, 156, 292, 1.5); ctx.stroke(); } else if (pose === "dead") { ctx.lineWidth = 4; // dizzy spiral doodle above the head ctx.beginPath(); for (let i = 0; i <= 40; i++) { const a = (i / 40) * Math.PI * 4; const r = 2 + (i / 40) * 11; const x = 188 + r * Math.cos(a) + rand(-1, 1); const y = 34 + r * Math.sin(a) + rand(-1, 1); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); } // the 🤗 trophy REPLACES the face after clearing critters — big and filled, // covering the whole head (eyes + mouth above were skipped). if (hasHug) { ctx.font = '108px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif'; ctx.textAlign = "center"; ctx.textBaseline = "middle"; // On the glitch board the whole scene is CSS-inverted (invert(1) hue-rotate(180)). // Pre-apply the exact inverse here — hue-rotate(180) then invert(1) — so the // emoji cancels back to its natural colours instead of going negative. if (level.glitch) ctx.filter = "hue-rotate(180deg) invert(1)"; ctx.fillText("🤗", 128 + rand(-1.5, 1.5), 80 + rand(-1.5, 1.5)); ctx.filter = "none"; } } const charCanvas = document.createElement("canvas"); charCanvas.width = CHAR_PX_W * TEX_SCALE; charCanvas.height = CHAR_PX_H * TEX_SCALE; const charCtx = charCanvas.getContext("2d"); let charPose = "idle"; drawCharCanvas(charCtx, charPose); const charTexture = new THREE.CanvasTexture(charCanvas); charTexture.colorSpace = THREE.SRGBColorSpace; const CHAR_STANDOFF = 0.12; // feet hover just above the keycap tops const CHAR_Z_OFF = -0.15; // stand toward the tile's top edge, not on the word const CRUISE = 1.3; // charMesh.position.y while airborne — up "in the sky" const charGeometry = new THREE.PlaneGeometry(0.86, 1.075); charGeometry.translate(0, 1.075 / 2, 0); // pivot at the feet const charMesh = new THREE.Mesh( charGeometry, new THREE.MeshBasicMaterial({ map: charTexture, transparent: true }) ); charMesh.renderOrder = 1; // only transparent mesh left — draw after keycaps const charTilt = { z: 0 }; // cartoon lean, composed into the billboard quat const charPosFor = (r, c) => { const s = swapTileAt(r, c); // swap tiles sit at a gapped world position const { x, z } = s ? s : tileAt(r, c); return { x, z: z + CHAR_Z_OFF }; }; // charGroup carries the grid position; charMesh.position.y is the jump arc. const startPos = charPosFor(level.start[0], level.start[1]); const charGroup = new THREE.Group(); charGroup.position.set(startPos.x, CHAR_STANDOFF, startPos.z); charGroup.add(charMesh); board.add(charGroup); function setPose(pose) { charPose = pose; drawCharCanvas(charCtx, charPose); charTexture.needsUpdate = true; } // While airborne the wings beat slowly: step the flap frame on its own timer // and redraw the doodle. It runs independent of the idle "boil" (which pauses // mid-hop), so the wings keep flapping through every glide. let flapTimer = null, flapDir = 1; function startFlap() { stopFlap(); wingPhase = 0; flapDir = 1; flapTimer = setInterval(() => { wingPhase += flapDir; if (wingPhase >= 3) flapDir = -1; else if (wingPhase <= 0) flapDir = 1; if (charPose === "fly") { drawCharCanvas(charCtx, "fly"); charTexture.needsUpdate = true; } }, 130); } function stopFlap() { if (flapTimer) { clearInterval(flapTimer); flapTimer = null; } } // "Sketch boil": re-jitter the ink so the board looks hand-drawn and alive. // This used to redraw all ~16 hi-dpi (768px) tile canvases and re-upload their // textures to the GPU in a single tick, three times a second — a ~70ms // main-thread stall that dropped a frame each time and read as choppy/jagged // animation (measured as 40-90ms frame spikes spaced ~340ms apart on // HF/Safari). Two changes keep it lively without the hitch: // 1. Only boil while idle. During a hop / judge bob / verdict the motion // already carries the life, and a stalled frame mid-motion is exactly // what reads as jank — so leave those frames for the animation. // 2. Re-jitter only a slice of the board per tick, cycling through, so no // single tick redraws+uploads more than a few textures. // One round-robin over every boilable surface (board tiles + the doodle + the // tile sides), redrawing a small fixed slice per tick so a tick never redraws // more than ~2 hi-dpi canvases — each tick stays well under a frame budget, // and the cost is spread evenly instead of bursting. ~2 of ~18 surfaces every // 100ms re-jitters each one roughly once a second: calm, but still alive. let boilCursor = 0, boilBeat = 0; setInterval(() => { if (document.hidden || state !== "idle") return; const surfaces = tiles.length + 2; // tiles + doodle + tile-sides for (let n = 0; n < 2 && surfaces; n++, boilCursor++) { const i = boilCursor % surfaces; if (i < tiles.length) { const t = tiles[i]; drawTileCanvas(t.ctx, t.word); t.texture.needsUpdate = true; } else if (i === tiles.length) { drawCharCanvas(charCtx, charPose); charTexture.needsUpdate = true; } else { drawTileSideCanvas(sideCtx); sideTexture.needsUpdate = true; } } for (const s of swapTiles) { // 0-1, only once a board is cleared — cheap // a tile leading to a glitch board flickers its label into glyphs const lbl = LEVELS[s.targetId].glitch ? glitchLabel(s.label) : s.label; drawSwapTileCanvas(s.ctx, lbl); s.texture.needsUpdate = true; } for (const s of submitTiles) { // the injection keys boil like any other keycap drawSubmitTileCanvas(s.ctx, s.target, submitTileState(s.target)); s.texture.needsUpdate = true; } if (boilBeat++ % 4 === 0) syncAudioUI(); // mute-slash boil, no rush }, 100); // On a glitch board the corruption should read as LIVE — reality flickering — // not a frozen typo. A second, faster ticker re-rolls the word tiles' glitched // letter(s) so the glyphs crawl and change over time. Only the few word tiles, // only while idle, and ≤2 hi-dpi redraws a tick (the boil's same budget), so it // stays calm. Non-word tiles don't glitch, so the boil alone re-jitters those. let glitchCursor = 0; setInterval(() => { if (!level.glitch || document.hidden || state !== "idle") return; const wordTiles = tiles.filter((t) => appendsWord(t.word)); for (let n = 0; n < 2 && wordTiles.length; n++, glitchCursor++) { const t = wordTiles[glitchCursor % wordTiles.length]; drawTileCanvas(t.ctx, t.word); t.texture.needsUpdate = true; } }, 120); // The armed `data` key corrupts FASTER than the rest — a frantic re-roll so the // red warning tile crawls and flickers harder than its neighbours. setInterval(() => { if (!dataArmed || !level.glitch || document.hidden || state !== "idle") return; const d = tiles.find((t) => eq(t.word, "data")); if (d) { drawTileCanvas(d.ctx, d.word); d.texture.needsUpdate = true; } }, 55); // ---- sounds: a tiny Web Audio sketch-synth ---- // Every cue is an oscillator doodle or a pinch of filtered noise — no // samples to load, and the bleeps match the hand-drawn look. // Music and sfx each carry a volume (0..1) and ride their own bus off the // master, so the in-game mixer can level/mute them independently. Levels // persist in localStorage; read them now so the buses come up at the right // level when the context is built. const AUDIO_PREF_KEY = "sq-audio"; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); function loadAudioPrefs() { try { return JSON.parse(localStorage.getItem(AUDIO_PREF_KEY)) || {}; } catch (e) { return {}; } } function saveAudioPrefs() { try { localStorage.setItem(AUDIO_PREF_KEY, JSON.stringify({ musicVol, sfxVol })); } catch (e) { /* storage may be partitioned inside the HF iframe — ignore */ } } const _ap = loadAudioPrefs(); // migrate the old on/off booleans ({music,sfx}) to volumes when present let musicVol = typeof _ap.musicVol === "number" ? clamp01(_ap.musicVol) : (_ap.music === false ? 0 : 1); let sfxVol = typeof _ap.sfxVol === "number" ? clamp01(_ap.sfxVol) : (_ap.sfx === false ? 0 : 1); let musicPrev = musicVol || 1; // level to restore when un-muting let sfxPrev = sfxVol || 1; // perceived loudness is ~quadratic, so square the slider before it hits gain const MUSIC_MAX = 0.6, SFX_MAX = 1.0; // bus level at full slider (× 0.4 master) const taper = (v) => v * v; const musicBusGain = () => MUSIC_MAX * taper(musicVol); const sfxBusGain = () => SFX_MAX * taper(sfxVol); let ac = null; let masterGain = null; let sfxGain = null; // every bleep routes here; the mixer levels it let musicGain = null; // the background loop routes here let musicFilter = null; // a low-pass; glitch mode closes it so music sounds "next room" const MUSIC_OPEN = 22000, MUSIC_MUFFLED = 560; // low-pass cutoff (Hz) function audio() { if (!ac) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; ac = new AC(); masterGain = ac.createGain(); masterGain.gain.value = 0.4; masterGain.connect(ac.destination); sfxGain = ac.createGain(); sfxGain.gain.value = sfxBusGain(); sfxGain.connect(masterGain); musicGain = ac.createGain(); musicGain.gain.value = musicBusGain(); // music → low-pass → master. The filter is open by default, or already // muffled if the audio wakes up while we're on a glitch board. musicFilter = ac.createBiquadFilter(); musicFilter.type = "lowpass"; musicFilter.Q.value = 0.6; musicFilter.frequency.value = level && level.glitch ? MUSIC_MUFFLED : MUSIC_OPEN; musicGain.connect(musicFilter); musicFilter.connect(masterGain); } if (ac.state === "suspended") ac.resume(); loadScratch(); // decode the pen-scratch sample on first wake-up return ac; } // Glitch mode muffles the track behind the low-pass (smooth ramp); normal // mode opens it back up. No-ops until the audio context exists. function setMusicMuffled(on) { if (!musicFilter || !ac) return; musicFilter.frequency.setTargetAtTime(on ? MUSIC_MUFFLED : MUSIC_OPEN, ac.currentTime, 0.3); } // Sounds often fire from gsap timelines (outside any user gesture), so unlock // the context on real gestures — these also resume after suspension, and kick // off the music loop the first time (browsers block audio until a gesture). function unlockAudio() { if (audio() && musicVol > 0) startMusic(); } document.addEventListener("keydown", unlockAudio); document.addEventListener("pointerdown", unlockAudio); // one swept note with a fast attack and an exponential die-off function tone({ type = "triangle", from = 440, to = from, dur = 0.12, vol = 0.3, at = 0 }) { if (!audio()) return; const t0 = ac.currentTime + at; const osc = ac.createOscillator(); const g = ac.createGain(); osc.type = type; osc.frequency.setValueAtTime(from, t0); if (to !== from) osc.frequency.exponentialRampToValueAtTime(Math.max(to, 1), t0 + dur); g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(vol, t0 + 0.008); g.gain.exponentialRampToValueAtTime(0.001, t0 + dur); osc.connect(g).connect(sfxGain); osc.start(t0); osc.stop(t0 + dur + 0.05); } // short low-passed noise burst — paper taps and keycap thocks function thud({ dur = 0.05, vol = 0.4, freq = 1000, at = 0 }) { if (!audio()) return; const t0 = ac.currentTime + at; const len = Math.ceil(ac.sampleRate * dur); const buf = ac.createBuffer(1, len, ac.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len); const src = ac.createBufferSource(); src.buffer = buf; const filter = ac.createBiquadFilter(); filter.type = "lowpass"; filter.frequency.value = freq; const g = ac.createGain(); g.gain.value = vol; src.connect(filter).connect(g).connect(sfxGain); src.start(t0); } // The pen-scratch cue is a real nib recording (trimmed to one stroke), // decoded once into a buffer the first time the audio context wakes up. let scratchBuf = null; let scratchLoading = false; function loadScratch() { if (scratchBuf || scratchLoading || !ac || !props.value.scratchAudio) return; scratchLoading = true; fetch(props.value.scratchAudio) .then((r) => r.arrayBuffer()) .then((b) => ac.decodeAudioData(b)) .then((buf) => (scratchBuf = buf)) .catch(() => (scratchLoading = false)); // let a later wake-up retry } const sfx = { jump() { tone({ type: "triangle", from: 280, to: 640, dur: 0.14, vol: 0.3 }); // springy boing }, press() { thud({ dur: 0.045, vol: 0.5, freq: 1300 }); // keycap thock… tone({ type: "sine", from: 160, to: 65, dur: 0.1, vol: 0.5 }); // …with a low bump }, bonk() { thud({ dur: 0.06, vol: 0.35, freq: 600 }); tone({ type: "square", from: 150, to: 70, dur: 0.16, vol: 0.16 }); }, pop() { tone({ type: "triangle", from: 900, to: 1400, dur: 0.07, vol: 0.14 }); // chip appears }, soar() { tone({ type: "sine", from: 360, to: 820, dur: 0.34, vol: 0.16 }); // lift-off swell tone({ type: "triangle", from: 520, to: 1040, dur: 0.2, vol: 0.07, at: 0.06 }); }, flap() { thud({ dur: 0.07, vol: 0.1, freq: 280 }); // soft wing-beat whoosh }, warp() { // pulled in (upward swirl), then spat back out (downward whoosh) tone({ type: "sine", from: 240, to: 920, dur: 0.18, vol: 0.16 }); tone({ type: "triangle", from: 720, to: 170, dur: 0.3, vol: 0.16, at: 0.17 }); }, shuffle() { // rows slide sideways: an airy whoosh, then a soft thock as the wrapped // cap drops back into its new leftmost home tone({ type: "sine", from: 300, to: 560, dur: 0.18, vol: 0.1 }); thud({ dur: 0.06, vol: 0.12, freq: 520 }); thud({ dur: 0.05, vol: 0.14, freq: 950, at: 0.42 }); }, scratch(dur = 0.4) { if (!audio() || !scratchBuf) return; // sample still decoding → skip silently const src = ac.createBufferSource(); src.buffer = scratchBuf; // stretch the stroke toward the word's writing time (longer words → // slower nib) without wild pitch shifts, plus jitter so no two are alike const fit = Math.min(1.12, Math.max(0.78, scratchBuf.duration / dur)); src.playbackRate.value = fit * rand(0.97, 1.03); const g = ac.createGain(); g.gain.value = 0.3; // sit under the keycap press — broadband noise reads loud src.connect(g).connect(sfxGain); // ride the sfx bus so the mixer levels/mutes it src.start(); }, click() { thud({ dur: 0.03, vol: 0.3, freq: 2500 }); // UI button tap }, warn() { tone({ type: "square", from: 660, dur: 0.07, vol: 0.1 }); tone({ type: "square", from: 660, dur: 0.07, vol: 0.1, at: 0.12 }); }, die() { // dizzy warble while he wobbles… for (let i = 0; i < 4; i++) { tone({ type: "sine", from: i % 2 ? 392 : 330, dur: 0.1, vol: 0.16, at: i * 0.12 }); } // …then a slide-whistle drop as he slips off the board (timeline hits 0.85) tone({ type: "sine", from: 520, to: 90, dur: 0.62, vol: 0.26, at: 0.85 }); }, stamp(win) { thud({ dur: 0.07, vol: 0.6, freq: 500 }); tone({ type: "sine", from: 130, to: 50, dur: 0.18, vol: 0.55 }); if (win) { // quick rising arpeggio tone({ type: "triangle", from: 523, dur: 0.09, vol: 0.22, at: 0.15 }); tone({ type: "triangle", from: 659, dur: 0.09, vol: 0.22, at: 0.24 }); tone({ type: "triangle", from: 784, dur: 0.16, vol: 0.22, at: 0.33 }); } else { // glum two-note shrug tone({ type: "triangle", from: 294, to: 277, dur: 0.16, vol: 0.18, at: 0.18 }); tone({ type: "triangle", from: 233, to: 220, dur: 0.26, vol: 0.18, at: 0.38 }); } }, }; // ---- background music: per-board loops, crossfaded on transition ---- // Each board names its own loop (data.levels[].music — bonus = corporate-glitch, // critters = farm) and a loudness trim (.music_gain); boards without one play // the default loop (data.music, Cipher2). Every track gets its own