import * as THREE from "three";
"host": "your-partyserver.example"
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const svgLeft = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M10 5.75L3.75 12L10 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 12H20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const svgUp = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M12 20.25V4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 10L12 3.75L18.25 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const svgRight = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M14 5.75L20.25 12L14 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.5 12H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const svgDown = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M18.25 14L12 20.25L5.75 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 19.5V3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const svgPlus = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
const svgMinus = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M5.25 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
const MODEL = "https://carterogunsola.com/lab/pocket-pal/model.glb";
const AO = "https://carterogunsola.com/lab/pocket-pal/ao.webp";
const BOARD_URL = `https://${SITE.presence.host}/parties/scores/scores`;
const SWITCH_TRAVEL = 0.046;
const UNBAKED = new Set(["core", "glass", "mask", "lcd", "cart", "label"]);
reflective: { paper: "#a3a993", ink: "#22261d" },
paper: { paper: "#dcdbd5", ink: "#1e1e1e" },
amber: { paper: "#1d150b", ink: "#ffb347" }
const SHADES = [0, 0.36, 0.68, 1];
px = new Uint8Array(RES * RES);
lvl = new Float32Array(RES * RES);
out = new Uint8Array(RES * RES);
this.tex = new THREE.DataTexture(this.out, RES, RES, THREE.RedFormat, THREE.UnsignedByteType);
this.tex.magFilter = THREE.NearestFilter;
this.tex.minFilter = THREE.NearestFilter;
this.tex.needsUpdate = true;
if (x >= 0 && y >= 0 && x < RES && y < RES) this.px[y * RES + x] = c;
for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.pset(x + i, y + j, c);
this.rect(x, y, w, 1, c);
this.rect(x, y + h - 1, w, 1, c);
this.rect(x, y, 1, h, c);
this.rect(x + w - 1, y, 1, h, c);
text(s, x, y, c, k = 1) {
for (const ch of s.toUpperCase()) {
const g = FONT[ch] ?? FONT[" "];
for (let i = 0; i < 15; i++) if (g[i] === "1") this.rect(x + i % 3 * k, y + (i / 3 | 0) * k, k, k, c);
return s.length * 4 * k - k;
this.text(s, RES - this.width(s, k) >> 1, y, c, k);
sprite(rows, x, y, c, k = 1) {
rows.forEach((row, j) => [...row].forEach((v, i) => v === "1" && this.rect(x + i * k, y + j * k, k, k, c)));
tri(ax, ay, bx, by, cx, cy, c) {
const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
if (Math.abs(area) < 1e-6) return;
const x0 = Math.floor(Math.min(ax, bx, cx));
const x1 = Math.ceil(Math.max(ax, bx, cx));
const y0 = Math.floor(Math.min(ay, by, cy));
const y1 = Math.ceil(Math.max(ay, by, cy));
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) {
const w0 = ((bx - px) * (cy - py) - (by - py) * (cx - px)) / area;
const w1 = ((cx - px) * (ay - py) - (cy - py) * (ax - px)) / area;
if (w0 >= -0.08 && w1 >= -0.08 && 1 - w0 - w1 >= -0.08) this.pset(x, y, c);
const w = Math.max(...lines.map((s) => this.width(s))) + 10;
const h = lines.length * 8 + 6;
this.rect(x, y, w, h, 0);
this.frame(x, y, w, h, 3);
lines.forEach((s, i) => this.center(s, y + 4 + i * 8, 3));
present(dt, ghost, power, wipe, invert) {
const keep = Math.pow(ghost, dt * 60);
const cut = Math.floor(wipe * RES);
for (let i = 0; i < this.px.length; i++) {
const on = power > 0 && (i / RES | 0) < cut;
const shade = invert ? 1 - SHADES[this.px[i]] : SHADES[this.px[i]];
const target = on ? shade : 0;
this.lvl[i] = target + (this.lvl[i] - target) * keep;
this.out[i] = Math.round(this.lvl[i] * 255);
this.tex.needsUpdate = true;
const DPAD = new Set(["up", "right", "down", "left"]);
const fetchModel = () => {
modelBytes ??= fetch(MODEL).then((r) => r.ok ? r.arrayBuffer() : Promise.reject(new Error(`${MODEL} ${r.status}`)));
modelBytes.catch(() => modelBytes = null);
fetchModel().catch(() => {
return localStorage.getItem(`handheld:${key}`);
localStorage.setItem(`handheld:${key}`, String(value));
function keepBest(g, key) {
if (g.score <= g.best) return;
store.set(`${key}:best`, g.best);
const damp = (cur, to, lambda, dt) => cur + (to - cur) * (1 - Math.exp(-lambda * dt));
const note = (at, f, dur, wave = "square", gain = 0.2) => ({ at, wave, from: f, to: f, dur, gain });
key: { cap: SE_CAP, tones: [{ at: 0, wave: "noise", from: 5200, to: 2600, dur: 0.02, gain: 0.07 }] },
eat: { cap: SE_CAP, tones: [note(0, 659, 0.045), note(0.045, 988, 0.08)] },
drip: { cap: SE_CAP, tones: [{ at: 0, wave: "triangle", from: 247, to: 165, dur: 0.07, gain: 0.3 }] },
low: { cap: SE_CAP, tones: [note(0, 440, 0.04, "square", 0.14), note(0.09, 440, 0.04, "square", 0.14)] },
{ at: 0, wave: "noise", from: 2600, to: 260, dur: 0.38, gain: 0.55 },
{ at: 0, wave: "square", from: 196, to: 55, dur: 0.3, gain: 0.22 }
empty: { cap: JINGLE_CAP, tones: [note(0, 523, 0.15, "triangle", 0.32), note(0.17, 392, 0.15, "triangle", 0.32), note(0.34, 262, 0.32, "triangle", 0.32)] },
start: { cap: JINGLE_CAP, tones: [note(0, 262, 0.07), note(0.08, 330, 0.07), note(0.16, 392, 0.07), note(0.24, 523, 0.16)] },
best: { cap: JINGLE_CAP, tones: [523, 659, 784, 1047, 1319].map((f, i) => note(i * 0.07, f, i === 4 ? 0.2 : 0.07, "triangle", 0.28)) },
move: { cap: SE_CAP, tones: [note(0, 1175, 0.03, "square", 0.1)] },
pause: { cap: SE_CAP, tones: [note(0, 988, 0.05, "square", 0.14), note(0.07, 740, 0.07, "square", 0.14)] },
eject: { cap: SE_CAP, tones: [{ at: 0, wave: "noise", from: 700, to: 3400, dur: 0.16, gain: 0.3 }] },
{ at: 0, wave: "noise", from: 1600, to: 240, dur: 0.07, gain: 0.5 },
{ at: 0, wave: "square", from: 120, to: 70, dur: 0.06, gain: 0.22 }
boot: { cap: JINGLE_CAP, tones: [note(0, 1047, 0.06, "triangle", 0.3), note(0.08, 1568, 0.22, "triangle", 0.3)] },
lock: { cap: SE_CAP, tones: [{ at: 0, wave: "noise", from: 3e3, to: 1200, dur: 0.03, gain: 0.16 }] },
drop: { cap: SE_CAP, tones: [{ at: 0, wave: "noise", from: 1400, to: 180, dur: 0.07, gain: 0.4 }] },
line: { cap: SE_CAP, tones: [{ at: 0, wave: "square", from: 660, to: 990, dur: 0.09, gain: 0.16 }] },
set: { cap: JINGLE_CAP, tones: [1047, 1319, 1568, 2093].map((f, i) => note(i * 0.045, f, i === 3 ? 0.14 : 0.045, "square", 0.14)) },
topout: { cap: JINGLE_CAP, tones: [{ at: 0, wave: "square", from: 392, to: 65, dur: 0.55, gain: 0.2 }] },
pass: { cap: SE_CAP, tones: [note(0, 1319, 0.04, "triangle", 0.2)] },
{ at: 0, wave: "noise", from: 4200, to: 1400, dur: 0.12, gain: 0.45 },
{ at: 0.09, wave: "noise", from: 3e3, to: 900, dur: 0.14, gain: 0.35 }
skid: { cap: SE_CAP, tones: [{ at: 0, wave: "noise", from: 900, to: 250, dur: 0.45, gain: 0.22 }] },
print1: { cap: SE_CAP, tones: [note(0, 523, 0.05, "triangle", 0.3)] },
print2: { cap: SE_CAP, tones: [note(0, 659, 0.05, "triangle", 0.3)] },
note(0, 784, 0.05, "triangle", 0.3),
{ at: 0.02, wave: "noise", from: 2600, to: 700, dur: 0.1, gain: 0.3 },
{ at: 0.09, wave: "noise", from: 2e3, to: 500, dur: 0.08, gain: 0.25 }
dropped: { cap: JINGLE_CAP, tones: [{ at: 0, wave: "triangle", from: 523, to: 110, dur: 0.5, gain: 0.3 }] }
if (this.ctx.state !== "running") void this.ctx.resume();
const ctx = new AudioContext();
const out = ctx.createGain();
const shape = ctx.createWaveShaper();
const curve = new Float32Array(1024);
for (let i = 0; i < curve.length; i++) curve[i] = Math.tanh((i / (curve.length - 1) * 2 - 1) * 1.6) / Math.tanh(1.6);
const low = ctx.createBiquadFilter();
low.frequency.value = 3800;
const high = ctx.createBiquadFilter();
high.frequency.value = 280;
out.connect(shape).connect(high).connect(low).connect(ctx.destination);
const noise = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
const data = noise.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
this.setVolume(this.volume);
if (this.ctx && this.out) this.out.gain.setTargetAtTime(this.muted || this.silent ? 0 : v * v * 0.9, this.ctx.currentTime, 0.02);
this.setVolume(this.volume);
const program = KIT[name];
if (!program || !ctx || !this.out || this.muted || this.silent || ctx.state !== "running") return;
const n = this.frame.get(name) ?? 0;
this.frame.set(name, n + 1);
const t0 = ctx.currentTime + 5e-3;
for (const tone of program.tones) {
const start = t0 + tone.at;
const end = start + Math.min(tone.dur, program.cap - tone.at);
if (end <= start) continue;
const env = ctx.createGain();
env.gain.setValueAtTime(1e-4, start);
env.gain.exponentialRampToValueAtTime(tone.gain, start + 4e-3);
env.gain.exponentialRampToValueAtTime(1e-4, end);
if (tone.wave === "noise") {
const src = ctx.createBufferSource();
const band = ctx.createBiquadFilter();
band.frequency.setValueAtTime(tone.from, start);
band.frequency.exponentialRampToValueAtTime(tone.to, end);
src.connect(band).connect(env);
const osc = ctx.createOscillator();
osc.frequency.setValueAtTime(tone.from, start);
if (tone.to !== tone.from) osc.frequency.exponentialRampToValueAtTime(tone.to, end);
this.t = Math.max(0, this.t - dt);
l.rect(4, y, RES - 8, 18, 0);
l.frame(4, y, RES - 8, 18, 3);
l.text(this.label, 8, y + 3, 3);
for (let i = 0; i < 10; i++) l.rect(8 + i * 8, y + 10, 6, 4, i < Math.round(this.value * 10) ? 3 : 1);
const DROP = [".1.", "111", "111", ".1."];
rule = "A LINE THAT RUNS OUT";
best = Number(store.get("ink:best")) || 0;
return this.phase === "ready" || this.phase === "play";
this.body = [6, 5, 4, 3, 2].map((x) => ({ x, y: 10 }));
const head = this.body[0];
for (let y = 0; y < ROWS; y++)
for (let x = 0; x < COLS; x++) {
if (this.body.some((c) => c.x === x && c.y === y)) continue;
if (Math.abs(x - head.x) + Math.abs(y - head.y) < 4) continue;
this.food = free[Math.random() * free.length | 0] ?? { x: 0, y: 0 };
for (const k of ["up", "right", "down", "left"]) {
if (!pad.hit.has(k) || this.queue.length >= 2) continue;
const last = this.queue[this.queue.length - 1] ?? this.dir;
if (d.x === -last.x && d.y === -last.y || d.x === last.x && d.y === last.y) continue;
this.glint = Math.max(0, this.glint - dt);
for (const m of this.marks) m.t -= dt;
this.marks = this.marks.filter((m) => m.t > 0);
for (const s of this.sparks) {
this.sparks = this.sparks.filter((s) => s.t > 0);
const go = pad.hit.has("2") || pad.hit.has("start");
if (this.phase === "title") {
if (this.phase === "over") {
if (this.fresh && !this.cheered && this.t > 0.7) {
if (go && this.t > 0.8) this.start(fx);
if (this.phase === "ready") {
while (this.stepT >= this.stepEvery && this.phase === "play") {
this.stepT -= this.stepEvery;
this.dir = this.queue.shift() ?? this.dir;
const head = this.body[0];
const nx = head.x + this.dir.x;
const ny = head.y + this.dir.y;
const tailMoves = this.grow === 0;
const hitsSelf = this.body.some((c, i) => (!tailMoves || i < this.body.length - 1) && c.x === nx && c.y === ny);
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS || hitsSelf) return this.end("crash", fx);
this.body.unshift({ x: nx, y: ny });
if (nx === this.food.x && ny === this.food.y) {
this.stepEvery = Math.max(0.075, this.stepEvery - 4e-3);
const cx = FIELD_X + nx * TILE + 1;
const cy = FIELD_Y + ny * TILE + 1;
for (let i = 0; i < 6; i++) {
const a = i / 6 * Math.PI * 2 + Math.random() * 0.5;
const v = 34 + Math.random() * 16;
this.sparks.push({ x: cx, y: cy, vx: Math.cos(a) * v, vy: Math.sin(a) * v, t: 0.22 });
if (this.grow > 0) this.grow--;
if (--this.drainLeft <= 0) {
const tail = this.body.pop();
this.marks.push({ ...tail, t: 0.9 });
if (this.body.length < 2) return this.end("empty", fx);
fx.sfx(this.body.length <= 3 ? "low" : "drip");
l.rect(FIELD_X + c.x * TILE, FIELD_Y + c.y * TILE, 3, 3, shade);
const blink = (this.clock * 2.4 | 0) % 2 === 0;
if (this.phase === "title") {
l.sprite(LOGO, RES - 36 >> 1, 12, 3, 2);
l.center("A LINE THAT", 46, 2);
l.center("RUNS OUT", 54, 2);
if (blink) l.center("PRESS 2", 72, 3);
l.center(`BEST ${this.best}`, 86, 2);
l.text(String(this.score).padStart(3, "0"), 2, 2, 3);
const low = this.body.length <= 3;
l.sprite(DROP, RES - 35, 2, 3);
if (!(low && !blink && this.phase === "play")) {
l.frame(RES - 30, 2, 28, 5, 3);
l.rect(RES - 29, 3, Math.round(26 * this.drainLeft / DRAIN), 3, 2);
l.rect(1, 8, RES - 2, 1, 2);
l.frame(FIELD_X - 1, FIELD_Y - 1, COLS * TILE + 1, ROWS * TILE + 1, 3);
for (const m of this.marks) this.cell(l, m, 1);
const fx = FIELD_X + f.x * TILE;
const fy = FIELD_Y + f.y * TILE;
l.rect(fx + 1, fy, 1, 3, 3);
l.rect(fx, fy + 1, 3, 1, 3);
const gone = this.phase === "over" && this.cause === "empty";
this.body.forEach((c, i) => {
if (h && !(this.phase === "over" && blink)) {
const hx = FIELD_X + h.x * TILE;
const hy = FIELD_Y + h.y * TILE;
if (this.glint > 0) l.frame(hx, hy, 3, 3, 3);
else l.rect(hx, hy, 3, 3, 3);
l.pset(hx + 1 + this.dir.x * 2, hy + 1 + this.dir.y * 2, 3);
for (const s of this.sparks) l.pset(s.x, s.y, 2);
if (this.phase === "ready") l.box(["READY"], 40);
if (this.phase === "over" && this.t > 0.35) {
const lines = [this.cause === "crash" ? "CRASH" : "OUT OF INK", `SCORE ${this.score}`, this.fresh ? "NEW BEST" : `BEST ${this.best}`];
if (this.t > 0.8 && blink) lines.push("PRESS 2");
else if (this.t > 0.8) lines.push("");
{ n: 3, cells: [[0, 1], [1, 1], [2, 1]] },
{ n: 2, cells: [[0, 0], [0, 1], [1, 1]] }
rule = "ONE INK LINE SCORES 5";
grid = new Uint8Array(SET_COLS * SET_ROWS);
next = { shape: 0, ink: 1 };
repeat = { left: 0, right: 0 };
best = Number(store.get("set:best")) || 0;
return this.phase === "ready" || this.phase === "play" || this.phase === "clear";
if (!this.bag.length) this.bag = [1, 2, 3].sort(() => Math.random() - 0.5);
return { shape: Math.random() < 0.5 ? 0 : 1, ink: this.bag.pop() };
return this.grid[y * SET_COLS + x];
return cells.every(([cx, cy]) => {
return gx >= 0 && gx < SET_COLS && gy < SET_ROWS && (gy < 0 || this.at(gx, gy) === 0);
const { shape, ink } = this.next;
this.piece = { n: s.n, cells: s.cells.map(([x, y]) => [x, y]), ink, x: 3, y: shape === 0 ? -1 : 0 };
if (!this.fits(this.piece.cells, this.piece.x, this.piece.y)) this.end(fx);
if (!this.fits(p.cells, p.x + dx, p.y)) return;
const cells = p.cells.map(([x, y]) => dir > 0 ? [p.n - 1 - y, x] : [y, p.n - 1 - x]);
for (const kick of [0, -1, 1]) {
if (!this.fits(cells, p.x + kick, p.y)) continue;
while (this.fits(p.cells, p.x, y + 1)) y++;
for (const p2 of this.pops) p2.t -= dt;
this.pops = this.pops.filter((p2) => p2.t > 0);
const go = pad.hit.has("2") || pad.hit.has("start");
if (this.phase === "title") {
if (this.phase === "over") {
if (this.fresh && !this.cheered && this.t > 0.9) {
if (go && this.t > 1) this.start(fx);
if (this.phase === "ready") {
if (this.phase === "clear") {
if (this.t < CLEAR_T) return;
for (const [k, dx] of [["left", -1], ["right", 1]]) {
} else if (pad.held.has(k)) {
while (this.repeat[k] > DAS) {
if (pad.hit.has("2") || pad.hit.has("1")) this.turn(1);
if (pad.hit.has("3") || pad.hit.has("4")) this.turn(-1);
if (!this.fits(p.cells, p.x, p.y + 1)) {
if (this.lock >= LOCK) this.settle(fx);
const every = Math.max(0.09, 0.72 * 0.87 ** Math.floor(this.lines / 6));
const step = pad.held.has("down") ? Math.min(0.035, every) : every;
while (this.fall >= step && this.fits(p.cells, p.x, p.y + 1)) {
if (p.cells.some(([, cy]) => p.y + cy < 0)) return this.end(fx);
for (const [cx, cy] of p.cells) this.grid[(p.y + cy) * SET_COLS + p.x + cx] = p.ink;
for (let y = 0; y < SET_ROWS; y++) {
const row = Array.from({ length: SET_COLS }, (_, x) => this.at(x, y));
if (row.some((v) => v === 0)) continue;
if (row.every((v) => v === row[0])) pure.push(y);
if (!full.length) return this.spawn(fx);
this.rows = [... new Set([...full, ...pure.map((y) => y - 1).filter((y) => y >= 0)])];
this.score += full.length + pure.length * 4;
this.lines += full.length;
for (const y of pure) this.pops.push({ row: y, t: 0.8 });
for (let y = 0; y < SET_ROWS; y++) if (!this.rows.includes(y)) keep.push(y);
const next = new Uint8Array(SET_COLS * SET_ROWS);
const offset = SET_ROWS - keep.length;
keep.forEach((y, i) => next.set(this.grid.subarray(y * SET_COLS, (y + 1) * SET_COLS), (offset + i) * SET_COLS));
if (ink === 1) l.rect(px, py, 3, 3, 3);
else if (ink === 2) l.rect(px, py, 3, 3, 1);
l.pset(px + 1, py + 1, 3);
l.pset(px + 2, py + 2, 3);
const blink = (this.clock * 2.4 | 0) % 2 === 0;
if (this.phase === "title") {
l.sprite(SET_LOGO, RES - 34 >> 1, 12, 3, 2);
l.center("FILL A LINE", 44, 2);
l.center("ONE INK SCORES 5", 52, 2);
if (blink) l.center("PRESS 2", 72, 3);
l.center(`BEST ${this.best}`, 86, 2);
l.text("SCORE", 2, 6, 2);
l.text(String(this.score).padStart(4, "0"), 2, 13, 3);
l.text("LINES", 2, 26, 2);
l.text(String(this.lines), 2, 33, 3);
l.text("NEXT", 70, 6, 2);
l.frame(70, 13, 16, 16, 2);
const ns = SHAPES[this.next.shape];
for (const [cx, cy] of ns.cells) this.cell(l, 72 + cx * 4, 15 + cy * 4, this.next.ink);
l.frame(SET_X - 1, SET_Y - 1, SET_COLS * 4 + 1, SET_ROWS * 4 + 1, 3);
const pulled = Math.max(0, this.t / CLEAR_T - 0.35) / 0.65;
for (let y = 0; y < SET_ROWS; y++) {
const clearing = this.phase === "clear" && this.rows.includes(y);
for (let x = 0; x < SET_COLS; x++) {
const px = SET_X + x * 4;
const py = SET_Y + y * 4;
if (Math.abs(x - (SET_COLS - 1) / 2) >= pulled * (SET_COLS / 2 + 0.5)) l.rect(px, py, 4, 3, 3);
} else if (v) this.cell(l, px, py, v);
if (p && this.phase === "play") {
const gy = this.landing();
if (gy !== p.y) for (const [cx, cy] of p.cells) l.pset(SET_X + (p.x + cx) * 4 + 1, SET_Y + (gy + cy) * 4 + 1, 2);
for (const [cx, cy] of p.cells) if (p.y + cy >= 0) this.cell(l, SET_X + (p.x + cx) * 4, SET_Y + (p.y + cy) * 4, p.ink);
if (this.phase === "over") {
const filled = Math.min(SET_ROWS, Math.floor(this.t / 0.7 * SET_ROWS));
for (let y = SET_ROWS - filled; y < SET_ROWS; y++) l.rect(SET_X, SET_Y + y * 4, SET_COLS * 4 - 1, 3, 2);
for (const pop of this.pops) {
const y = SET_Y + pop.row * 4 - Math.round((0.8 - pop.t) * 12);
l.rect(SET_X + 12, y - 1, 13, 7, 0);
l.text("+5", SET_X + 14, y, 3);
if (this.phase === "ready") l.box(["READY"], 40);
if (this.phase === "over" && this.t > 0.8) {
const lines = ["OUT OF ROOM", `SCORE ${this.score}`, this.fresh ? "NEW BEST" : `BEST ${this.best}`];
lines.push(this.t > 1 && blink ? "PRESS 2" : "");
"11111..111..1.....1111.",
"1.....1...1.1.....1...1",
"1.....1...1.1.....1...1",
"1111..1...1.1.....1...1",
"1.....1...1.1.....1...1",
"1.....1...1.1.....1...1",
"1......111..11111.1111.",
".......................",
"1.1.1.1.1.1.1.1.1.1.1.1"
const CLIMB = ["1", "2", "3", "4", "up"];
function planeShape(x, y, pitch) {
const dx = Math.cos(pitch);
const dy = -Math.sin(pitch);
const at = (along, up) => [x + dx * along + dy * up, y + dy * along - dx * up];
return [at(6, 0), at(-5, 3), at(-3, -2), at(-4, 0)];
rule = "THE LOWER THE FASTER";
best = Number(store.get("fold:best")) || 0;
return this.phase === "ready" || this.phase === "play";
let last = this.walls[this.walls.length - 1];
while (!last || last.x < this.x + RES * 2) {
const x = (last?.x ?? 96) + WALL_EVERY + Math.random() * 22;
const size = Math.max(WALL_GAP_MIN, Math.round(WALL_GAP - x / 600));
const lo = SKY + size / 2 + 4;
const hi = GROUND - size / 2 - 4;
const prev = last?.gap ?? 48;
const gap = Math.round(Math.max(lo, Math.min(hi, prev + (Math.random() * 2 - 1) * 28)));
last = { x, gap, size, passed: false };
this.walls = this.walls.filter((w) => w.x > this.x - PLANE_X - WALL_W);
return planeShape(this.x, this.y, this.pitch).slice(0, 3);
const go = pad.hit.has("2") || pad.hit.has("start");
if (this.phase === "title") {
if (this.phase === "over") {
if (this.fresh && !this.cheered && this.t > 0.9) {
if (go && this.t > 1) this.start(fx);
if (this.phase === "ready") {
if (this.t > 0.9) this.phase = "play";
this.fly(dt, CLIMB.some((k) => pad.held.has(k)), fx);
const stall = this.v < STALL;
if (stall && !this.stalled) fx.sfx("low");
const target = stall ? -1 : climb ? 0.85 : -0.4;
const authority = target > this.pitch ? 2.6 * Math.min(1, this.v / 45) : 2.6;
const turn = Math.max(-authority, Math.min(authority, (target - this.pitch) * 6));
this.v += (-GRAVITY * Math.sin(this.pitch) - 0.02 * Math.abs(turn) * this.v) * dt;
this.v = Math.max(8, Math.min(TOP_SPEED, this.v));
this.x += this.v * Math.cos(this.pitch) * dt;
this.y -= this.v * Math.sin(this.pitch) * dt;
this.pitch = Math.min(this.pitch, 0);
const tail = this.trail[this.trail.length - 1];
if (!tail || this.x - tail.x >= 3) this.trail.push({ x: this.x, y: this.y });
if (this.trail.length > 24) this.trail.shift();
this.score = Math.floor(this.x / 8);
for (const w of this.walls) {
if (!w.passed && w.x + WALL_W < this.x - 4) {
this.v = Math.min(TOP_SPEED, this.v + 3);
for (const [px, py] of this.points()) {
if (px < w.x || px > w.x + WALL_W) continue;
if (Math.abs(py - w.gap) > w.size / 2) return this.end("wall", fx);
if (this.points().some(([, py]) => py >= GROUND)) this.end("ground", fx);
if (this.cause === "wall") {
this.y = Math.min(GROUND - 2, this.y + this.fallV * dt);
this.v = damp(this.v, 0, 4, dt);
this.pitch = damp(this.pitch, 0, 10, dt);
this.y = damp(this.y, GROUND - 2, 10, dt);
plane(l, sx, sy, pitch) {
const [n, w, k, c] = planeShape(sx, sy, pitch);
l.tri(n[0], n[1], w[0], w[1], c[0], c[1], 2);
l.tri(n[0], n[1], c[0], c[1], k[0], k[1], 3);
const blink = (this.clock * 2.4 | 0) % 2 === 0;
if (this.phase === "title") {
l.sprite(FOLD_LOGO, RES - FOLD_LOGO[0].length * 2 >> 1, 12, 3, 2);
l.center("HOLD 2 TO CLIMB", 44, 2);
l.center("THE LOWER THE FASTER", 52, 2);
if (blink) l.center("PRESS 2", 72, 3);
l.center(`BEST ${this.best}`, 86, 2);
const cam = this.x - PLANE_X;
l.text(String(this.score).padStart(4, "0"), 2, 2, 3);
this.plane(l, RES - 37, 4, 0);
if (!(this.stalled && !blink && this.phase === "play")) {
l.frame(RES - 30, 2, 28, 5, 3);
l.rect(RES - 29, 3, Math.round(26 * Math.max(0, this.v - STALL) / (TOP_SPEED - STALL)), 3, 2);
l.rect(1, 8, RES - 2, 1, 2);
for (const w of this.walls) {
const sx2 = Math.round(w.x - cam);
if (sx2 > RES || sx2 + WALL_W < 0) continue;
const top = Math.round(w.gap - w.size / 2);
const bottom = Math.round(w.gap + w.size / 2);
this.stack(l, sx2, SKY, top - SKY);
this.stack(l, sx2, bottom, GROUND - bottom);
l.rect(0, GROUND, RES, 1, 3);
for (let gx = -(Math.floor(cam) % 8); gx < RES; gx += 8) l.rect(gx, GROUND + 2, 3, 1, 2);
for (const p of this.trail) if (p.x < this.x - 6) l.pset(p.x - cam, p.y, 1);
if (this.phase === "over" && this.cause === "wall") {
l.rect(sx - 1, this.y - 1, 3, 3, 3);
l.pset(sx + 1, this.y - 1, 0);
l.pset(sx - 2, this.y, 2);
} else this.plane(l, sx, this.y, this.pitch);
if (this.phase === "ready") l.box(["READY"], 40);
if (this.phase === "over" && this.t > 0.8) {
const lines = [this.cause === "wall" ? "CRUMPLED" : "LANDED", `SCORE ${this.score}`, this.fresh ? "NEW BEST" : `BEST ${this.best}`];
lines.push(this.t > 1 && blink ? "PRESS 2" : "");
l.rect(x, y, WALL_W, h, 0);
l.frame(x, y, WALL_W, h, 3);
for (let yy = y + 2; yy < y + h - 1; yy += 2) l.rect(x + 1, yy, WALL_W - 2, 1, 2);
const DOT_BOUNCE = Math.sqrt(2 * DOT_G * 30);
const DOT_SPRITE = [".11.", "1111", "1111", ".11."];
const DOT_SQUASH = ["111111", ".1111."];
rule = "TEAR LEDGES TO SCORE";
best = Number(store.get("dot:best")) || 0;
return this.phase === "ready" || this.phase === "play";
this.ledges = [{ x: 0, y: 76, w: RES, prints: [], cut: 0, torn: -1 }];
return Math.min(26, 5 + this.played * 0.08 + this.score * 0.9);
let top = this.ledges.reduce((a, l) => l.y < a.y ? l : a);
while (top.y > this.cam - 40) {
const w = Math.round(Math.max(10, 18 - this.score / 4) + Math.random() * 6);
const mid = top.x + top.w / 2 + (Math.random() * 2 - 1) * 28;
const y = Math.round(top.y - 14 - Math.random() * 9);
const next = { x: Math.round(Math.max(0, Math.min(RES - w, mid - w / 2))), y, w, prints: [], cut: 0, torn: -1 };
if (Math.random() < 0.4) {
const x = Math.round(Math.random() * (RES - w));
if (Math.abs(x - next.x) > w + 8) this.ledges.push({ x, y: y + Math.round(Math.random() * 6 - 3), w, prints: [], cut: 0, torn: -1 });
this.ledges = this.ledges.filter((l) => l.y < this.cam + DOT_VIEW + 40 && l.torn < 1.2);
return [this.x, this.x - RES, this.x + RES].some((x) => x + 2 >= l.x && x - 2 <= l.x + l.w);
for (const p of this.pops) p.t -= dt;
this.pops = this.pops.filter((p) => p.t > 0);
for (const l of this.ledges) if (l.torn >= 0) l.torn += dt;
this.squash = Math.max(0, this.squash - dt);
const go = pad.hit.has("2") || pad.hit.has("start");
if (this.phase === "title") {
if (this.phase === "over") {
if (this.fresh && !this.cheered && this.t > 0.9) {
if (go && this.t > 1) this.start(fx);
if (this.phase === "ready") {
if (this.t > 0.9) this.phase = "play";
this.hop(dt, (pad.held.has("right") ? 1 : 0) - (pad.held.has("left") ? 1 : 0), fx);
this.vx = damp(this.vx, dir * 66, 12, dt);
this.x = (this.x + this.vx * dt + RES) % RES;
const l = this.ledges.find((l2) => l2.torn < 0 && prev + 2 <= l2.y && this.y + 2 >= l2.y && this.under(l2));
this.cam -= this.rise() * dt;
if (this.focus < this.cam) this.cam = damp(this.cam, this.focus, 4, dt);
this.cam = Math.min(this.cam, this.y - 12);
if (this.y - this.cam > DOT_VIEW + 6) this.end(fx);
this.focus = Math.min(this.focus, l.y - 62);
const near = [this.x, this.x - RES, this.x + RES].reduce((a, b) => Math.abs(b - l.x - l.w / 2) < Math.abs(a - l.x - l.w / 2) ? b : a);
const at = Math.max(0, Math.min(l.w - 2, Math.round(near - l.x - 1)));
if (l.prints.length < 3) return fx.sfx(l.prints.length === 1 ? "print1" : "print2");
this.pops.push({ x: l.x + at, y: l.y, t: 0.7 });
sheet(l, x, y, from, to, prints) {
if (y < DOT_TOP || y > RES) return;
l.rect(x + from, y, to - from, 1, 3);
l.rect(x + from, y + 1, to - from, 2, 1);
for (const p of prints) if (p >= from && p < to) l.rect(x + p, y + 1, 2, 2, 3);
const blink = (this.clock * 2.4 | 0) % 2 === 0;
if (this.phase === "title") {
l.sprite(DOT_LOGO, RES - DOT_LOGO[0].length * 2 >> 1, 12, 3, 2);
l.center("THREE PRINTS TEAR", 44, 2);
l.center("A TORN LEDGE SCORES", 52, 2);
if (blink) l.center("PRESS 2", 72, 3);
l.center(`BEST ${this.best}`, 86, 2);
const sy = (wy) => Math.round(wy - this.cam + DOT_TOP);
for (const g of this.ledges) {
this.sheet(l, g.x, y, 0, g.w, g.prints);
const drift = Math.round(g.torn * 18);
const drop = Math.round(g.torn * g.torn * 160);
this.sheet(l, g.x - drift, y + drop, 0, g.cut, g.prints);
this.sheet(l, g.x + drift, y + drop, g.cut, g.w, g.prints);
const low = this.phase === "play" && this.y - this.cam > DOT_VIEW - 22;
for (let x = 0; x < RES; x += 2) l.pset(x, RES - 1, low && blink ? 3 : 1);
if (this.phase !== "over") {
for (const ox of [0, -RES, RES]) {
if (this.squash > 0) l.sprite(DOT_SQUASH, Math.round(this.x + ox) - 3, dy, 3);
else l.sprite(DOT_SPRITE, Math.round(this.x + ox) - 2, dy - 2, 3);
for (const p of this.pops) {
const y = sy(p.y) - 8 - Math.round((0.7 - p.t) * 12);
const x = Math.max(0, Math.min(RES - 13, Math.round(p.x) - 5));
l.rect(x, y - 1, 13, 7, 0);
l.text("+1", x + 2, y, 3);
l.text(String(this.score).padStart(3, "0"), 2, 2, 3);
l.rect(1, 8, RES - 2, 1, 2);
if (this.phase === "ready") l.box(["READY"], 40);
if (this.phase === "over" && this.t > 0.6) {
const lines = ["OFF THE PAGE", `SCORE ${this.score}`, this.fresh ? "NEW BEST" : `BEST ${this.best}`];
lines.push(this.t > 1 && blink ? "PRESS 2" : "");
if (performance.now() - this.loadedAt < 3e4) return;
this.loadedAt = performance.now();
fetch(BOARD_URL).then((r) => r.ok ? r.json() : null).then((rows) => rows && (this.rows = rows)).catch(() => this.loadedAt = -Infinity);
return this.rows?.[game] ?? [];
const t = this.top(game);
return !!this.rows && score > 0 && (t.length < 10 || score > t[t.length - 1].s);
return this.top(game).filter((r) => r.s >= score).length + 1;
async submit(game, name, score, ms) {
const r = await fetch(BOARD_URL, {
headers: { "content-type": "application/json" },
body: JSON.stringify({ game, name, score, ms: Math.round(ms) })
const res = await r.json();
if (this.rows) this.rows[game] = res.board;
this.mine = { n: name, s: score };
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
constructor(games, board) {
raise = new Array(SLOTS).fill(0);
return this.game.name.toLowerCase();
return this.sign?.state === "enter";
if (!s || s.state !== "enter") return;
s.at = Math.min(2, s.at + 1);
if (!s || s.state !== "enter") return;
s.at = Math.max(0, s.at - 1);
return this.games[this.inserted];
this.sel = this.inserted;
if (this.mode !== "game") return;
if (this.mode === "shelf") this.browse(dt, pad, fx);
else if (this.mode === "load") this.load(fx);
else this.play(dt, pad, fx);
const step = (pad.hit.has("right") ? 1 : 0) - (pad.hit.has("left") ? 1 : 0);
const next = this.sel + step;
if (step && next >= 0 && next < this.games.length) {
this.raise.forEach((r, i) => this.raise[i] = damp(r, i === this.sel ? 5 : 0, 30, dt));
if (pad.hit.has("2") || pad.hit.has("start")) {
this.swap = this.sel !== this.inserted;
this.seated = !this.swap;
fx.sfx(this.swap ? "eject" : "move");
if (t < EJECT_END) this.lift = (t / EJECT_END) ** 3;
this.inserted = this.sel;
} else if (t < DROP_END) this.lift = (1 - (t - DROP_AT) / (DROP_END - DROP_AT)) ** 4;
if (t < (this.swap ? BOOT_AT : 0.36)) return;
if (pad.hit.has("start")) {
} else if (pad.hit.has("select")) this.toShelf(fx);
const live = this.game.playing();
if (live && (pad.hit.has("start") || pad.hit.has("select"))) {
if (this.sign) return this.signInput(dt, pad, fx);
if (pad.hit.has("select")) return this.toShelf(fx);
this.game.update(dt, pad, fx);
if (g.phase === "play" || g.phase === "clear") this.runMs += dt * 1e3;
if (g.phase === this.lastPhase) return;
this.lastPhase = g.phase;
if (g.phase === "ready") this.runMs = 0;
if (g.phase === "title") {
if (g.phase === "over" && this.board.qualifies(this.key, g.score)) {
const saved = store.get("initials") ?? "AAA";
this.sign = { letters: [...saved], at: 0, state: "enter", t: 0, rank: this.board.rankOf(this.key, g.score), score: g.score };
if (s.state === "sending") return;
if (s.state !== "enter") {
if (s.t > 1.8 || pad.hit.size) this.sign = null;
if (pad.hit.has("select")) {
const turn = (pad.hit.has("up") ? 1 : 0) - (pad.hit.has("down") ? 1 : 0);
const i = LETTERS.indexOf(s.letters[s.at]);
s.letters[s.at] = LETTERS[(i + turn + LETTERS.length) % LETTERS.length];
const move = (pad.hit.has("right") ? 1 : 0) - (pad.hit.has("left") ? 1 : 0);
s.at = Math.max(0, Math.min(2, s.at + move));
if (!(pad.hit.has("2") || pad.hit.has("start"))) return;
const name = s.letters.join("");
store.set("initials", name);
void this.board.submit(this.key, name, s.score, this.runMs).then((res) => {
if (this.sign !== s) return;
s.state = res ? "done" : "failed";
s.rank = res?.rank ?? null;
const blink = (performance.now() / 400 | 0) % 2 === 0;
l.center("NEW HIGH SCORE", 6, 3);
l.center(String(s.score), 16, 3, 2);
if (s.rank) l.center(`RANK ${s.rank}`, 34, 2);
s.letters.forEach((ch, i) => {
if (s.state === "enter" && i === s.at && blink) l.rect(x, 58, 6, 1, 3);
const foot = s.state === "enter" ? "2 SAVE" : s.state === "sending" ? "SAVING" : s.state === "done" ? "SAVED" : "NOT SAVED";
l.center(foot, 72, s.state === "failed" ? 3 : 2);
const blink = (performance.now() / 420 | 0) % 2 === 0;
l.text(this.game.name, 2, 2, 3);
l.text("TOP 10", RES - 2 - l.width("TOP 10"), 2, 2);
l.rect(1, 8, RES - 2, 1, 2);
const mine = this.board.mine;
const own = !!mine && r.n === mine.n && r.s === mine.s;
if (own) l.rect(1, y + 1, 2, 2, 3);
l.text(String(i + 1).padStart(2, " "), 5, y, c);
const score = String(r.s);
l.text(score, RES - 3 - l.width(score), y, c);
if (blink) l.center("PRESS 2", 87, 3);
if (this.mode === "game") {
if (this.sign) return this.drawSign(l, this.sign);
const rows = this.board.top(this.key);
if (this.game.phase === "title" && rows.length && this.titleT % 9 >= 4) return this.drawBoard(l, rows);
if (this.paused) l.box(["PAUSE", "SELECT :: SHELF"], 36);
const out = this.mode === "load" ? Math.min(1, this.t / 0.3) : 0;
l.text("POCKET PAL", 2, 2, 3);
l.rect(1, 8, RES - 2, 1, 2);
for (let i = 0; i < SLOTS; i++) {
const x = SLOT_X + i * SLOT_GAP;
const g2 = this.games[i];
this.empty(l, x, SLOT_Y);
let y = SLOT_Y - Math.round(this.raise[i]);
if (i === this.sel) y -= Math.round(out * out * 64);
this.cartridge(l, x, y, g2.name[0], i === this.sel);
const g = this.games[this.sel];
l.rect(2, 54, RES - 4, 1, 1);
l.center(g.name, 60, 3, 2);
l.center(`BEST ${g.best}`, 84, 2);
cartridge(l, x, y, initial, on) {
l.rect(x, y, SLOT_W, SLOT_H, 0);
l.frame(x, y, SLOT_W, SLOT_H, 3);
for (let k = 0; k < 3; k++) l.rect(x + 3, y + 2 + k * 2, SLOT_W - 6, 1, 2);
l.frame(x + 2, y + 9, SLOT_W - 4, SLOT_H - 11, on ? 3 : 2);
l.text(initial, x + 8, y + 13, 3);
for (let i = 0; i < SLOT_W; i += 2) {
l.pset(x + i, y + SLOT_H - 1, 1);
for (let j = 0; j < SLOT_H; j += 2) {
l.pset(x + SLOT_W - 1, y + j, 1);
const LABEL_PAPER = "#efece6";
const LABEL_INK = "#1b1b1c";
function planarUv(mesh) {
const pos = mesh.geometry.getAttribute("position");
const pts = Array.from({ length: pos.count }, (_, i) => new THREE.Vector3().fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld));
const box = new THREE.Box3().setFromPoints(pts);
const size = box.getSize(new THREE.Vector3());
const uv = new Float32Array(pos.count * 2);
uv[i * 2] = (p.x - box.min.x) / size.x;
uv[i * 2 + 1] = (p.y - box.min.y) / size.y;
mesh.geometry.setAttribute("uv1", new THREE.BufferAttribute(uv, 2));
function printLabel(g, n) {
const W = Math.round(H * 2.75);
const canvas = document.createElement("canvas");
const c = canvas.getContext("2d");
c.fillStyle = LABEL_PAPER;
const cell = Math.floor(H * 0.5 / g.mark.length);
const pad = Math.round(H * 0.26);
const top = Math.round((H - g.mark.length * cell) / 2);
g.mark.forEach((row, j) => [...row].forEach((v, i) => v === "1" && c.fillRect(pad + i * cell, top + j * cell, cell, cell)));
const num = String(n + 1).padStart(2, "0");
const k = Math.round(cell * 0.6);
const left = W - pad - (num.length * 4 - 1) * k;
[...num].forEach((ch, ci) => {
for (let i = 0; i < 15; i++) if (glyph[i] === "1") c.fillRect(left + ci * 4 * k + i % 3 * k, top + (i / 3 | 0) * k, k, k);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
[/^glass$/, [0, 0, 0.54], 0],
[/^(screen|mask)$/, [0, 0, 0.42], 1],
[/^slab$/, [0, 0, 0.3], 2],
[/^slab_core$/, [0, 0, 0.19], 2],
[/^btn_/, [0, 0, 0.28], 3],
[/^(board|cup_|window|rail_|print_)/, [0, 0, 0.12], 4],
[/^(wheel_|switch)/, [0.26, 0, 0], 4],
[/^core$/, [0, 0, -0.2], 5],
[/^body$/, [0, 0, -0.42], 6],
[/^cart$/, [0, 0.24, -0.42], 6],
[/^port_/, [0, -0.06, -0.42], 6]
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vN = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * mv;
float f = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.2);
gl_FragColor = vec4(uInk, 0.05 + 0.8 * f);
#include <colorspace_fragment>
if (cell.x < 0.0 || cell.y < 0.0 || cell.x >= uRes || cell.y >= uRes) return 0.0;
return texture2D(uDots, (cell + 0.5) / uRes).r;
float box(vec2 f, float g, vec2 aa) {
vec2 lo = smoothstep(vec2(g * 0.5) - aa, vec2(g * 0.5) + aa, f);
vec2 hi = smoothstep(vec2(g * 0.5) - aa, vec2(g * 0.5) + aa, 1.0 - f);
return lo.x * lo.y * hi.x * hi.y;
return clamp(d * uContrast + (uContrast - 1.0) * 0.18, 0.0, 1.0);
vec2 aa = max(fwidth(p) * 0.75, vec2(1e-4));
float sharp = clamp((1.0 / aa.x - 2.0) / 3.0, 0.0, 1.0);
float inside = box(fract(p), g, aa);
vec2 ps = p - vec2(0.22);
float fall = ink(level(floor(ps))) * box(fract(ps), g, aa);
vec3 col = uPaper * (1.0 - uShadow * fall);
col *= 1.0 - 0.05 * sharp * step(0.001, uGrid) * (1.0 - inside);
col = mix(col, uInk, ink(level(id)) * mix(1.0, inside, sharp));
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
const STYLE_ID = "exp-pocket-pal-style";
-webkit-touch-callout: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
outline: 2px solid var(--ink);
gap: var(--s-12,0.75rem);
font-family: var(--font-sans);
transition: scale 0.4s var(--ease-out-expo,cubic-bezier(0.16,1,0.3,1));
.hh[data-ready] .hh__loading {
.hh[data-failed] .hh__loading {
.hh__retry:focus-visible {
outline: 2px solid var(--ink);
.hh[data-mode]:not([data-mode="play"]):not([data-hover]) {
.hh[data-mode]:not([data-mode="play"])[data-drag] {
inset-block-start: var(--s-16,16px);
height: var(--s-40,40px);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md,6px);
box-shadow: var(--shadow-control);
height: calc(100% - 4px);
border-radius: var(--r-sm,4px);
box-shadow: var(--shadow-seg);
.hh-modes[data-ready] .hh-modes__pill {
transition: transform 250ms var(--ease-out-quad,ease-out),width 250ms var(--ease-out-quad,ease-out);
padding-inline: var(--s-12,12px);
font-family: var(--font-sans);
text-transform: uppercase;
transition: color 300ms var(--ease-out-quad,ease-out),scale 200ms var(--ease-out-quad,ease-out);
.hh-modes button[aria-pressed="true"] {
.hh-modes button:active {
.hh-modes button:focus-visible {
outline: 2px solid var(--ink);
border-radius: var(--r-sm,4px);
@media (hover:hover) and (pointer:fine) {
@container (max-width: 480px) {
padding-inline: var(--s-8,8px);
@media (pointer:coarse) {
inset-block-start: calc(var(--s-16,16px) + 3.25rem);
inset-inline: var(--s-16,16px);
font-family: var(--font-sans);
.hh[data-mode]:not([data-mode="play"]) .hh-modehint {
.hh-modehint [data-when="coarse"] {
@media (pointer:coarse) {
.hh-modehint [data-when="fine"] {
.hh-modehint [data-when="coarse"] {
.hh-card__note [data-when] {
.hh:not(:focus) .hh-card__note [data-when="idle"],.hh:focus .hh-card__note [data-when="focus"] {
@media (pointer:coarse) {
@media (prefers-reduced-motion:reduce) {
.hh-modes__pill,.hh-modes button {
transition: none!important;
.hh:not([data-ready]) .hh-card {
.hh:not([data-ready]) .hh-modes {
font-family: var(--font-sans);
inset-inline-end: var(--s-16,16px);
inset-block-end: var(--s-16,16px);
.hh-card__panel,.hh-pin {
background: var(--surface);
box-shadow: 0 0 0 1px var(--line),var(--shadow-control);
max-block-size: calc(100cqh - 5.5rem);
overscroll-behavior: contain;
padding-inline: var(--s-16,16px);
border-radius: var(--r-md,6px);
background: var(--surface);
box-shadow: 0 0 0 1px var(--line),var(--shadow-control);
touch-action: manipulation;
.hh-card__toggle:focus-visible {
outline: 2px solid var(--ink);
@container (max-width: 760px) or (max-height: 600px) {
.hh-card:not([data-open]) .hh-card__panel {
max-block-size: calc(100cqh - 9rem);
margin-inline-start: auto;
padding-inline: 10px 9px;
.hh-row[data-live] .hh-row__verb {
padding-inline-start: 9px;
.hh-row[role="switch"]:focus-visible {
outline: 2px solid var(--ink);
background: color-mix(in srgb, var(--ink) 20%, transparent);
transition: background-color 160ms ease;
background: var(--surface);
box-shadow: var(--shadow-seg);
transition: transform 160ms var(--ease-out-quad,ease-out);
.hh-row[aria-checked="true"] .hh-switch {
.hh-row[aria-checked="true"] .hh-switch::after {
transform: translateX(10px);
font-family: var(--font-mono);
color: color-mix(in srgb, var(--ink) 72%, transparent);
transition: background-color 120ms ease,color 120ms ease,transform 120ms var(--ease-out-quad,ease-out);
outline: 2px solid var(--ink);
@media (hover:hover) and (pointer:fine) {
.hh-key:not([data-down]):hover {
background: color-mix(in srgb, var(--ink) 8%, transparent);
.hh-pin[data-side="left"] {
@media (pointer:coarse) {
@media (prefers-reduced-motion:reduce) {
.hh-key,.hh-switch,.hh-switch::after,.hh__loading {
INK: [{ keys: ["left", "up", "right", "down"], verb: "Steer" }],
{ keys: ["left", "right"], verb: "Move" },
{ keys: ["up"], verb: "Drop" },
{ keys: ["2", "3"], verb: "Turn" }
FOLD: [{ keys: ["2"], verb: "Hold to climb" }],
DOT: [{ keys: ["left", "right"], verb: "Steer" }]
function hintsFor(deck) {
if (deck.mode === "shelf")
{ keys: ["2"], verb: "Play" },
{ keys: ["left", "right"], verb: "Choose" }
if (deck.mode !== "game") return [];
{ keys: ["left", "up", "right", "down"], verb: "Pick letters" },
{ keys: ["2"], verb: "Save" },
{ keys: ["select"], verb: "Skip" }
{ keys: ["start"], verb: "Resume" },
{ keys: ["select"], verb: "Shelf" }
if (deck.game.phase === "title")
{ keys: ["2"], verb: "Play" },
{ keys: ["select"], verb: "Shelf" }
if (deck.game.phase === "over")
{ keys: ["2"], verb: "Play again" },
{ keys: ["select"], verb: "Shelf" }
return [...GAME_HINTS[deck.game.name] ?? [], { keys: ["start"], verb: "Pause" }];
const ICONS = { left: svgLeft, up: svgUp, right: svgRight, down: svgDown };
const CARD_ROWS = [["left", "up", "right", "down"], ["1", "2", "3", "4"], ["start"], ["select"]];
const DPAD_ROW = new Set(["left", "up", "right", "down", "select"]);
function makeControls(dev, signal) {
const coarse = matchMedia("(pointer: coarse)");
let input = coarse.matches ? "touch" : "keys";
let pinned = store.get("pins") === "1";
const holding = new Map();
const k = holding.get(e.pointerId);
if (k === void 0) return;
holding.delete(e.pointerId);
window.addEventListener("pointerup", letGo, { signal, capture: true });
window.addEventListener("pointercancel", letGo, { signal, capture: true });
const grab = (el, down, up) => {
el.addEventListener("pointerdown", (e) => {
dev.wrap.focus({ preventScroll: true });
if (up) el.setPointerCapture(e.pointerId);
el.addEventListener("pointermove", (e) => e.stopPropagation());
el.addEventListener("pointerup", end);
el.addEventListener("pointercancel", end);
el.addEventListener("lostpointercapture", end);
const label = (k) => k === "start" ? input === "keys" ? "Enter" : "Start" : k === "select" ? input === "keys" ? "Shift" : "Select" : k;
const keyButton = (k) => {
const b = document.createElement("button");
b.setAttribute("aria-label", KEY_NAME[k]);
} else b.textContent = label(k);
holding.set(e.pointerId, k);
holding.delete(e.pointerId);
const el = document.createElement("span");
el.className = "hh-row__verb";
const lead = i > 0 && h.keys.length === 1 && ICONS[h.keys[0]];
if (lead) el.insertAdjacentHTML("beforeend", lead.replace("<svg", '<svg aria-hidden="true"'));
el.append(i ? h.verb.toLowerCase() : h.verb);
const switchRow = (text, get, set) => {
const row = document.createElement("button");
row.className = "hh-row";
row.setAttribute("role", "switch");
row.innerHTML = `<span class="hh-row__label">${text}</span><span class="hh-switch" aria-hidden="true"></span>`;
const sync = () => row.setAttribute("aria-checked", String(get()));
row.addEventListener("click", (e) => e.detail === 0 && flip());
return Object.assign(row, { sync });
const stepRow = (text, wheel) => {
const row = document.createElement("div");
row.className = "hh-row";
row.innerHTML = `<span class="hh-row__label">${text}</span>`;
for (const [sign, icon, name] of [
const b = document.createElement("button");
b.setAttribute("aria-label", `${text} ${name}`);
b.addEventListener("click", (e) => e.detail === 0 && dev.turn(wheel, sign * 0.4));
signal.addEventListener("abort", () => clearInterval(timer));
dev.turn(wheel, sign * 0.4);
timer = window.setInterval(() => dev.turn(wheel, sign * 0.4), 140);
const card = document.createElement("div");
card.className = "hh-card hh-ui";
const panel = document.createElement("div");
panel.className = "hh-card__panel";
const toggle = document.createElement("button");
toggle.className = "hh-card__toggle";
toggle.textContent = "Controls";
toggle.setAttribute("aria-expanded", "false");
toggle.addEventListener("pointerdown", (e) => e.stopPropagation());
toggle.addEventListener("click", () => {
const open = !card.hasAttribute("data-open");
card.toggleAttribute("data-open", open);
toggle.setAttribute("aria-expanded", String(open));
const keyRows = document.createElement("div");
keyRows.style.cssText = "display:contents";
const rule = document.createElement("div");
rule.className = "hh-card__rule";
const pinRow = switchRow("Pin to device", () => pinned, (on) => {
pins.hidden = !on || !shown;
store.set("pins", on ? "1" : "0");
const powerRow = switchRow("Power", () => dev.power(), (on) => dev.setPower(on));
const soundRow = switchRow("Sound", () => dev.sound(), (on) => dev.setSound(on));
const note2 = document.createElement("p");
note2.className = "hh-card__note";
note2.innerHTML = `<span data-when="idle">Click the handheld to use your keys</span><span data-when="focus">M for sound :: Esc to leave</span>`;
panel.append(keyRows, rule, stepRow("Contrast", 0), stepRow("Volume", 1), soundRow, powerRow, pinRow, note2);
card.append(panel, toggle);
const pins = document.createElement("div");
pins.className = "hh-pins hh-ui";
const sr = document.createElement("p");
sr.setAttribute("role", "status");
sr.setAttribute("aria-live", "polite");
dev.wrap.append(pins, card, sr);
dev.wrap.addEventListener("pointerdown", (e) => input = e.pointerType === "mouse" ? "keys" : "touch", { signal, capture: true });
dev.wrap.addEventListener("keydown", () => input = "keys", { signal });
let powerOn = dev.power();
let soundOn = dev.sound();
const render = (hints) => {
keyRows.replaceChildren();
for (const keys of CARD_ROWS) {
const row = document.createElement("div");
row.className = "hh-row";
const hs = hints.filter((h) => h.keys.some((k) => keys.includes(k)));
const live = new Set(hs.flatMap((h) => h.keys));
if (live.has(k)) b.dataset.live = "";
const touch = input === "touch";
for (const side of ["left", "right"]) {
const hs = hints.filter((h) => DPAD_ROW.has(h.keys[0]) === (side === "left"));
if (!hs.length) continue;
const el = document.createElement("div");
const row = document.createElement("div");
row.className = "hh-row";
for (const k of h.keys) {
const anchor = touch ? side === "left" ? "btn_dir_up" : "btn_face_up" : side === "left" ? "btn_dir_left" : "btn_face_right";
groups.push({ el, anchor, side: touch ? "above" : side, w: 0, h: 0 });
for (const g of groups) {
keyEls = [...dev.wrap.querySelectorAll(".hh-key[data-k]")];
const spoken = (k) => ICONS[k] ? KEY_NAME[k] : label(k);
sr.textContent = hints.map((h) => `${h.keys.length === 4 && h.keys[0] === "left" ? "Arrows" : h.keys.map(spoken).join(" or ")}: ${h.verb}.`).join(" ");
pins.hidden = !on || !pinned;
const key = `${input}|${pinned}|${hints.map((h) => h.keys.join("+") + h.verb).join(",")}`;
const W = dev.wrap.clientWidth;
const H = dev.wrap.clientHeight;
if (dev.power() !== powerOn) powerRow.setAttribute("aria-checked", String(powerOn = dev.power()));
if (dev.sound() !== soundOn) soundRow.setAttribute("aria-checked", String(soundOn = dev.sound()));
for (const el of keyEls) el.toggleAttribute("data-down", dev.pad.held.has(el.dataset.k));
const boxes = groups.map((g) => {
const a = dev.anchor(g.anchor) ?? { x: W / 2, y: H / 2 };
const x = g.side === "left" ? a.x - g.w - 34 : g.side === "right" ? a.x + 34 : a.x - g.w / 2;
const y = g.side === "above" ? a.y - g.h - 18 : a.y - g.h / 2;
if (boxes.length === 2) {
const overlap = l.x + l.g.w + 8 - r.x;
if (overlap > 0 && Math.abs(l.y - r.y) < Math.max(l.g.h, r.g.h)) {
for (const { g, x, y } of boxes) {
const cx = Math.max(8, Math.min(W - g.w - 8, x));
const cy = Math.max(8, Math.min(H - g.h - 8, y));
g.el.style.transform = `translate3d(${cx.toFixed(2)}px, ${cy.toFixed(2)}px, 0)`;
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const ac = new AbortController();
const wrap = document.createElement("div");
wrap.setAttribute("role", "application");
wrap.setAttribute("aria-label", "Pocket Pal, a handheld game player. Arrow keys, 1 to 4, Enter and Shift press its keys. M toggles sound. Escape leaves.");
wrap.innerHTML = `<p class="hh__loading"><span class="spin"></span>Loading Pocket Pal</p>
<p class="hh-modehint hh-ui"><span data-when="fine">Drag to turn :: Scroll to zoom :: Double-click to reset</span><span data-when="coarse">Drag to turn :: Pinch to zoom :: Double-tap to reset</span></p>`;
const loading = wrap.querySelector(".hh__loading");
renderer = new THREE.WebGLRenderer({ antialias: true });
wrap.dataset.failed = "";
loading.textContent = "This browser can't draw Pocket Pal.";
return { destroy: () => wrap.remove(), set: () => {
renderer.transmissionResolutionScale = 0.5;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.toneMapping = THREE.NeutralToneMapping;
wrap.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(26, 1, 0.05, 20);
const pmrem = new THREE.PMREMGenerator(renderer);
const room = new RoomEnvironment();
let envRT = pmrem.fromScene(room, 0.04);
scene.environment = envRT.texture;
renderer.domElement.addEventListener(
envRT = pmrem.fromScene(room, 0.04);
scene.environment = envRT.texture;
scene.environmentIntensity = 0.85;
const key = new THREE.DirectionalLight(16777215, 1.3);
key.position.set(-2, 3, 4);
const readSurface = () => {
const probe = document.createElement("span");
probe.style.cssText = "color:var(--surface);position:absolute;visibility:hidden";
const c = getComputedStyle(probe).color;
scene.background = new THREE.Color(c);
const board = new Leaderboard();
const deck = new Deck([new Ink(), new Compose(), new Fold(), new Dot()], board);
const pad = { held: new Set(), hit: new Set() };
const shake = { trauma: 0, x: 0, y: 0, z: 0 };
sfx: (name) => sfx.emit(name),
shake.trauma = Math.min(1, shake.trauma + amount);
stopT = Math.max(stopT, seconds);
const lcdMat = new THREE.ShaderMaterial({
fragmentShader: LCD_FRAG,
uDots: { value: lcd.tex },
uPaper: { value: new THREE.Color(PALETTES[P.palette].paper) },
uInk: { value: new THREE.Color(PALETTES[P.palette].ink) },
uGrid: { value: P.grid },
uShadow: { value: P.shadow },
const setPalette = (id) => {
lcdMat.uniforms.uPaper.value.set(PALETTES[id].paper);
lcdMat.uniforms.uInk.value.set(PALETTES[id].ink);
const ao = new THREE.TextureLoader().load(AO, void 0, void 0, () => {
for (const m of Object.values(mats))
if (m instanceof THREE.MeshStandardMaterial && m.aoMap) {
ao.colorSpace = THREE.NoColorSpace;
const std = (color, roughness, extra = {}) => new THREE.MeshStandardMaterial({ color, roughness, ...extra });
smoke: new THREE.MeshPhysicalMaterial({
attenuationColor: new THREE.Color("#a9adb4"),
attenuationDistance: 0.11
slab: new THREE.MeshPhysicalMaterial({
attenuationColor: new THREE.Color("#50535a"),
attenuationDistance: 0.17
glass: new THREE.MeshPhysicalMaterial({ color: "#ffffff", roughness: 0.04, transparent: true, opacity: 0.06, depthWrite: false, envMapIntensity: 0.7 }),
window: new THREE.MeshPhysicalMaterial({ color: "#050506", roughness: 0.08, clearcoat: 0.6, clearcoatRoughness: 0.03 }),
core: std("#4a4d53", 0.55),
board: std("#141517", 0.6),
cup: std("#2b2c30", 0.45),
key: std("#cfccc4", 0.42),
print: std("#cfccc4", 0.6),
glyph: std("#1d1d1f", 0.5),
ink: std("#1b1b1c", 0.45),
spot: std("#b03a1e", 0.4),
metal: std("#b9bdc4", 0.28, { metalness: 1 }),
dark: std("#0d0d0e", 0.6),
mask: new THREE.MeshBasicMaterial({ color: "#070707" }),
cart: std("#d8d6d0", 0.55),
label: std("#ffffff", 0.7),
for (const [name, m] of Object.entries(mats)) {
if (!UNBAKED.has(name) && m instanceof THREE.MeshStandardMaterial) m.aoMap = ao;
const pivot = new THREE.Group();
const labels = new Map();
let fitSize = new THREE.Vector3(0.7, 1.2, 0.2);
const localDir = (obj, world) => {
const parent = obj.parent;
const a = parent.worldToLocal(new THREE.Vector3(0, 0, 0));
const b = parent.worldToLocal(world.clone());
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
wrap.dataset.failed = "";
loading.innerHTML = `Pocket Pal didn't load.<button type="button" class="hh__retry hh-ui">Try again</button>`;
loading.querySelector("button").addEventListener("click", () => {
delete wrap.dataset.failed;
loading.innerHTML = `<span class="spin"></span>Loading Pocket Pal`;
const loadModel = () => fetchModel().then((bytes) => loader.parse(bytes, "", onModel, fail)).catch(fail);
const onModel = (gltf) => {
const model = gltf.scene;
if (!mesh.isMesh) return;
const name = mesh.material.name;
mesh.material = mats[name] ?? mats.dark;
const box = new THREE.Box3().setFromObject(model);
model.position.sub(box.getCenter(new THREE.Vector3()));
pivot.updateMatrixWorld(true);
const k = PART_KEYS[o.name];
parts.set(k, { obj: o, rest: o.position.clone(), dir: localDir(o, new THREE.Vector3(0, 0, -TRAVEL)), x: 0, v: 0, target: 0 });
if (o.name.startsWith("wheel_")) {
const i = Number(o.name.slice(6));
rest: o.quaternion.clone(),
axis: localDir(o, new THREE.Vector3(0, 0, 1)).normalize(),
label: i === 0 ? "CONTRAST" : "VOLUME",
value: i === 0 ? 0.6 : 0.5
cartridge = { obj: o, rest: o.position.clone(), dir: localDir(o, new THREE.Vector3(0, EJECT, 0)) };
if (o.name === "cart_label") label = o;
if (o.name === "switch") {
powerSwitch = { obj: o, rest: o.position.clone(), dir: localDir(o, new THREE.Vector3(0, -SWITCH_TRAVEL, 0)), x: 0, v: 0, target: 0 };
const b = new THREE.Box3().setFromObject(powerSwitch.obj);
b.min.y -= SWITCH_TRAVEL;
const hit = new THREE.Mesh(new THREE.BoxGeometry(...b.getSize(new THREE.Vector3()).toArray()), new THREE.MeshBasicMaterial({ visible: false }));
hit.position.copy(b.getCenter(new THREE.Vector3())).sub(model.position);
if (label) planarUv(label);
const partOf = new Map([...parts.values(), ...powerSwitch ? [powerSwitch] : []].map((p) => [p.obj, p]));
const hit = EXPLODE.find(([re]) => re.test(o.name));
const part = partOf.get(o);
base: (part ? part.rest : o.position).clone(),
off: localDir(o, new THREE.Vector3(...hit[1])),
wrap.querySelector(".hh__loading")?.setAttribute("aria-hidden", "true");
const start = new URLSearchParams(location.search).get("mode");
if (MODES.some(([m]) => m === start)) setMode(start);
const setSound = (on) => {
sfx.setVolume(sfx.volume);
store.set("sound", on ? "1" : "0");
if (power) osd.show(on ? "SOUND ON" : "SOUND OFF", on ? sfx.volume : 0);
controls.say(on ? "Sound on." : "Sound off.");
sfx.silent = store.get("sound") === "0";
const setPower = (on) => {
if (powerSwitch) powerSwitch.target = on ? 0 : 1;
if (pad.held.has(k)) return;
const turnWheel = (w, delta) => {
w.value = Math.min(1, Math.max(0, w.value + delta * 0.25));
if (w.label === "CONTRAST") lcdMat.uniforms.uContrast.value = 0.45 + w.value * 0.9;
else sfx.setVolume(w.value);
if (power) osd.show(w.label, w.value);
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const r = renderer.domElement.getBoundingClientRect();
ndc.set((e.clientX - r.left) / r.width * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObjects(hitTargets, true)[0];
while (o && !hitTargets.includes(o)) o = o.parent;
const nearestKey = (e) => {
const r = renderer.domElement.getBoundingClientRect();
const v = new THREE.Vector3();
for (const o of hitTargets) {
if (!PART_KEYS[o.name]) continue;
o.getWorldPosition(v).project(camera);
const d = Math.hypot((v.x + 1) / 2 * r.width + r.left - e.clientX, (1 - v.y) / 2 * r.height + r.top - e.clientY);
const pointer = { x: 0, y: 0 };
const tilt = { x: 0, y: 0 };
const pointerKeys = new Map();
const tap = { x: 0, y: 0, part: false, partAt: 0, last: 0 };
if (drag && e.pointerId === drag.id) {
turnWheel(drag.wheel, (drag.y - e.clientY) * 0.03);
const held = pointerKeys.get(e.pointerId);
if (held && DPAD.has(held)) {
const o2 = pickAt(e) ?? (e.pointerType === "mouse" ? null : nearestKey(e));
const k = o2 ? PART_KEYS[o2.name] : void 0;
if (k && k !== held && DPAD.has(k)) {
pointerKeys.set(e.pointerId, k);
if (e.pointerType === "touch") return;
delete wrap.dataset.hover;
const r = wrap.getBoundingClientRect();
pointer.x = (e.clientX - r.left) / r.width * 2 - 1;
pointer.y = (e.clientY - r.top) / r.height * 2 - 1;
if (e.target.closest(".hh-ui")) return;
wrap.focus({ preventScroll: true });
const o = pickAt(e) ?? (e.pointerType === "mouse" || mode !== "play" ? null : nearestKey(e));
if (o) tap.partAt = performance.now();
const k = PART_KEYS[o.name];
wrap.setPointerCapture(e.pointerId);
pointerKeys.set(e.pointerId, k);
const w = wheels.find((x) => x.obj === o);
wrap.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, wheel: w, y: e.clientY };
if (o.name === "switch_hit") setPower(!power);
else if (o === cartridge?.obj && power) deck.toShelf(fx);
{ signal, capture: true }
if (e.type === "pointerup" && e.pointerType !== "mouse" && mode !== "play" && !tap.part && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) < 12) {
const now = performance.now();
if (now - tap.last < 350) glide(poseOf(mode === "orbit" ? "play" : mode));
const k = pointerKeys.get(e.pointerId);
pointerKeys.delete(e.pointerId);
if (drag && drag.id === e.pointerId) {
delete wrap.dataset.drag;
wrap.addEventListener("pointerup", lift, { signal });
wrap.addEventListener("pointercancel", lift, { signal });
wrap.addEventListener("lostpointercapture", lift, { signal });
wrap.addEventListener("contextmenu", (e) => e.preventDefault(), { signal });
document.addEventListener("keydown", (e) => e.key === "Tab" && (wrap.dataset.kbd = ""), { signal, capture: true });
wrap.addEventListener("pointerdown", () => delete wrap.dataset.kbd, { signal, capture: true });
for (const k of [...pad.held]) release(k);
delete wrap.dataset.drag;
document.addEventListener("visibilitychange", () => document.hidden && letGo(), { signal });
delete wrap.dataset.hover;
if (mode !== "play") return;
const w = wheels.find((x) => x.obj === o);
turnWheel(w, -e.deltaY * 4e-3);
{ signal, passive: false }
if (e.target !== wrap || e.metaKey || e.ctrlKey || e.altKey) return;
if (deck.signing() && /^[a-z]$/i.test(e.key)) {
deck.type(e.key.toUpperCase());
if (deck.signing() && e.key === "Backspace") {
if (e.key === "m" || e.key === "M") {
if (e.key === "Escape") {
const k = KEYBOARD[e.key];
const k = KEYBOARD[e.key];
if (k && pad.held.has(k) && ![...pointerKeys.values()].includes(k)) release(k);
for (const k of [...pad.held]) if (![...pointerKeys.values()].includes(k)) release(k);
const w = wrap.clientWidth;
const h = wrap.clientHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, h, false);
const t = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
const portrait = camera.aspect < 0.8;
const byH = fitSize.y * (portrait ? 1.12 : 1.22) / 2 / t;
const byW = fitSize.x * (portrait ? 1.28 : 1.9) / 2 / (t * camera.aspect);
playDist = Math.max(byH, byW) + fitSize.z;
orbit.minDistance = playDist * 0.62;
orbit.maxDistance = playDist * 1.6;
if (mode === "play" && !rig.moving) {
camera.position.set(0, 0, playDist);
camera.updateProjectionMatrix();
const ro = new ResizeObserver(size);
const themeObs = new MutationObserver(() => {
if (xrayOn) xrayMat.uniforms.uInk.value.copy(readInk());
themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
const spin = new THREE.Quaternion();
const stepSpring = (p, dt) => {
p.v += (k * (p.target - p.x) - c * p.v) * dt;
p.obj.position.copy(p.rest).addScaledVector(p.dir, p.x);
let last = performance.now();
raf = requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - last) / 1e3);
if (!visible || document.hidden || !ready) return;
acc = Math.min(acc + dt, 0.25);
deck.update(STEP, pad, fx);
if (label && deck.inserted !== labelShown) {
labelShown = deck.inserted;
if (!labels.has(labelShown)) {
const tex = printLabel(deck.game, labelShown);
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
labels.set(labelShown, tex);
m.map = labels.get(labelShown);
if (cartridge) cartridge.obj.position.copy(cartridge.rest).addScaledVector(cartridge.dir, reduced ? 0 : deck.lift);
if (power && wipe < 1) wipe = Math.min(1, wipe + dt * 2.6);
lcd.present(dt, P.ghost, power, wipe, flashFrames > 0);
if (flashFrames > 0) flashFrames--;
shake.trauma = Math.max(0, shake.trauma - dt * 1.8);
const jolt = reduced ? 0 : shake.trauma * shake.trauma;
shake.x = (Math.random() * 2 - 1) * 0.012 * jolt;
shake.y = (Math.random() * 2 - 1) * 0.012 * jolt;
shake.z = (Math.random() * 2 - 1) * 0.035 * jolt;
for (let i = 0; i < 4; i++) {
for (const p of parts.values()) stepSpring(p, dt / 4);
if (powerSwitch) stepSpring(powerSwitch, dt / 4);
for (const w of wheels) {
w.obj.quaternion.copy(w.rest).premultiply(spin.setFromAxisAngle(w.axis, w.angle));
const play = mode === "play";
const calm = !play ? 0 : document.activeElement === wrap ? 0.25 : 1;
const tx = reduced ? 0 : pointer.y * P.tilt * 0.6 * calm;
const ty = reduced ? -0.22 : -0.22 + pointer.x * P.tilt * calm;
tilt.x = damp(tilt.x, tx, 5, dt);
tilt.y = damp(tilt.y, ty, 5, dt);
pivot.rotation.set(tilt.x, tilt.y, shake.z);
pivot.position.set(shake.x, (reduced ? 0 : Math.sin(clock * 0.9) * 6e-3 * calm) + shake.y, 0);
if (mode !== "play" && !rig.moving) orbit.update(dt);
renderer.render(scene, camera);
controls.frame(power ? hintsFor(deck) : []);
raf = requestAnimationFrame(frame);
const anchorAt = new THREE.Vector3();
const anchorObjs = new Map();
const controls = makeControls(
turn: (i, d) => wheels[i] && turnWheel(wheels[i], d),
power: () => power === 1,
sound: () => !sfx.silent,
setSound: (on) => setSound(on),
unlock: () => sfx.unlock(),
const o = anchorObjs.get(name) ?? pivot.getObjectByName(name);
o.getWorldPosition(anchorAt).project(camera);
return { x: (anchorAt.x + 1) / 2 * wrap.clientWidth, y: (1 - anchorAt.y) / 2 * wrap.clientHeight };
const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = !reduced;
orbit.dampingFactor = 0.08;
orbit.minPolarAngle = 0.3;
orbit.maxPolarAngle = Math.PI - 0.3;
orbit.addEventListener("start", () => {
orbit.addEventListener("end", () => delete wrap.dataset.drag);
const rig = { at: new THREE.Vector3(), vel: new THREE.Vector3(), to: new THREE.Vector3(), moving: false };
const sph = new THREE.Spherical();
const [az, el, k] = m === "apart" ? [-1, 0.28, 1.5] : m === "xray" ? [-0.4, 0.14, 1] : [0, 0, 1];
return new THREE.Vector3(playDist * k, Math.PI / 2 - el, az);
sph.setFromVector3(camera.position);
rig.at.set(sph.radius, sph.phi, sph.theta);
const turn = rig.to.z - rig.at.z;
rig.to.z = rig.at.z + Math.atan2(Math.sin(turn), Math.cos(turn));
const stepRig = (dt) => {
const c = 2 * Math.sqrt(k);
for (let i = 0; i < 4; i++) {
rig.vel.x += ((rig.to.x - rig.at.x) * k - rig.vel.x * c) * h;
rig.vel.y += ((rig.to.y - rig.at.y) * k - rig.vel.y * c) * h;
rig.vel.z += ((rig.to.z - rig.at.z) * k - rig.vel.z * c) * h;
rig.at.addScaledVector(rig.vel, h);
if (reduced || rig.at.distanceTo(rig.to) < 1e-4 && rig.vel.length() < 1e-3) {
camera.position.setFromSphericalCoords(rig.at.x, rig.at.y, rig.at.z);
const stepApart = (dt) => {
const open = mode === "apart";
for (const L of layers) {
const target = open && clock - apartAt >= L.order * 0.05 ? 1 : 0;
if (reduced) L.a = target;
const k = open ? 110 : 170;
L.v += ((target - L.a) * k - L.v * 2 * Math.sqrt(k)) * dt;
const at = L.part ? L.part.rest : L.obj === cartridge?.obj ? cartridge.rest : L.obj.position;
at.copy(L.base).addScaledVector(L.off, L.a);
const probe = document.createElement("span");
probe.style.cssText = "color:var(--ink);position:absolute;visibility:hidden";
const c = getComputedStyle(probe).color;
return new THREE.Color(c);
const xrayMat = new THREE.ShaderMaterial({
fragmentShader: XRAY_FRAG,
uniforms: { uInk: { value: new THREE.Color() } },
const shells = new Map();
const setXray = (on) => {
if (on === xrayOn || !root3d) return;
shells.forEach((m, mesh) => mesh.material = m);
xrayMat.uniforms.uInk.value.copy(readInk());
if (!mesh.isMesh || mesh.name === "screen" || mesh.name === "switch_hit") return;
shells.set(mesh, mesh.material);
const modes = document.createElement("div");
modes.className = "hh-modes hh-ui";
modes.setAttribute("role", "group");
modes.setAttribute("aria-label", "View mode");
const pill = document.createElement("span");
pill.className = "hh-modes__pill";
pill.setAttribute("aria-hidden", "true");
const modeButtons = MODES.map(([m, text]) => {
const b = document.createElement("button");
b.setAttribute("aria-pressed", String(m === mode));
b.addEventListener("click", (e) => {
if (e.detail) wrap.focus({ preventScroll: true });
wrap.dataset.mode = mode;
const placePill = () => {
const b = modeButtons.find((x) => x.dataset.mode === mode);
const box = modes.getBoundingClientRect();
const r = b.getBoundingClientRect();
pill.style.width = `${r.width}px`;
pill.style.transform = `translateX(${r.left - box.left - modes.clientLeft}px)`;
requestAnimationFrame(() => {
requestAnimationFrame(() => modes.setAttribute("data-ready", ""));
for (const b of modeButtons) b.setAttribute("aria-pressed", String(b.dataset.mode === m));
controls.show(m === "play");
orbit.enabled = m !== "play";
if (m === "apart") apartAt = clock;
if (m === "play" || m === "apart" || m === "xray") glide(poseOf(m));
else if (was === "play") rig.moving = false;
if (mode === "play" || e.target.closest(".hh-ui") || performance.now() - tap.partAt < 600) return;
glide(poseOf(mode === "orbit" ? "play" : mode));
cancelAnimationFrame(raf);
if (!mesh.isMesh) return;
Object.values(mats).forEach((m) => m.dispose());
labels.forEach((t) => t.dispose());
renderer.forceContextLoss();
if (k === "palette") setPalette(v);
else if (k === "ghost") P.ghost = v;
else if (k === "grid") lcdMat.uniforms.uGrid.value = P.grid = v;
else if (k === "shadow") lcdMat.uniforms.uShadow.value = P.shadow = v;
else if (k === "tilt") P.tilt = v;
experiment.mount(document.querySelector(".stage"));