Marble Maze

Tilt the board, or your phone, to roll a marble through three mazes.

What it is

A second capstone, built on the same pieces as Tower Toppler, with one new trick: on a phone you steer by tilting the phone itself. The browser's deviceorientation event reports how the phone is tilted (iOS asks permission first, in response to a tap), and the first reading becomes "level" so you can hold it at any angle. In the physics, the board never moves: gravity tilts instead, and the camera tilts the opposite way, which looks exactly like the board tilting. The floor is one seamless triangle mesh, so the marble doesn't catch on the edges between tiles.

When to use it

  • Motion controls for mobile games and playful pages
  • Seeing physics, GSAP and Rive work together a second time
  • A keyboard-playable 3D game: arrow keys or WASD
Official docs ↗

Play

Loading game…

Levels are text grids (# wall, O hole, S start, G goal), and a unit test runs a breadth-first search on each one to prove the goal can be reached. Best times are kept in this browser only.

Show code · src/app/marble/Marble.tsx
"use client";

import { Environment, Lightformer } from "@react-three/drei";
import { lazy, Suspense, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useMotion } from "@/components/motion/MotionProvider";
import { ThreeCanvas } from "@/components/three/ThreeCanvas";
import { Button } from "@/components/ui";
import { gsap, SplitText, useGSAP } from "@/lib/gsap";
import { RiveStars, warmRive } from "../game/RiveBits";
import { LEVELS, stars } from "./levels";
import { useTilt } from "./tilt";

// The physics world (and Rapier's ~800 KB engine) loads as its own chunk.
const MarbleWorld = lazy(() => import("./MarbleWorld").then((m) => ({ default: m.MarbleWorld })));

type Screen = "intro" | "countdown" | "playing" | "won" | "finished";
type Result = { seconds: number; stars: number; best: boolean; falls: number };

const BEST_KEY = "motion-lab:marble-best";
// localStorage can throw (private windows, blocked storage): then best times
// simply aren't remembered.
const readBest = (): Record<string, number> => { try { return JSON.parse(localStorage.getItem(BEST_KEY) ?? "{}"); } catch { return {}; } };
const writeBest = (best: Record<string, number>) => { try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch {} };
const fmt = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, "0")}`;

export function Marble() {
  const { reduced } = useMotion();
  const root = useRef<HTMLDivElement>(null);
  const layerRef = useRef<HTMLDivElement>(null);
  const timeRef = useRef<HTMLSpanElement>(null);
  const dotRef = useRef<HTMLSpanElement>(null);
  const startedRef = useRef(0);
  const screenRef = useRef<Screen>("intro");
  const fallsRef = useRef(0);

  const [ready, setReady] = useState(false);
  const [screen, setScreen] = useState<Screen>("intro");
  const [level, setLevel] = useState(0);
  const [attempt, setAttempt] = useState(0);
  const [falls, setFalls] = useState(0);
  const [result, setResult] = useState<Result | null>(null);
  const [total, setTotal] = useState(0);

  const current = LEVELS[level];
  const tilt = useTilt(layerRef, screen === "playing" || screen === "countdown");

  useEffect(() => { screenRef.current = screen; }, [screen]);
  // Phones and tablets get a "tilt the phone" button. (Read on the client
  // only: the server snapshot says no, so hydration matches.)
  const canTilt = useSyncExternalStore(
    () => () => {},
    () => typeof DeviceOrientationEvent !== "undefined" && matchMedia("(pointer: coarse)").matches,
    () => false,
  );

  // ---- Flow ---------------------------------------------------------------

  const start = (index: number, keepTotal: boolean) => {
    setLevel(index);
    setAttempt((a) => a + 1); // a fresh board and marble
    setFalls(0);
    fallsRef.current = 0;
    setResult(null);
    if (!keepTotal) setTotal(0);
    setScreen("countdown");
  };

  // 3, 2, 1, Go: the board is live (tiltable) but frozen until "Go".
  useGSAP(() => {
    if (screen !== "countdown") return;
    layerRef.current?.focus({ preventScroll: true });
    const el = root.current!.querySelector<HTMLElement>(".countdown");
    if (!el) return;
    // Clear the label at the end: when the screen changes, useGSAP reverts the
    // tweens, which would otherwise bring the last "Go" back into view.
    const tl = gsap.timeline({ onComplete: () => { el.textContent = ""; startedRef.current = performance.now(); setScreen("playing"); } });
    for (const n of reduced ? ["Go"] : ["3", "2", "1", "Go"]) {
      tl.call(() => { el.textContent = n; })
        .fromTo(el, { scale: reduced ? 1 : 1.6, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, duration: reduced ? 0.15 : 0.3, ease: "back.out(2)" })
        .to(el, { autoAlpha: 0, duration: reduced ? 0.4 : 0.35, delay: reduced ? 0.4 : 0.25 });
    }
  }, { scope: root, dependencies: [screen, attempt], revertOnUpdate: true });

  const onGoal = useCallback(() => {
    if (screenRef.current !== "playing") return;
    const seconds = (performance.now() - startedRef.current) / 1000;
    const name = LEVELS[level].name;
    const best = readBest();
    const isBest = !best[name] || seconds < best[name];
    if (isBest) writeBest({ ...best, [name]: seconds });
    setResult({ seconds, stars: stars(seconds, LEVELS[level].par), best: isBest, falls: fallsRef.current });
    setTotal((t) => t + seconds);
    setScreen(level === LEVELS.length - 1 ? "finished" : "won");
  }, [level]);

  const onFall = useCallback(() => {
    fallsRef.current += 1;
    setFalls(fallsRef.current);
    const flash = root.current?.querySelector(".fall-flash");
    if (flash) gsap.fromTo(flash, { autoAlpha: 0.9 }, { autoAlpha: 0, duration: reduced ? 0.2 : 0.7, ease: "power2.out" });
  }, [reduced]);

  // The marble's position, for curious eyes and the end-to-end tests.
  const onPosition = useCallback((x: number, z: number) => {
    layerRef.current?.setAttribute("data-ball", `${x.toFixed(2)},${z.toFixed(2)}`);
  }, []);

  const markReady = useCallback(() => { setReady(true); warmRive(); }, []);

  // ---- HUD: the clock and the tilt indicator update without React renders --

  useEffect(() => {
    let raf = 0;
    const tick = () => {
      if (timeRef.current && screenRef.current === "playing") timeRef.current.textContent = fmt((performance.now() - startedRef.current) / 1000);
      if (dotRef.current) dotRef.current.style.transform = `translate(${tilt.target.current.x * 14}px, ${-tilt.target.current.y * 14}px)`;
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [tilt.target]);
  useEffect(() => { if (screen === "countdown" && timeRef.current) timeRef.current.textContent = fmt(0); }, [screen]);

  // Menu panels pop in; the title splits into letters.
  useGSAP(() => {
    const panel = root.current!.querySelector(".panel");
    if (!panel) return;
    const tl = gsap.timeline();
    tl.from(panel, { autoAlpha: 0, scale: reduced ? 1 : 0.92, y: reduced ? 0 : 24, duration: reduced ? 0.25 : 0.5, ease: "back.out(1.6)" });
    const title = panel.querySelector(".panel-title");
    if (title && !reduced) {
      const split = SplitText.create(title, { type: "chars", aria: "auto" });
      tl.from(split.chars, { yPercent: 110, autoAlpha: 0, stagger: 0.03, duration: 0.5, ease: "power3.out" }, 0.1);
    }
  }, { scope: root, dependencies: [screen, ready], revertOnUpdate: true });

  const useDeviceTilt = async () => {
    const ok = await tilt.enableDevice();
    if (!ok) alert("Tilt isn't available: the browser didn't allow motion sensors.");
  };

  const playing = screen === "playing" || screen === "countdown";

  const overlay = (
    <div className="pointer-events-none absolute inset-0 text-white">
      {playing && (
        <div
          ref={layerRef}
          className="pointer-events-auto absolute inset-0 cursor-grab touch-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent active:cursor-grabbing"
          tabIndex={0}
          role="application"
          aria-label="Tilt the board with the arrow keys or WASD, or drag on it"
        />
      )}
      <div className="absolute inset-x-0 top-0 flex flex-wrap items-center justify-between gap-2 bg-linear-to-b from-black/60 to-transparent px-4 pt-3 pb-6 text-sm">
        <span><strong>{current.name}</strong> · level {level + 1}/{LEVELS.length}</span>
        <span className="tabular-nums">Time <strong ref={timeRef}>0:00.0</strong> · Falls <strong>{falls}</strong></span>
        <span className="relative inline-block size-9 rounded-full border border-white/50" role="img" aria-label="Board tilt">
          <span ref={dotRef} className="absolute top-1/2 left-1/2 -mt-1.5 -ml-1.5 size-3 rounded-full bg-white" />
        </span>
      </div>
      <span className="countdown absolute top-1/2 left-1/2 -translate-1/2 text-7xl font-black drop-shadow-lg" aria-live="assertive" />
      <div className="fall-flash invisible absolute inset-0 grid place-items-center bg-black/40 text-3xl font-bold">Whoops! Back to the start</div>

      {!ready && (
        <div className="absolute inset-0 grid place-items-center bg-black/70" role="status"><p className="m-0 text-sm text-white/80">Loading the physics engine…</p></div>
      )}

      {ready && !playing && (
        <div className="pointer-events-auto absolute inset-0 grid place-items-center bg-black/35 p-4">
          <div className="panel w-full max-w-sm rounded-2xl border border-white/15 bg-[#17161f]/95 p-6 text-center shadow-2xl">
            {screen === "intro" && (
              <>
                <h3 className="panel-title mt-0 mb-2 overflow-hidden text-3xl font-bold">Marble Maze</h3>
                <p className="text-sm text-white/80">Tilt the board to roll the marble to the glowing ring. Avoid the holes.</p>
                <p className="text-sm text-white/60">Arrow keys or WASD, drag on the board, or tilt your phone.</p>
                <Button className="mt-2" onClick={() => start(0, false)}>Play</Button>
              </>
            )}
            {(screen === "won" || screen === "finished") && result && (
              <>
                <h3 className="panel-title mt-0 mb-1 overflow-hidden text-2xl font-bold">{screen === "finished" ? "All mazes done" : "Made it!"}</h3>
                <RiveStars value={result.stars} label={`${result.stars} out of 5 stars`} />
                <p className="my-1 text-sm text-white/80">
                  {fmt(result.seconds)} (par {fmt(current.par)}) · {result.falls} fall{result.falls === 1 ? "" : "s"}{result.best && " · new best!"}
                </p>
                {screen === "finished" && <p className="my-1 font-semibold">Total {fmt(total)}</p>}
                <div className="mt-3 flex justify-center gap-2">
                  {screen === "won" ? (
                    <>
                      <Button variant="ghost" className="text-white" onClick={() => { setTotal((t) => t - result.seconds); start(level, true); }}>Retry</Button>
                      <Button onClick={() => start(level + 1, true)}>Next maze</Button>
                    </>
                  ) : (
                    <Button onClick={() => start(0, false)}>Play again</Button>
                  )}
                </div>
              </>
            )}
          </div>
        </div>
      )}
    </div>
  );

  return (
    <div ref={root}>
      <ThreeCanvas shadows camera={{ position: [0, 12, 7], fov: 45 }} className="h-[440px] sm:h-[540px]" label={`Marble Maze, level ${level + 1}: ${current.name}. A wooden maze seen from above, with a steel marble`} overlay={overlay}>
        {(animate) => (
          <>
            <color attach="background" args={["#14121c"]} />
            <ambientLight intensity={0.5} />
            <directionalLight position={[4, 10, 6]} intensity={2} castShadow shadow-mapSize={[1024, 1024]} shadow-camera-left={-7} shadow-camera-right={7} shadow-camera-top={7} shadow-camera-bottom={-7} />
            {/* Reflections for the steel marble, from Lightformer panels: no HDR download. */}
            <Environment resolution={128}>
              <Lightformer form="rect" intensity={3} position={[0, 5, -4]} scale={[10, 3, 1]} />
              <Lightformer form="rect" intensity={1.5} position={[-5, 2, 0]} rotation-y={Math.PI / 2} scale={[6, 2, 1]} color="#a89dff" />
              <Lightformer form="ring" intensity={2} position={[3, 6, 3]} scale={2} />
            </Environment>
            <Suspense fallback={null}>
              <MarbleWorld key={`${level}-${attempt}`} level={current} target={tilt.target} running={screen === "playing"} animate={animate}
                onFall={onFall} onGoal={onGoal} onPosition={onPosition} onReady={markReady} />
            </Suspense>
          </>
        )}
      </ThreeCanvas>
      <div className="mb-2 flex flex-wrap items-center gap-2">
        {canTilt && (tilt.deviceOn
          ? <><Button variant="ghost" onClick={tilt.recalibrate}>Recalibrate level</Button><Button variant="ghost" onClick={tilt.disableDevice}>Stop using tilt</Button></>
          : <Button onClick={useDeviceTilt}>Tilt your phone to play</Button>)}
        <span className="text-sm text-muted">Keyboard: Tab into the board, then arrow keys or WASD. Mouse or touch: drag on the board like a joystick.</span>
      </div>
      {reduced && <p className="text-sm text-muted">Reduced motion: menus fade instead of moving and the countdown is short; the marble itself still rolls, since that&apos;s the game.</p>}
    </div>
  );
}
Show code · src/app/marble/MarbleWorld.tsx
"use client";

import { useFrame } from "@react-three/fiber";
import { CuboidCollider, Physics, RigidBody, TrimeshCollider, type RapierRigidBody, useRapier } from "@react-three/rapier";
import { useEffect, useMemo, useRef, type RefObject } from "react";
import { Euler, type Mesh, PerspectiveCamera, Quaternion, Vector3 } from "three";
import { cellAt, find, type Level, toBoard } from "./levels";
import type { Tilt } from "./tilt";

const MAX_TILT = (13 * Math.PI) / 180; // radians at full input
const G = 9.81;
const BALL_RADIUS = 0.3;
const WALL_HEIGHT = 0.45;

/** The board's collision shapes and visible parts, built from the level grid. */
function useBoard(level: Level) {
  return useMemo(() => {
    const h = level.grid.length;
    const w = level.grid[0].length;
    // Floor: ONE triangle mesh over every cell that isn't a hole. Separate
    // boxes per cell would leave seams the marble keeps bumping into
    // ("ghost collisions"); shared vertices make one smooth surface.
    const vertices: number[] = [];
    for (let r = 0; r <= h; r++) for (let c = 0; c <= w; c++) vertices.push(c - w / 2, 0, r - h / 2);
    const index = (r: number, c: number) => r * (w + 1) + c;
    const indices: number[] = [];
    const tiles: { x: number; z: number }[] = [];
    const holes: { x: number; z: number }[] = [];
    for (let r = 0; r < h; r++) {
      for (let c = 0; c < w; c++) {
        const { x, z } = toBoard(level, r, c);
        if (cellAt(level, r, c) === "O") { holes.push({ x, z }); continue; }
        // Two triangles per cell, wound so they face up.
        indices.push(index(r, c), index(r + 1, c), index(r, c + 1), index(r, c + 1), index(r + 1, c), index(r + 1, c + 1));
        if (cellAt(level, r, c) !== "#") tiles.push({ x, z });
      }
    }
    // Walls: each horizontal run of # becomes one box, so fewer seams there too.
    const walls: { x: number; z: number; length: number }[] = [];
    level.grid.forEach((row, r) => {
      for (const m of row.matchAll(/#+/g)) {
        const start = m.index!;
        walls.push({ x: start + m[0].length / 2 - w / 2, z: r + 0.5 - h / 2, length: m[0].length });
      }
    });
    const s = find(level, "S");
    const g = find(level, "G");
    return {
      size: Math.max(w, h), vertices: new Float32Array(vertices), indices: new Uint32Array(indices),
      tiles, holes, walls, start: toBoard(level, s.row, s.col), goal: toBoard(level, g.row, g.col),
    };
  }, [level]);
}

const MIN_HFOV = 2 * Math.atan(Math.tan((45 * Math.PI) / 360) * 1.25);

// Each frame: ease the board's tilt towards the player's input, then turn it
// into physics and pictures. The board itself never moves: gravity is tilted
// instead (simpler and steadier than rotating a physics body), and the camera
// tilts the opposite way, which on screen looks exactly like the board tilting.
function Tilter({ target, size }: { target: RefObject<Tilt>; size: number }) {
  // The physics world and the scratch maths objects are held in refs: they're
  // mutated every frame, which React's rules allow for refs only.
  const world = useRef(useRapier().world);
  const current = useRef<Tilt>({ x: 0, y: 0 });
  const scratch = useRef({ q: new Quaternion(), e: new Euler(), g: new Vector3(), offset: new Vector3(), up: new Vector3() });
  useFrame(({ camera, size: view }, delta) => {
    const { q, e, g, offset, up } = scratch.current;
    const k = Math.min(1, delta * 8);
    current.current = { x: current.current.x + (target.current.x - current.current.x) * k, y: current.current.y + (target.current.y - current.current.y) * k };
    // Right (+x) means the right side goes down: a negative turn about z.
    // Away (+y) means the far side goes down: a negative turn about x.
    q.setFromEuler(e.set(-current.current.y * MAX_TILT, 0, -current.current.x * MAX_TILT)).invert();
    g.set(0, -G, 0).applyQuaternion(q);
    world.current.gravity = { x: g.x, y: g.y, z: g.z };
    const distance = size * 1.12;
    // Steeply from above, so walls don't hide the corridors behind them.
    camera.position.copy(offset.set(0, distance, distance * 0.32).applyQuaternion(q));
    camera.up.copy(up.set(0, 1, 0).applyQuaternion(q));
    camera.lookAt(0, 0, 0.3);
    if (camera instanceof PerspectiveCamera) {
      const fov = Math.max(45, (2 * Math.atan(Math.tan(MIN_HFOV / 2) / (view.width / view.height)) * 180) / Math.PI);
      if (Math.abs(camera.fov - fov) > 0.01) { camera.fov = fov; camera.updateProjectionMatrix(); }
    }
  });
  return null;
}

function Ball({ start, onFall, onPosition, ballRef }: {
  start: { x: number; z: number };
  onFall: () => void;
  onPosition: (x: number, z: number) => void;
  ballRef: RefObject<RapierRigidBody | null>;
}) {
  const lastReport = useRef(0);
  useFrame(({ clock }) => {
    const b = ballRef.current;
    if (!b) return;
    const p = b.translation();
    if (p.y < -2) {
      // Fell through a hole: back to the start, at rest.
      b.setTranslation({ x: start.x, y: BALL_RADIUS + 0.05, z: start.z }, true);
      b.setLinvel({ x: 0, y: 0, z: 0 }, true);
      b.setAngvel({ x: 0, y: 0, z: 0 }, true);
      onFall();
    }
    if (clock.elapsedTime - lastReport.current > 0.1) {
      lastReport.current = clock.elapsedTime;
      onPosition(p.x, p.z);
    }
  });
  return (
    <RigidBody ref={ballRef} position={[start.x, BALL_RADIUS + 0.05, start.z]} colliders="ball" ccd canSleep={false}
      friction={0.7} restitution={0.15} linearDamping={0.25} angularDamping={0.4} density={4}>
      <mesh castShadow>
        <sphereGeometry args={[BALL_RADIUS, 48, 48]} />
        <meshStandardMaterial color="#eef1f6" metalness={0.7} roughness={0.18} envMapIntensity={1.6} />
      </mesh>
    </RigidBody>
  );
}

function GoalPad({ x, z, animate }: { x: number; z: number; animate: boolean }) {
  const ring = useRef<Mesh>(null);
  useFrame(({ clock }) => {
    if (ring.current && animate) ring.current.scale.setScalar(1 + Math.sin(clock.elapsedTime * 3) * 0.08);
  });
  return (
    <mesh ref={ring} position={[x, 0.02, z]} rotation-x={-Math.PI / 2}>
      <ringGeometry args={[0.24, 0.42, 48]} />
      <meshStandardMaterial color="#3ee0c5" emissive="#3ee0c5" emissiveIntensity={1.4} />
    </mesh>
  );
}

export function MarbleWorld({ level, target, running, animate, onFall, onGoal, onPosition, onReady }: {
  level: Level;
  target: RefObject<Tilt>;
  running: boolean;
  animate: boolean;
  onFall: () => void;
  onGoal: () => void;
  onPosition: (x: number, z: number) => void;
  onReady: () => void;
}) {
  const board = useBoard(level);
  const ball = useRef<RapierRigidBody>(null);
  useEffect(onReady, [onReady]);
  return (
    <Physics gravity={[0, -G, 0]} paused={!running}>
      <Tilter target={target} size={board.size} />

      <RigidBody type="fixed" colliders={false}>
        <TrimeshCollider args={[board.vertices, board.indices]} friction={0.9} />
        {board.walls.map((w, i) => (
          <CuboidCollider key={i} args={[w.length / 2, WALL_HEIGHT / 2, 0.5]} position={[w.x, WALL_HEIGHT / 2, w.z]} />
        ))}
        {/* The goal: a sensor detects the marble without blocking it. */}
        <CuboidCollider sensor args={[0.3, 0.4, 0.3]} position={[board.goal.x, 0.4, board.goal.z]} onIntersectionEnter={onGoal} />
      </RigidBody>

      {/* What you see. Tiles are a touch smaller than a cell so the grid shows. */}
      {board.tiles.map((t, i) => (
        <mesh key={i} position={[t.x, -0.15, t.z]} receiveShadow>
          <boxGeometry args={[0.98, 0.3, 0.98]} />
          <meshStandardMaterial color={(i + Math.round(t.x + t.z)) % 2 ? "#c8a27a" : "#c09a72"} roughness={0.8} />
        </mesh>
      ))}
      {board.holes.map((t, i) => (
        <mesh key={i} position={[t.x, -0.6, t.z]}>
          <boxGeometry args={[1, 0.02, 1]} />
          <meshStandardMaterial color="#0b0a10" />
        </mesh>
      ))}
      {board.walls.map((w, i) => (
        <mesh key={i} position={[w.x, WALL_HEIGHT / 2, w.z]} castShadow receiveShadow>
          <boxGeometry args={[w.length, WALL_HEIGHT, 1]} />
          <meshStandardMaterial color="#6b4a32" roughness={0.7} />
        </mesh>
      ))}
      <mesh position={[0, -0.32, 0]} receiveShadow>
        <boxGeometry args={[board.size + 0.6, 0.04, board.size + 0.6]} />
        <meshStandardMaterial color="#3b2a1e" />
      </mesh>
      <GoalPad x={board.goal.x} z={board.goal.z} animate={animate} />
      <Ball start={board.start} onFall={onFall} onPosition={onPosition} ballRef={ball} />
    </Physics>
  );
}
Show code · src/app/marble/tilt.ts
"use client";

import { type RefObject, useCallback, useEffect, useRef, useState } from "react";

// Where the player wants the board tilted, as x (left −1 … right +1) and
// y (towards you −1 … away +1). Three sources, in order of priority:
// - the phone itself (DeviceOrientation), once the player turns it on
// - the arrow keys or WASD, while the game has keyboard focus
// - dragging on the board, like a joystick (mouse, finger or pen)

export type Tilt = { x: number; y: number };
const clamp = (v: number) => Math.max(-1, Math.min(1, v));
const DRAG_RANGE = 110; // px of drag for a full tilt
const DEVICE_RANGE = 22; // degrees of phone tilt for a full tilt

// ---- Phone tilt maths (pure, so it's unit-tested) ---------------------------

type Vec = [number, number, number];
const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const unit = (v: Vec): Vec => { const l = Math.hypot(...v) || 1; return [v[0] / l, v[1] / l, v[2] / l]; };
const rad = (deg: number) => (deg * Math.PI) / 180;

/**
 * Which way is "up", in screen coordinates (x right, y up the screen, z out
 * of it), from a deviceorientation reading. beta and gamma are Euler angles,
 * and Euler angles misbehave when the phone is held in landscape: gamma runs
 * into its ±90° limit and beta flips. The up direction they describe doesn't:
 * it's smooth however the phone is held. Then the screen's own rotation
 * (screen.orientation.angle) turns device axes into screen axes.
 */
export function upOnScreen(beta: number, gamma: number, screenAngle: number): Vec {
  const b = rad(beta), g = rad(gamma), a = rad(screenAngle);
  // World "up" seen from the device: the rotation is Z(alpha)·X(beta)·Y(gamma),
  // and alpha (compass heading) drops out.
  const ux = -Math.sin(g) * Math.cos(b), uy = Math.sin(b), uz = Math.cos(g) * Math.cos(b);
  // Rotated a degrees anticlockwise, screen-right is the device axis rotated by -a.
  return [ux * Math.cos(a) - uy * Math.sin(a), ux * Math.sin(a) + uy * Math.cos(a), uz];
}

/**
 * Tilt relative to a neutral pose (how the phone was held at "Play"): the
 * screen's right and up directions are flattened onto the neutral "level",
 * and the current up direction is measured along them. Same sensitivity
 * whether the phone is held flat or steeply.
 */
export function tiltFrom(up: Vec, neutral: Vec, rangeDeg = DEVICE_RANGE): Tilt {
  const n = unit(neutral);
  const along = (axis: Vec) => unit([axis[0] - dot(axis, n) * n[0], axis[1] - dot(axis, n) * n[1], axis[2] - dot(axis, n) * n[2]]);
  const right = along([1, 0, 0]);
  const upScreen = along([0, 1, 0]);
  const scale = Math.sin(rad(rangeDeg));
  // Right side down: "up" leans towards the screen's left. Far edge down: it
  // leans towards the bottom of the screen.
  return { x: clamp(-dot(up, right) / scale), y: clamp(-dot(up, upScreen) / scale) };
}

type OrientationEventWithPermission = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<"granted" | "denied"> };

export function useTilt(layer: RefObject<HTMLElement | null>, active: boolean) {
  const target = useRef<Tilt>({ x: 0, y: 0 });
  const keys = useRef(new Set<string>());
  const drag = useRef<{ x0: number; y0: number; tilt: Tilt } | null>(null);
  const device = useRef<{ neutral: Vec | null; tilt: Tilt } | null>(null);
  const [deviceOn, setDeviceOn] = useState(false);

  const update = useCallback(() => {
    if (device.current) { target.current = device.current.tilt; return; }
    const k = keys.current;
    const kx = (k.has("ArrowRight") || k.has("d") ? 1 : 0) - (k.has("ArrowLeft") || k.has("a") ? 1 : 0);
    const ky = (k.has("ArrowUp") || k.has("w") ? 1 : 0) - (k.has("ArrowDown") || k.has("s") ? 1 : 0);
    target.current = kx || ky ? { x: kx, y: ky } : drag.current?.tilt ?? { x: 0, y: 0 };
  }, []);

  useEffect(() => {
    const el = layer.current;
    if (!el || !active) return;
    const KEYS = new Set(["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "w", "a", "s", "d"]);
    const down = (e: KeyboardEvent) => {
      const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
      if (!KEYS.has(key)) return;
      e.preventDefault(); // arrows would scroll the page
      keys.current.add(key);
      update();
    };
    const up = (e: KeyboardEvent) => { keys.current.delete(e.key.length === 1 ? e.key.toLowerCase() : e.key); update(); };
    const blur = () => { keys.current.clear(); update(); };
    const press = (e: PointerEvent) => {
      el.setPointerCapture(e.pointerId);
      drag.current = { x0: e.clientX, y0: e.clientY, tilt: { x: 0, y: 0 } };
    };
    const move = (e: PointerEvent) => {
      if (!drag.current) return;
      drag.current.tilt = { x: clamp((e.clientX - drag.current.x0) / DRAG_RANGE), y: clamp(-(e.clientY - drag.current.y0) / DRAG_RANGE) };
      update();
    };
    const release = () => { drag.current = null; update(); };
    el.addEventListener("keydown", down);
    el.addEventListener("keyup", up);
    el.addEventListener("blur", blur);
    el.addEventListener("pointerdown", press);
    el.addEventListener("pointermove", move);
    el.addEventListener("pointerup", release);
    el.addEventListener("pointercancel", release);
    return () => {
      el.removeEventListener("keydown", down);
      el.removeEventListener("keyup", up);
      el.removeEventListener("blur", blur);
      el.removeEventListener("pointerdown", press);
      el.removeEventListener("pointermove", move);
      el.removeEventListener("pointerup", release);
      el.removeEventListener("pointercancel", release);
      blur();
    };
  }, [layer, active, update]);

  // Phone tilt. The first reading becomes "level", so the phone can be held at
  // any comfortable angle; turning the phone (portrait ↔ landscape) starts
  // a new "level", since the old one no longer means anything.
  useEffect(() => {
    if (!deviceOn) return;
    device.current = { neutral: null, tilt: { x: 0, y: 0 } };
    const screenAngle = () => screen.orientation?.angle ?? Number((window as { orientation?: number }).orientation ?? 0);
    const onOrientation = (e: DeviceOrientationEvent) => {
      if (e.beta == null || e.gamma == null || !device.current) return;
      const d = device.current;
      const up = upOnScreen(e.beta, e.gamma, screenAngle());
      d.neutral ??= up;
      d.tilt = tiltFrom(up, d.neutral);
      update();
    };
    const onRotate = () => { if (device.current) device.current.neutral = null; };
    window.addEventListener("deviceorientation", onOrientation);
    screen.orientation?.addEventListener("change", onRotate);
    window.addEventListener("orientationchange", onRotate);
    return () => {
      window.removeEventListener("deviceorientation", onOrientation);
      screen.orientation?.removeEventListener("change", onRotate);
      window.removeEventListener("orientationchange", onRotate);
      device.current = null;
      update();
    };
  }, [deviceOn, update]);

  /** Must run inside a tap: iOS only asks for permission in response to one. */
  const enableDevice = useCallback(async () => {
    const DOE = (typeof DeviceOrientationEvent !== "undefined" ? DeviceOrientationEvent : undefined) as OrientationEventWithPermission | undefined;
    if (!DOE) return false;
    if (DOE.requestPermission && (await DOE.requestPermission()) !== "granted") return false;
    setDeviceOn(true);
    return true;
  }, []);

  const recalibrate = useCallback(() => { if (device.current) device.current.neutral = null; }, []);
  const disableDevice = useCallback(() => setDeviceOn(false), []);

  return { target, deviceOn, enableDevice, disableDevice, recalibrate };
}
Show code · src/app/marble/levels.ts
// Marble Maze levels, drawn as text. Plain data, so tests can check that
// every level is well formed and actually solvable.
//   #  wall      .  floor     O  hole (the marble falls through)
//   S  start     G  goal
// Each character is one 1 × 1 cell; row 0 is the far side of the board.

export type Level = { name: string; par: number; grid: string[] };

export const LEVELS: Level[] = [
  {
    name: "First roll",
    par: 20,
    grid: [
      "#########",
      "#S......#",
      "#######.#",
      "#.......#",
      "#.#######",
      "#.......#",
      "#######.#",
      "#G......#",
      "#########",
    ],
  },
  {
    name: "Mind the gaps",
    par: 30,
    grid: [
      "###########",
      "#S....O...#",
      "#..O......#",
      "#.....O..##",
      "##O.......#",
      "#....O..O.#",
      "#.O.......#",
      "#....##O..#",
      "#.O..#G...#",
      "#....#....#",
      "###########",
    ],
  },
  {
    name: "The spiral",
    par: 45,
    grid: [
      "###########",
      "#S........#",
      "#########.#",
      "#.......#.#",
      "#.#####.#.#",
      "#.#G.O#.#.#",
      "#.#.###.#.#",
      "#.#.....#.#",
      "#.#######.#",
      "#.........#",
      "###########",
    ],
  },
];

export type Cell = "#" | "." | "O" | "S" | "G";
export const cellAt = (level: Level, row: number, col: number) => level.grid[row]?.[col] as Cell | undefined;

/** Grid position → board coordinates (the board is centred on 0,0). */
export function toBoard(level: Level, row: number, col: number) {
  const w = level.grid[0].length;
  const h = level.grid.length;
  return { x: col - (w - 1) / 2, z: row - (h - 1) / 2 };
}

export function find(level: Level, cell: "S" | "G") {
  for (let row = 0; row < level.grid.length; row++) {
    const col = level.grid[row].indexOf(cell);
    if (col >= 0) return { row, col };
  }
  throw new Error(`${level.name} has no ${cell}`);
}

type Pos = { row: number; col: number };

/**
 * Breadth-first search over walkable cells: the shortest route from S to G,
 * or null if there's none. (The tests' autopilot steers along it, too.)
 */
export function shortestPath(level: Level): Pos[] | null {
  const start = find(level, "S");
  const goal = find(level, "G");
  const from = new Map<string, Pos | null>([[`${start.row},${start.col}`, null]]);
  const queue: Pos[] = [start];
  while (queue.length) {
    const here = queue.shift()!;
    if (here.row === goal.row && here.col === goal.col) {
      const path: Pos[] = [];
      for (let p: Pos | null = here; p; p = from.get(`${p.row},${p.col}`) ?? null) path.unshift(p);
      return path;
    }
    for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
      const next = { row: here.row + dr, col: here.col + dc };
      const cell = cellAt(level, next.row, next.col);
      const key = `${next.row},${next.col}`;
      if (!cell || cell === "#" || cell === "O" || from.has(key)) continue;
      from.set(key, here);
      queue.push(next);
    }
  }
  return null;
}

export const solvable = (level: Level) => shortestPath(level) !== null;

// 1–5 stars for the Rive rating: par or better is 5.
export function stars(seconds: number, par: number) {
  const ratio = seconds / par;
  return ratio <= 1 ? 5 : ratio <= 1.3 ? 4 : ratio <= 1.7 ? 3 : ratio <= 2.5 ? 2 : 1;
}