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
Play
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} />}
</>
);
}