Back to Impossible Machine
SOURCE / PINNED RELEASE

Made of little things.

Impossible Machine

Release
841127ac75f3…
Author-recorded commit
123625b81870…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-47d97da9d80

Archive hash verified: db7c8101167c4b07…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import {
  storage,
  themeGet,
  themeOnChanged,
  config,
  type Theme,
  type Subscription,
} from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { step, caught, endpoints, type Piece, type Ball } from './physics.js';
import './styles.css';
const $ = <T extends HTMLElement>(id: string) =>
  document.getElementById(id) as T;
const canvas = $<HTMLCanvasElement>('board'),
  ctx = canvas.getContext('2d')!;
const levels = [
  {
    start: { x: 125, y: 90 },
    cup: { x: 435, y: 505 },
    hint: 'Catch the fall with a tilted ramp. Build a path to the cup.',
    pieces: [
      { x: 160, y: 230, angle: 0.28 },
      { x: 345, y: 380, angle: -0.2 },
    ],
  },
  {
    start: { x: 100, y: 80 },
    cup: { x: 495, y: 510 },
    hint: 'A longer journey. Try a chain of ramps, or a well-placed bumper.',
    pieces: [
      { x: 140, y: 210, angle: 0.4 },
      { x: 335, y: 350, angle: 0.2 },
    ],
  },
  {
    start: { x: 470, y: 75 },
    cup: { x: 115, y: 510 },
    hint: 'Send it back the other way. Negative angles roll the marble left.',
    pieces: [
      { x: 425, y: 220, angle: -0.3 },
      { x: 220, y: 365, angle: 0.2 },
    ],
  },
  {
    start: { x: 170, y: 75 },
    cup: { x: 420, y: 510 },
    hint: 'No rules, no rush. Build something delightfully unnecessary.',
    pieces: [{ x: 205, y: 220, angle: 0.3 }],
  },
];
let level = 0,
  pieces: Piece[] = [],
  selected = 0,
  id = 0,
  ball: Ball = { x: 0, y: 0, vx: 0, vy: 0 },
  mode: 'edit' | 'run' | 'won' | 'miss' = 'edit',
  attempt = 0,
  time = 0,
  trail: { x: number; y: number }[] = [],
  ghost: { x: number; y: number }[] = [],
  solved = new Set<number>(),
  showTrail = true;
let scale = 1,
  ox = 0,
  oy = 0,
  w = 0,
  h = 0,
  drag: { id: number; dx: number; dy: number } | null = null;
const subscriptions: Subscription[] = [];
function status(s: string) {
  $('status').textContent = s;
}
function update() {
  const select = $<HTMLSelectElement>('piece');
  select.replaceChildren(
    ...pieces.map((p, i) => {
      const o = document.createElement('option');
      o.value = String(p.id);
      o.textContent = `${p.kind === 'ramp' ? 'Ramp' : 'Bumper'} ${i + 1}`;
      return o;
    }),
  );
  select.value = String(selected);
  const p = pieces.find((p) => p.id === selected);
  for (const key of ['left', 'right'])
    $<HTMLButtonElement>(key).disabled =
      !p || p.kind !== 'ramp' || mode === 'run';
  $<HTMLButtonElement>('remove').disabled = !p || mode === 'run';
  for (const key of ['ramp', 'bumper'])
    $<HTMLButtonElement>(key).disabled = pieces.length >= 12 || mode === 'run';
  $('solved').textContent = `${solved.size} / 3`;
  $('phase').textContent =
    mode === 'run'
      ? 'LET GRAVITY DO ITS THING'
      : mode === 'won'
        ? 'A WORK OF GENIUS'
        : 'YOUR WORKBENCH';
  $('attempt').textContent =
    `TAKE ${String(Math.max(1, attempt)).padStart(2, '0')}`;
  $<HTMLButtonElement>('drop').disabled = mode === 'run';
}
function retry() {
  if (trail.length) ghost = [...trail];
  trail = [];
  mode = 'edit';
  time = 0;
  ball = { ...levels[level].start, vx: 0, vy: 0 };
  $('celebration').hidden = true;
  status('Ready for another little experiment.');
  update();
}
function load() {
  pieces = levels[level].pieces.map((p) => ({ ...p, id: ++id, kind: 'ramp' }));
  pieces.push({ id: ++id, kind: 'bumper', x: 480, y: 330, angle: 0 });
  selected = pieces[0].id;
  attempt = 0;
  ghost = [];
  trail = [];
  retry();
  $('hint').textContent = levels[level].hint;
  status('Drag to move · Select a ramp, then ↶ ↷ to tilt');
}
function edit() {
  if (mode !== 'edit') retry();
}
async function save() {
  if (!runtimeHasDomain('storage')) {
    status('Progress stays in this session. Saving is unavailable here.');
    return;
  }
  try {
    await storage.setItem(
      'impossible-machine:solved',
      JSON.stringify([...solved]),
    );
    status('Progress saved. Your next experiment awaits.');
  } catch {
    status('Could not save. You can keep playing in this session.');
  }
}
$('save').onclick = () => void save();
$<HTMLSelectElement>('level').onchange = (e) => {
  level = Number((e.target as HTMLSelectElement).value);
  load();
};
$('reset').onclick = load;
$('retry').onclick = retry;
$('drop').onclick = () => {
  retry();
  mode = 'run';
  attempt++;
  status('Rolling… You can retry at any time.');
  update();
};
$('next').onclick = () => {
  level = (level + 1) % levels.length;
  $<HTMLSelectElement>('level').value = String(level);
  load();
};
for (const kind of ['ramp', 'bumper'] as const)
  $(kind).onclick = () => {
    edit();
    if (pieces.length >= 12) return;
    pieces.push({
      id: ++id,
      kind,
      x: 300 + ((pieces.length % 3) - 1) * 25,
      y: 280,
      angle: 0.25,
    });
    selected = id;
    update();
  };
$<HTMLSelectElement>('piece').onchange = (e) => {
  selected = Number((e.target as HTMLSelectElement).value);
  update();
};
for (const [key, delta] of [
  ['left', -0.13],
  ['right', 0.13],
] as const)
  $(key).onclick = () => {
    edit();
    const p = pieces.find((p) => p.id === selected);
    if (p) p.angle = Math.max(-1.4, Math.min(1.4, p.angle + delta));
    update();
  };
$('remove').onclick = () => {
  edit();
  pieces = pieces.filter((p) => p.id !== selected);
  selected = pieces[0]?.id ?? 0;
  update();
};
for (const b of document.querySelectorAll<HTMLButtonElement>('.nudges button'))
  b.onclick = () => {
    edit();
    const p = pieces.find((p) => p.id === selected);
    if (p) {
      p.x = Math.max(40, Math.min(560, p.x + Number(b.dataset.dx ?? 0)));
      p.y = Math.max(110, Math.min(550, p.y + Number(b.dataset.dy ?? 0)));
    }
  };
function pos(e: PointerEvent) {
  const r = canvas.getBoundingClientRect();
  return {
    x: (e.clientX - r.left - ox) / scale,
    y: (e.clientY - r.top - oy) / scale,
  };
}
canvas.onpointerdown = (e) => {
  if (mode === 'run') return;
  const q = pos(e);
  const p = [...pieces].reverse().find((p) => {
    if (p.kind === 'bumper') return Math.hypot(q.x - p.x, q.y - p.y) < 40;
    const [ax, ay, bx, by] = endpoints(p);
    const t = Math.max(
      0,
      Math.min(1, ((q.x - ax) * (bx - ax) + (q.y - ay) * (by - ay)) / 19600),
    );
    return Math.hypot(q.x - ax - t * (bx - ax), q.y - ay - t * (by - ay)) < 29;
  });
  if (!p) return;
  edit();
  selected = p.id;
  drag = { id: p.id, dx: q.x - p.x, dy: q.y - p.y };
  canvas.setPointerCapture(e.pointerId);
  update();
};
canvas.onpointermove = (e) => {
  if (!drag) return;
  const p = pieces.find((p) => p.id === drag!.id)!;
  const q = pos(e);
  p.x = Math.max(35, Math.min(565, q.x - drag.dx));
  p.y = Math.max(110, Math.min(555, q.y - drag.dy));
};
canvas.onpointerup = canvas.onpointercancel = () => {
  drag = null;
};
function line(
  ax: number,
  ay: number,
  bx: number,
  by: number,
  color: string,
  width: number,
) {
  ctx.beginPath();
  ctx.moveTo(ax, ay);
  ctx.lineTo(bx, by);
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineCap = 'round';
  ctx.stroke();
}
function circle(
  x: number,
  y: number,
  r: number,
  color: string | CanvasGradient,
) {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fillStyle = color;
  ctx.fill();
}
function draw() {
  const style = getComputedStyle(document.documentElement),
    fg = style.getPropertyValue('--fg').trim(),
    muted = style.getPropertyValue('--muted').trim();
  ctx.clearRect(0, 0, w, h);
  ctx.save();
  ctx.translate(ox, oy);
  ctx.scale(scale, scale);
  const L = levels[level];
  ctx.globalAlpha = 0.15;
  line(35, 565, 565, 565, fg, 1);
  ctx.globalAlpha = 1;
  ctx.font = '11px ui-monospace,monospace';
  ctx.fillStyle = muted;
  ctx.textAlign = 'center';
  ctx.fillText('THE DROP', L.start.x, L.start.y - 30);
  line(L.start.x, L.start.y + 22, L.start.x, L.start.y + 42, '#b0b7ac', 2);
  if (showTrail) {
    for (const [path, alpha] of [
      [ghost, 0.18],
      [trail, 0.45],
    ] as const) {
      ctx.globalAlpha = alpha;
      path.forEach((p, i) => {
        if (i % 4 === 0) circle(p.x, p.y, 2.5, '#719c89');
      });
    }
    ctx.globalAlpha = 1;
  }
  for (const p of pieces) {
    if (p.id === selected && mode !== 'run') {
      ctx.setLineDash([4, 5]);
      ctx.strokeStyle = '#729889';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.kind === 'ramp' ? 84 : 37, 0, Math.PI * 2);
      ctx.stroke();
      ctx.setLineDash([]);
    }
    if (p.kind === 'ramp') {
      const [ax, ay, bx, by] = endpoints(p);
      line(ax, ay + 5, bx, by + 5, '#00000015', 15);
      line(ax, ay, bx, by, '#9b6739', 13);
      line(ax, ay - 2, bx, by - 2, '#d9ac71', 9);
      circle(p.x, p.y, 3, '#8d6037');
      circle(p.x - 1, p.y - 1, 1, '#f7dfbb');
    } else {
      circle(p.x, p.y + 4, 25, '#00000015');
      circle(p.x, p.y, 25, '#b56151');
      circle(p.x, p.y - 2, 21, '#e7987b');
      circle(p.x, p.y - 2, 13, '#f0b99c');
      circle(p.x, p.y - 2, 6, '#ba6755');
    }
  }
  const c = L.cup;
  ctx.fillStyle = '#78a294';
  ctx.beginPath();
  ctx.moveTo(c.x - 38, c.y);
  ctx.lineTo(c.x - 29, c.y + 49);
  ctx.quadraticCurveTo(c.x, c.y + 60, c.x + 29, c.y + 49);
  ctx.lineTo(c.x + 38, c.y);
  ctx.closePath();
  ctx.fill();
  ctx.strokeStyle = '#426c5f';
  ctx.lineWidth = 3;
  ctx.stroke();
  ctx.fillStyle = '#34594f';
  ctx.beginPath();
  ctx.ellipse(c.x, c.y, 36, 8, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = '#b6d3bf';
  ctx.stroke();
  ctx.font = '18px Georgia';
  ctx.fillStyle = '#e8eee0';
  ctx.fillText('★', c.x, c.y + 35);
  if (mode !== 'won') {
    circle(ball.x + 2, ball.y + 4, 11, '#00000018');
    const g = ctx.createRadialGradient(
      ball.x - 4,
      ball.y - 5,
      1,
      ball.x,
      ball.y,
      11,
    );
    g.addColorStop(0, '#fff1bf');
    g.addColorStop(0.4, '#e9b951');
    g.addColorStop(1, '#b87c25');
    circle(ball.x, ball.y, 10, g);
    circle(ball.x - 3, ball.y - 4, 2, '#fff8db');
  }
  ctx.restore();
}
function resize() {
  const r = canvas.getBoundingClientRect();
  w = r.width;
  h = r.height;
  const dpr = Math.min(devicePixelRatio, 2);
  canvas.width = w * dpr;
  canvas.height = h * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  scale = Math.min(w / 600, h / 630);
  ox = (w - 600 * scale) / 2;
  oy = (h - 600 * scale) / 2 + 8;
}
const observer = new ResizeObserver(resize);
observer.observe(canvas);
let previous = 0,
  accumulator = 0,
  raf = 0;
function frame(now: number) {
  const delta = previous ? Math.min((now - previous) / 1000, 0.05) : 0;
  previous = now;
  if (mode === 'run') {
    accumulator += delta;
    while (accumulator >= 1 / 240 && mode === 'run') {
      step(ball, pieces, 1 / 240);
      time += 1 / 240;
      accumulator -= 1 / 240;
      if (caught(ball, levels[level].cup)) {
        mode = 'won';
        if (level < 3) solved.add(level);
        $('celebration').hidden = false;
        status('In the cup! Try the next puzzle, or refine your machine.');
        if (runtimeHasDomain('storage')) void save();
        update();
      } else if (ball.y > 650 || ball.x < -50 || ball.x > 650 || time > 12) {
        mode = 'miss';
        status('Almost! Move a piece, or tap Retry to try again.');
        update();
      }
    }
    if (trail.length < 1200) trail.push({ x: ball.x, y: ball.y });
  } else accumulator = 0;
  draw();
  raf = requestAnimationFrame(frame);
}
function applyTheme(t: Theme) {
  const s = document.documentElement.style;
  s.setProperty('--bg', t.colors.background);
  s.setProperty('--fg', t.colors.text);
  s.setProperty('--primary', t.colors.primary);
}
if (runtimeHasDomain('theme')) {
  try {
    themeGet()
      .then(applyTheme)
      .catch(() => {});
    subscriptions.push(themeOnChanged(applyTheme));
  } catch {}
}
if (runtimeHasDomain('config')) {
  try {
    subscriptions.push(
      config.subscribe((v) => {
        showTrail = v.showTrail !== false;
      }),
    );
  } catch {}
}
if (runtimeHasDomain('storage'))
  try {
    storage
      .getItem('impossible-machine:solved')
      .then((raw) => {
        try {
          const a = JSON.parse(raw ?? '[]');
          if (Array.isArray(a))
            a.filter(
              (n: unknown) =>
                typeof n === 'number' && Number.isInteger(n) && n >= 0 && n < 3,
            ).forEach((n: number) => solved.add(n));
          update();
        } catch {}
      })
      .catch(() => {});
  } catch {}
window.addEventListener(
  'pagehide',
  () => {
    subscriptions.forEach((s) => s.close());
    observer.disconnect();
    cancelAnimationFrame(raf);
  },
  { once: true },
);
load();
resize();
raf = requestAnimationFrame(frame);