Stacker

One button, one tower: drop each slab exactly on the last.

What it is

A fast one-button game built to feel good on a phone. There's no physics engine and no WebAssembly: the rules are a few lines of geometry (stack.ts), so it loads quickly and runs smoothly. When a slab lands off-centre, the overlap stays and the overhang is cut off and tumbles away; land within a few hundredths of a unit and it snaps into a Perfect, and a streak of them grows the slab back. An orthographic camera (no perspective) gives the clean isometric look; GSAP lifts it as the tower grows and drives the score pops and menus.

When to use it

  • Quick, replayable games for mobile
  • One-input controls that work with tap, click or keyboard alike
  • Seeing how little code a satisfying game loop needs
Official docs ↗

Play

Loading game…

The slab's position is kept in a ref and updated every frame without re-rendering React; React state changes only when a slab is dropped. Only the top 40 slabs are drawn, since older ones are out of view. Your best height is kept in this browser only.

Show code · src/app/stacker/stack.ts
// Stacker's rules, as plain functions: no Three.js, no React, so they're
// unit-tested. A slab is a box seen from above: its centre (x, z) and its
// size (w along x, d along z). Every slab is the same height.

export type Slab = { x: number; z: number; w: number; d: number };
export type Axis = "x" | "z";

export const HEIGHT = 0.3;
export const BASE: Slab = { x: 0, z: 0, w: 3, d: 3 };
/** How close counts as "perfect": the slab snaps exactly onto the one below. */
export const PERFECT = 0.07;
/** After this many perfect drops in a row, each perfect grows the slab back a little. */
export const STREAK_TO_GROW = 3;
export const GROW = 0.15;

export type Drop =
  | { kind: "miss" }
  | { kind: "perfect"; placed: Slab }
  | { kind: "cut"; placed: Slab; chunk: Slab };

const size = (s: Slab, axis: Axis) => (axis === "x" ? s.w : s.d);
const centre = (s: Slab, axis: Axis) => (axis === "x" ? s.x : s.z);

/**
 * Drop `moving` (sliding along `axis`) onto `top`. Whatever overlaps stays;
 * whatever hangs over is cut off and falls (`chunk`). No overlap: game over.
 */
export function drop(top: Slab, moving: Slab, axis: Axis): Drop {
  const offset = centre(moving, axis) - centre(top, axis);
  const len = size(top, axis);
  if (Math.abs(offset) <= PERFECT) return { kind: "perfect", placed: { ...moving, x: top.x, z: top.z } };
  const overlap = len - Math.abs(offset);
  if (overlap <= 0) return { kind: "miss" };
  // The kept part sits between the overlapping edges; the chunk is the rest,
  // on the side the slab overshot.
  const keptCentre = centre(top, axis) + offset / 2;
  const chunkLen = Math.abs(offset);
  const chunkCentre = keptCentre + Math.sign(offset) * (overlap / 2 + chunkLen / 2);
  const placed = axis === "x" ? { ...moving, x: keptCentre, w: overlap } : { ...moving, z: keptCentre, d: overlap };
  const chunk = axis === "x" ? { ...moving, x: chunkCentre, w: chunkLen } : { ...moving, z: chunkCentre, d: chunkLen };
  return { kind: "cut", placed, chunk };
}

/** A streak of perfects earns a little size back, up to the base size. */
export function grow(slab: Slab, axis: Axis): Slab {
  return axis === "x" ? { ...slab, w: Math.min(BASE.w, slab.w + GROW) } : { ...slab, d: Math.min(BASE.d, slab.d + GROW) };
}

/** Slab speed in units per second: brisk from the start, faster as the tower grows. */
export const speed = (level: number) => Math.min(7, 2.6 + level * 0.09);

/** The sliding slab's position: a ping-pong between -range and +range around the tower. */
export function slide(t: number, range: number) {
  const period = 4 * range; // there and back, in distance units
  const p = ((t % period) + period) % period;
  return p < 2 * range ? -range + p : 3 * range - p;
}
Show code · src/app/stacker/Stacker.tsx
"use client";

import { useCallback, useRef, useState } 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 { type Axis, BASE, drop, grow, HEIGHT, type Slab, slide, speed, STREAK_TO_GROW } from "./stack";
import { type Chunk, type Live, type Placed, StackerScene } from "./StackerScene";

type Screen = "intro" | "playing" | "over";
const RANGE = 4.2;
const BEST_KEY = "motion-lab:stacker-best";
const readBest = () => { try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch { return 0; } };
const writeBest = (n: number) => { try { localStorage.setItem(BEST_KEY, String(n)); } catch {} };
const hueAt = (level: number) => (200 + level * 9) % 360;

const fresh = (): Live => ({ top: BASE, moving: { ...BASE }, axis: "x", t: 0, level: 1, running: false, cameraY: 0 });

export function Stacker() {
  const { reduced } = useMotion();
  const root = useRef<HTMLDivElement>(null);
  const layerRef = useRef<HTMLDivElement>(null);
  const live = useRef<Live>(fresh());
  const ids = useRef(1);
  const streak = useRef(0);

  const [screen, setScreen] = useState<Screen>("intro");
  const [placed, setPlaced] = useState<Placed[]>([{ ...BASE, id: 0, level: 0, hue: hueAt(0) }]);
  const [chunks, setChunks] = useState<Chunk[]>([]);
  const [flash, setFlash] = useState<{ id: number; slab: Slab; level: number } | null>(null);
  const [score, setScore] = useState(0);
  const [best, setBest] = useState(0);
  const [newBest, setNewBest] = useState(false);

  // Called every frame by the scene: slide the slab. Lives here because this
  // component owns the game; the scene only draws it.
  const step = useCallback((dt: number) => {
    const g = live.current;
    if (g.running) g.t += dt * speed(g.level);
    const pos = slide(g.t, RANGE);
    if (g.axis === "x") g.moving.x = g.top.x + pos;
    else g.moving.z = g.top.z + pos;
    // The slab's distance from a perfect drop, for the test autopilot.
    layerRef.current?.setAttribute("data-offset", (g.axis === "x" ? g.moving.x - g.top.x : g.moving.z - g.top.z).toFixed(3));
  }, []);

  const pop = (text: string) => {
    const el = root.current?.querySelector<HTMLElement>(".pop");
    if (!el) return;
    el.textContent = text;
    gsap.fromTo(el, { autoAlpha: 1, y: 0, scale: reduced ? 1 : 0.7 }, { autoAlpha: 0, y: reduced ? 0 : -40, scale: 1, duration: 0.8, ease: "power2.out", overwrite: true });
  };

  const start = () => {
    live.current = { ...fresh(), running: true };
    streak.current = 0;
    setPlaced([{ ...BASE, id: ids.current++, level: 0, hue: hueAt(0) }]);
    setChunks([]);
    setFlash(null);
    setScore(0);
    setNewBest(false);
    setBest(readBest());
    setScreen("playing");
    requestAnimationFrame(() => layerRef.current?.focus({ preventScroll: true }));
  };

  const dropNow = () => {
    const g = live.current;
    if (!g.running) return;
    // Copy what this drop needs out of the live game first. State updaters
    // run later, after the lines below have moved g on to the next slab: an
    // updater that read g.level would put the slab one level too high, inside
    // the next sliding slab (which looked like slabs passing through each
    // other, with flickering faces).
    const level = g.level;
    const moving = { ...g.moving };
    const result = drop(g.top, moving, g.axis);
    const hue = hueAt(level);
    if (result.kind === "miss") {
      // The whole slab falls, and that's the game.
      g.running = false;
      // The whole slab falls, away from the tower in the direction it was sliding.
      const away = Math.sign((g.axis === "x" ? moving.x - g.top.x : moving.z - g.top.z) || 1);
      const fallen: Chunk = { ...moving, id: ids.current++, level, hue, out: g.axis === "x" ? { x: away, z: 0 } : { x: 0, z: away } };
      setChunks((c) => [...c, fallen]);
      const height = level - 1;
      if (height > readBest()) { writeBest(height); setNewBest(true); }
      setBest(Math.max(height, readBest()));
      setScreen("over");
      return;
    }
    let slab = result.placed;
    if (result.kind === "perfect") {
      streak.current += 1;
      if (streak.current >= STREAK_TO_GROW) slab = grow(grow(slab, "x"), "z");
      setFlash({ id: ids.current++, slab, level });
      pop(streak.current > 1 ? `Perfect ×${streak.current}` : "Perfect!");
    } else {
      streak.current = 0;
      const away = Math.sign(g.axis === "x" ? result.chunk.x - result.placed.x : result.chunk.z - result.placed.z);
      const out = g.axis === "x" ? { x: away, z: 0 } : { x: 0, z: away };
      const piece: Chunk = { ...result.chunk, id: ids.current++, level, hue, out };
      setChunks((c) => [...c, piece]);
    }
    const landed: Placed = { ...slab, id: ids.current++, level, hue };
    setPlaced((p) => [...p, landed]);
    setScore(level);
    // Next slab: same size as the one just placed, along the other axis,
    // starting from the far end.
    const axis: Axis = g.axis === "x" ? "z" : "x";
    g.top = slab;
    g.moving = { ...slab };
    g.axis = axis;
    g.t = 0;
    g.level += 1;
    gsap.to(g, { cameraY: (g.level - 2) * HEIGHT, duration: reduced ? 0 : 0.4, ease: "power2.out", overwrite: true });
  };

  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === " " || e.key === "Enter") { e.preventDefault(); dropNow(); }
  };

  const onChunkDone = useCallback((id: number) => setChunks((c) => c.filter((x) => x.id !== id)), []);

  // Menu panels pop in with a split-letter title.
  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.45, ease: "back.out(1.6)" });
    const title = panel.querySelector(".panel-title");
    if (title && !reduced) tl.from(SplitText.create(title, { type: "chars", aria: "auto" }).chars, { yPercent: 110, autoAlpha: 0, stagger: 0.03, duration: 0.45, ease: "power3.out" }, 0.1);
  }, { scope: root, dependencies: [screen], revertOnUpdate: true });

  const hue = hueAt(score + 1); // the level being played is always one above the score

  const overlay = (
    <div className="pointer-events-none absolute inset-0 text-white">
      {screen === "playing" && (
        <div
          ref={layerRef}
          className="pointer-events-auto absolute inset-0 cursor-pointer touch-manipulation select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-white"
          tabIndex={0}
          role="application"
          aria-label="Press Space or tap to drop the slab"
          onPointerDown={dropNow}
          onKeyDown={onKey}
        />
      )}
      <div className="absolute inset-x-0 top-0 flex items-start justify-between px-4 pt-3 text-sm">
        <span className="text-white/80">Best {Math.max(best, score)}</span>
        <span className="text-5xl font-black tabular-nums drop-shadow" aria-live="polite" aria-label={`Height ${score}`}>{score}</span>
        <span className="w-12" />
      </div>
      <span className="pop invisible absolute top-[22%] left-1/2 -translate-x-1/2 text-2xl font-bold drop-shadow" aria-hidden="true" />
      {screen !== "playing" && (
        <div className="pointer-events-auto absolute inset-0 grid place-items-center bg-black/30 p-4">
          <div className="panel w-full max-w-xs rounded-2xl border border-white/15 bg-[#17161f]/95 p-6 text-center shadow-2xl">
            <h3 className="panel-title mt-0 mb-2 overflow-hidden text-3xl font-bold">{screen === "intro" ? "Stacker" : `Height ${score}`}</h3>
            <p className="text-sm text-white/80">
              {screen === "intro"
                ? "Tap, click or press Space to drop each slab on the tower. Overhangs fall off; line one up exactly for a Perfect."
                : newBest ? "A new best!" : `Best: ${best}`}
            </p>
            <Button className="mt-2" onClick={start}>{screen === "intro" ? "Play" : "Play again"}</Button>
          </div>
        </div>
      )}
    </div>
  );

  return (
    // data-top: the level of the highest placed slab, which must always equal
    // the score (a regression check for the bug described in dropNow).
    <div ref={root} data-top={placed.at(-1)?.level}>
      <ThreeCanvas orthographic camera={{ zoom: 50, position: [9, 9, 9], near: -50, far: 200 }} className="h-[480px] sm:h-[560px]" label={`Stacker: a tower ${score} slabs high`} overlay={overlay}>
        {() => (
          <StackerScene live={live} step={step} placed={placed} chunks={chunks} flash={flash} hue={hue} reduced={reduced} onChunkDone={onChunkDone} />
        )}
      </ThreeCanvas>
      <p className="text-sm text-muted">
        Tap or click anywhere on the game, or press Space or Enter once it has focus.
        {reduced && " Reduced motion: falling pieces vanish instead of tumbling and the camera jumps rather than glides; the slab still slides, since that's the game."}
      </p>
    </div>
  );
}
Show code · src/app/stacker/StackerScene.tsx
"use client";

import { useFrame } from "@react-three/fiber";
import { useEffect, useRef, type RefObject } from "react";
import { type Mesh, OrthographicCamera } from "three";
import { gsap } from "@/lib/gsap";
import { type Axis, HEIGHT, type Slab } from "./stack";

export type Placed = Slab & { id: number; level: number; hue: number };
/** A falling piece; `out` is the direction away from the tower (x or z, ±1). */
export type Chunk = Slab & { id: number; level: number; hue: number; out: { x: number; z: number } };
/** The live game, mutated every frame: refs, not React state, so no re-renders. */
export type Live = { top: Slab; moving: Slab; axis: Axis; t: number; level: number; running: boolean; cameraY: number };

// Three.js parses only the comma form of hsl(): "hsl(200 70% 62%)" silently becomes white.
export const colour = (hue: number, light = 62) => `hsl(${hue}, 70%, ${light}%)`;

function SlabMesh({ slab, level, hue }: { slab: Slab; level: number; hue: number }) {
  return (
    <mesh position={[slab.x, level * HEIGHT + HEIGHT / 2, slab.z]}>
      <boxGeometry args={[slab.w, HEIGHT, slab.d]} />
      <meshLambertMaterial color={colour(hue)} />
    </mesh>
  );
}

// An overhang that was cut off. It falls the way a real one would: pushed a
// little away from the tower, tipping over the cut edge as it drops. (Spinning
// it about its own centre made half of it swing into the slab it was cut
// from, and it started flush against that slab's face, which flickered.)
function Falling({ chunk, reduced, onDone }: { chunk: Chunk; reduced: boolean; onDone: (id: number) => void }) {
  const mesh = useRef<Mesh>(null);
  const v = useRef({ y: 0, age: 0 });
  useFrame((_, dt) => {
    const m = mesh.current;
    if (!m) return;
    const s = v.current;
    s.age += dt;
    if (reduced) {
      if (s.age > 0.3) onDone(chunk.id);
      return;
    }
    s.y -= 18 * dt;
    m.position.y += s.y * dt;
    m.position.x += chunk.out.x * 2.5 * dt;
    m.position.z += chunk.out.z * 2.5 * dt;
    // Tip outward over the cut edge: about z when moving along x, about x along z.
    const tip = 3.2 * dt;
    m.rotation.z -= chunk.out.x * tip;
    m.rotation.x += chunk.out.z * tip;
    if (s.age > 2.5) onDone(chunk.id);
  });
  return (
    // Starts a hair outside the slab it came from, so no faces coincide.
    <mesh ref={mesh} position={[chunk.x + chunk.out.x * 0.02, chunk.level * HEIGHT + HEIGHT / 2, chunk.z + chunk.out.z * 0.02]}>
      <boxGeometry args={[chunk.w, HEIGHT, chunk.d]} />
      <meshLambertMaterial color={colour(chunk.hue)} />
    </mesh>
  );
}

// A perfect drop: a bright outline that swells and fades around the slab.
function PerfectFlash({ slab, level, reduced }: { slab: Slab; level: number; reduced: boolean }) {
  const mesh = useRef<Mesh>(null);
  useEffect(() => {
    if (!mesh.current) return;
    const tw = gsap.fromTo(mesh.current.scale, { x: 1, y: 1, z: 1 }, { x: 1.35, y: 1, z: 1.35, duration: reduced ? 0.01 : 0.5, ease: "power2.out" });
    const fade = gsap.fromTo(mesh.current.material, { opacity: 0.9 }, { opacity: 0, duration: reduced ? 0.3 : 0.5 });
    return () => { tw.kill(); fade.kill(); };
  }, [reduced]);
  return (
    <mesh ref={mesh} position={[slab.x, (level + 1) * HEIGHT + 0.01, slab.z]} rotation-x={-Math.PI / 2}>
      <planeGeometry args={[slab.w + 0.2, slab.d + 0.2]} />
      <meshBasicMaterial color="#ffffff" transparent depthWrite={false} />
    </mesh>
  );
}

export function StackerScene({ live, step, placed, chunks, flash, hue, reduced, onChunkDone }: {
  live: RefObject<Live>;
  /** Advances the live game by dt seconds (owned by the parent, which mutates it). */
  step: (dt: number) => void;
  placed: Placed[];
  chunks: Chunk[];
  flash: { id: number; slab: Slab; level: number } | null;
  hue: number;
  reduced: boolean;
  onChunkDone: (id: number) => void;
}) {
  const moving = useRef<Mesh>(null);

  useFrame(({ camera, size }, dt) => {
    step(dt);
    const g = live.current;
    const m = moving.current;
    if (m) {
      m.visible = g.running;
      m.position.set(g.moving.x, g.level * HEIGHT + HEIGHT / 2, g.moving.z);
      m.scale.set(g.moving.w, 1, g.moving.d);
    }
    // The camera follows the tower up (cameraY is tweened by GSAP on each drop).
    camera.position.set(9, 9 + g.cameraY, 9);
    camera.lookAt(0, g.cameraY, 0);
    if (camera instanceof OrthographicCamera) {
      const zoom = Math.min(size.width, size.height) / 9.5;
      if (Math.abs(camera.zoom - zoom) > 0.01) { camera.zoom = zoom; camera.updateProjectionMatrix(); }
    }
  });

  return (
    <>
      {/* A dark, muted backdrop a little round the colour wheel from the slabs. */}
      <color attach="background" args={[`hsl(${(hue + 40) % 360}, 28%, 13%)`]} />
      <ambientLight intensity={0.9} />
      <directionalLight position={[5, 10, 3]} intensity={2} />
      {/* Only the top of the tower is ever in view: older slabs aren't drawn. */}
      {placed.slice(-40).map((p) => <SlabMesh key={p.id} slab={p} level={p.level} hue={p.hue} />)}
      <mesh ref={moving}>
        <boxGeometry args={[1, HEIGHT, 1]} />
        <meshLambertMaterial color={colour(hue)} />
      </mesh>
      {chunks.map((c) => <Falling key={c.id} chunk={c} reduced={reduced} onDone={onChunkDone} />)}
      {flash && <PerfectFlash key={flash.id} slab={flash.slab} level={flash.level} reduced={reduced} />}
    </>
  );
}