Starfield Runner
Fly through an endless asteroid field that keeps getting faster.
What it is
A fast arcade game with no physics engine and no WebAssembly, built to run smoothly on a phone. All the asteroids are one InstancedMesh (a single draw call), and they're never created or destroyed while you play: each one that passes the ship is moved back to the far end with a new position and size (object pooling), so there's no garbage-collection stutter. Collisions are a distance check at the moment a rock crosses the ship; passing close earns a bonus. The field comes from a seeded random generator, so the rules are repeatable in tests. GSAP drives the score pops, menus and crash shake; the camera banks into turns and widens when you boost.
When to use it
- Endless and procedural games
- Thousands of moving objects on modest hardware (instancing, pooling)
- Speed effects: field of view, streaks, camera roll
Play
Steering reuses Marble Maze's input code: keys, drag, or phone tilt (with the landscape-safe tilt maths). The game's state lives in a ref and is updated once per frame; the score and boost bar are written straight to the page, so React doesn't re-render while you fly. Your best score is kept in this browser only.
Show code · src/app/runner/field.ts
// Starfield Runner's rules, as plain functions (unit-tested): where asteroids
// appear, when a pass is a hit or a near miss, how fast things go. The ship
// flies at z = 0 along -z; asteroids come towards it.
export type Rock = { x: number; y: number; z: number; r: number };
export type Ship = { x: number; y: number };
export const SHIP_RADIUS = 0.45;
/** The ship can move within ±BOUNDS.x by ±BOUNDS.y. */
export const BOUNDS = { x: 5, y: 3 };
/** Rocks appear up to FAR units ahead and are recycled once they pass the ship. */
export const FAR = 140;
export const NEAR_MISS = 1.1; // extra clearance that still counts as close
/**
* How many rocks are in play at once (recycled, never re-created). Tuned by
* simulation: at 260 a simple dodging autopilot survived a median 4 s, at 90
* about 15 s; a ship that doesn't move lasts about 2 s at any density.
*/
export const ROCKS = 90;
/** A small, fast, seedable random generator (mulberry32): same seed, same field. */
export function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** A new rock somewhere in the corridor, `z` units ahead (negative z). */
export function spawn(random: () => number, z: number): Rock {
return {
x: (random() * 2 - 1) * (BOUNDS.x + 1.5),
y: (random() * 2 - 1) * (BOUNDS.y + 1),
z,
r: 0.35 + random() * random() * 1.4, // mostly small, now and then big
};
}
/** The opening field: rocks spread evenly from a safe distance out to FAR. */
export function field(seed: number, count: number): Rock[] {
const random = rng(seed);
return Array.from({ length: count }, (_, i) => spawn(random, -30 - (i / count) * (FAR - 30)));
}
/**
* As a rock crosses the ship's plane: a hit if the two spheres touch, a near
* miss if it passed within NEAR_MISS of touching, otherwise nothing.
*/
export function classify(ship: Ship, rock: Rock): "hit" | "near" | null {
const gap = Math.hypot(ship.x - rock.x, ship.y - rock.y) - (SHIP_RADIUS + rock.r);
return gap < 0 ? "hit" : gap < NEAR_MISS ? "near" : null;
}
/** Cruising speed in units per second after `t` seconds: quicker and quicker, to a cap. */
export const cruise = (t: number) => Math.min(70, 22 + t * 0.9);
export const BOOST = 1.6;
/** Score: distance flown, with boosting worth double, plus near misses. */
export const NEAR_MISS_POINTS = 50;
Show code · src/app/runner/Runner.tsx
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useMotion } from "@/components/motion/MotionProvider";
import { ThreeCanvas } from "@/components/three/ThreeCanvas";
import { Leaderboard } from "@/components/Leaderboard";
import { Button } from "@/components/ui";
import { gsap, SplitText, useGSAP } from "@/lib/gsap";
import { useTilt } from "../marble/tilt";
import { BOOST, BOUNDS, classify, cruise, FAR, field, NEAR_MISS_POINTS, ROCKS, rng, spawn } from "./field";
import { type Live, RunnerScene } from "./RunnerScene";
type Screen = "intro" | "playing" | "over";
const BEST_KEY = "motion-lab:runner-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 SHIP_SPEED = 9; // units per second at full input
const scoreOf = (g: Live) => Math.floor(g.distance / 2) + g.near * NEAR_MISS_POINTS;
const fresh = (seed: number): Live => ({
running: false, t: 0, speed: 0, boosting: false, ship: { x: 0, y: -0.5 }, vel: { x: 0, y: 0 },
rocks: field(seed, ROCKS), shake: 0, distance: 0, near: 0, energy: 1,
});
export function Runner() {
const { reduced } = useMotion();
const root = useRef<HTMLDivElement>(null);
const layerRef = useRef<HTMLDivElement>(null);
const scoreRef = useRef<HTMLSpanElement>(null);
const speedRef = useRef<HTMLSpanElement>(null);
const energyRef = useRef<HTMLSpanElement>(null);
const live = useRef<Live>(fresh(1));
const random = useRef(rng(1));
const boostKeys = useRef(new Set<string>());
const boostButton = useRef(false);
const [screen, setScreen] = useState<Screen>("intro");
const [final, setFinal] = useState({ score: 0, best: 0, newBest: false });
const tilt = useTilt(layerRef, screen === "playing");
const canTilt = useSyncExternalStore(
() => () => {},
() => typeof DeviceOrientationEvent !== "undefined" && matchMedia("(pointer: coarse)").matches,
() => false,
);
const pop = useCallback((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.8 }, { autoAlpha: 0, y: reduced ? 0 : -30, scale: 1, duration: 0.7, ease: "power2.out", overwrite: true });
}, [reduced]);
const crash = useCallback(() => {
const g = live.current;
g.running = false;
const score = scoreOf(g);
const best = readBest();
if (score > best) writeBest(score);
setFinal({ score, best: Math.max(score, best), newBest: score > best });
// A short, sharp shake (none with reduced motion), then the results.
if (!reduced) gsap.fromTo(g, { shake: 1 }, { shake: 0, duration: 0.6, ease: "power2.out" });
setScreen("over");
}, [reduced]);
// One frame of the game, called by the scene: speed, boost, steering, and
// the rocks coming past. Lives here because this component owns the game.
const step = useCallback((dt: number) => {
const g = live.current;
if (!g.running) { g.speed = Math.max(0, g.speed - 30 * dt); return; }
g.t += dt;
const wantBoost = boostKeys.current.size > 0 || boostButton.current;
g.boosting = wantBoost && g.energy > 0.02;
g.energy = Math.max(0, Math.min(1, g.energy + (g.boosting ? -0.4 : 0.12) * dt));
g.speed = cruise(g.t) * (g.boosting ? BOOST : 1);
g.distance += g.speed * dt * (g.boosting ? 2 : 1);
// Steer: the velocity eases towards the input, the ship stays in bounds.
const k = Math.min(1, dt * 10);
g.vel.x += (tilt.target.current.x * SHIP_SPEED - g.vel.x) * k;
g.vel.y += (tilt.target.current.y * SHIP_SPEED - g.vel.y) * k;
g.ship.x = Math.max(-BOUNDS.x, Math.min(BOUNDS.x, g.ship.x + g.vel.x * dt));
g.ship.y = Math.max(-BOUNDS.y, Math.min(BOUNDS.y, g.ship.y + g.vel.y * dt));
for (let i = 0; i < g.rocks.length; i++) {
const r = g.rocks[i];
const before = r.z;
r.z += g.speed * dt;
if (before < 0 && r.z >= 0) {
// The rock crosses the ship's plane right now: hit, close, or clear?
const verdict = classify(g.ship, r);
if (verdict === "hit") { crash(); return; }
if (verdict === "near") { g.near += 1; pop(`Close call +${NEAR_MISS_POINTS}`); }
}
// Behind the camera: recycle it to the far end (object pooling).
if (r.z > 8) g.rocks[i] = spawn(random.current, -FAR - random.current() * 20);
}
layerRef.current?.setAttribute("data-ship", `${g.ship.x.toFixed(2)},${g.ship.y.toFixed(2)}`);
}, [crash, pop, tilt.target]);
// The HUD reads the live game every frame, without React renders.
useEffect(() => {
let raf = 0;
const tick = () => {
const g = live.current;
if (scoreRef.current) scoreRef.current.textContent = String(scoreOf(g));
if (speedRef.current) speedRef.current.textContent = String(Math.round(g.speed * 3.6));
if (energyRef.current) energyRef.current.style.transform = `scaleX(${g.energy})`;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const start = () => {
const seed = Math.floor(Math.random() * 1e9);
random.current = rng(seed);
live.current = { ...fresh(seed), running: true };
setScreen("playing");
requestAnimationFrame(() => layerRef.current?.focus({ preventScroll: true }));
pop("Go!");
};
// Boost: Space or Shift, held.
const isBoostKey = (e: React.KeyboardEvent) => e.key === " " || e.key === "Shift";
const onKeyDown = (e: React.KeyboardEvent) => { if (isBoostKey(e)) { e.preventDefault(); boostKeys.current.add(e.key); } };
const onKeyUp = (e: React.KeyboardEvent) => { if (isBoostKey(e)) boostKeys.current.delete(e.key); };
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 useDeviceTilt = async () => {
if (!(await tilt.enableDevice())) alert("Tilt isn't available: the browser didn't allow motion sensors.");
};
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-grab touch-none select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-white active:cursor-grabbing"
tabIndex={0}
role="application"
aria-label="Steer with the arrow keys or WASD, or drag; hold Space or Shift to boost"
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => boostKeys.current.clear()}
/>
)}
<div className="absolute inset-x-0 top-0 flex items-start justify-between gap-2 bg-linear-to-b from-black/50 to-transparent px-4 pt-3 pb-6 text-sm">
<span className="tabular-nums"><span ref={speedRef}>0</span> km/h</span>
<span className="text-3xl font-black tabular-nums drop-shadow"><span ref={scoreRef}>0</span></span>
<span className="flex w-24 flex-col items-end gap-1 text-xs text-white/80">
Boost
<span className="block h-1.5 w-full overflow-hidden rounded-full bg-white/20" aria-hidden="true">
<span ref={energyRef} className="block h-full origin-left bg-[#3ee0c5]" />
</span>
</span>
</div>
<span className="pop invisible absolute top-[24%] left-1/2 -translate-x-1/2 text-xl font-bold whitespace-nowrap drop-shadow" aria-hidden="true" />
{screen === "playing" && (
<button
type="button"
className="pointer-events-auto absolute right-4 bottom-4 size-16 touch-none rounded-full border-2 border-white/60 bg-white/10 text-sm font-bold text-white select-none active:bg-white/30"
onPointerDown={() => { boostButton.current = true; }}
onPointerUp={() => { boostButton.current = false; }}
onPointerLeave={() => { boostButton.current = false; }}
onPointerCancel={() => { boostButton.current = false; }}
>
Boost
</button>
)}
{screen !== "playing" && (
<div className="pointer-events-auto absolute inset-0 flex overflow-y-auto bg-black/35 p-4">
<div className="panel m-auto 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" ? "Starfield Runner" : `Score ${final.score}`}</h3>
<p className="text-sm text-white/80">
{screen === "intro"
? "Dodge the asteroids. It keeps getting faster. Boost for double points, and fly close for bonuses."
: final.newBest ? "A new best!" : `Best: ${final.best}`}
</p>
<Button className="mt-2" onClick={start}>{screen === "intro" ? "Launch" : "Fly again"}</Button>
{screen === "over" && <Leaderboard game="runner" value={final.score} />}
</div>
</div>
)}
</div>
);
return (
<div ref={root}>
<ThreeCanvas camera={{ position: [0, 1.5, 6.5], fov: 68, near: 0.1, far: FAR * 1.5 }} className="h-[480px] sm:h-[560px]" label="Starfield Runner: a small ship flying through an asteroid field" overlay={overlay}>
{() => <RunnerScene live={live} step={step} />}
</ThreeCanvas>
<div className="mb-2 flex flex-wrap items-center gap-2">
{canTilt && (tilt.deviceOn
? <><Button variant="ghost" onClick={tilt.recalibrate}>Recalibrate</Button><Button variant="ghost" onClick={tilt.disableDevice}>Stop using tilt</Button></>
: <Button onClick={useDeviceTilt}>Steer by tilting your phone</Button>)}
<span className="text-sm text-muted">Keyboard: arrow keys or WASD to steer, hold Space or Shift to boost. Touch: drag to steer, hold Boost.</span>
</div>
{reduced && <p className="text-sm text-muted">Reduced motion: no screen shake and plain score pops; the flight itself still moves, since that's the game.</p>}
</div>
);
}
Show code · src/app/runner/RunnerScene.tsx
"use client";
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef, type RefObject } from "react";
import { BufferAttribute, BufferGeometry, type Group, type InstancedMesh, Object3D, PerspectiveCamera, type Points } from "three";
import { FAR, ROCKS, type Rock, type Ship } from "./field";
/** The live game, owned (and mutated) by Runner.tsx; the scene only reads it. */
export type Live = {
running: boolean;
t: number;
speed: number;
boosting: boolean;
ship: Ship;
vel: Ship;
rocks: Rock[];
shake: number;
distance: number;
near: number;
energy: number;
};
const STARS = 1400;
// Scenery: rocks far out to the sides, above and below, that can't be hit but
// fill the view. Without them the corridor's rocks bunch into a ball in the
// middle distance.
const OUTER = 140;
const BG = "#05040d";
export function RunnerScene({ live, step }: { live: RefObject<Live>; step: (dt: number) => void }) {
const rocks = useRef<InstancedMesh>(null);
const outer = useRef<InstancedMesh>(null);
// Outer rocks: placed by a fixed hash (no randomness during render), kept
// outside the corridor, recycled like the real ones.
const outerRocks = useRef(Array.from({ length: OUTER }, (_, i) => {
const h = (n: number) => ((Math.sin(i * 91.17 + n * 47.3) * 43758.5453) % 1 + 1) % 1;
const angle = h(1) * Math.PI * 2;
const radius = 10 + h(2) * 28;
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius * 0.6, z: -h(3) * FAR, r: 0.6 + h(4) * 2.2 };
}));
const ship = useRef<Group>(null);
const flame = useRef<Group>(null);
// Scratch objects and star positions belong to the scene: kept in refs.
const scratch = useRef(new Object3D());
const stars = useRef<Points>(null);
const starGeometry = useMemo(() => {
// Deterministic spread (no Math.random during render): a simple hash.
const p = new Float32Array(STARS * 3);
for (let i = 0; i < STARS; i++) {
const h = (n: number) => ((Math.sin(i * 12.9898 + n * 78.233) * 43758.5453) % 1 + 1) % 1;
p[i * 3] = (h(1) * 2 - 1) * 60;
p[i * 3 + 1] = (h(2) * 2 - 1) * 40;
p[i * 3 + 2] = -h(3) * FAR * 1.4;
}
const g = new BufferGeometry();
g.setAttribute("position", new BufferAttribute(p, 3));
return g;
}, []);
useFrame(({ camera, clock }, dt) => {
const d = Math.min(dt, 0.05); // a long stall (tab switch) shouldn't teleport rocks
step(d);
const g = live.current;
// Rocks: one instanced mesh, one draw call, its matrices rewritten in place.
const m = rocks.current;
if (m) {
const o = scratch.current;
g.rocks.forEach((r, i) => {
o.position.set(r.x, r.y, r.z);
o.scale.setScalar(r.r);
o.rotation.set(i + clock.elapsedTime * 0.4, i * 2 + clock.elapsedTime * 0.3, 0);
o.updateMatrix();
m.setMatrixAt(i, o.matrix);
});
m.instanceMatrix.needsUpdate = true;
}
const om = outer.current;
if (om) {
const o = scratch.current;
outerRocks.current.forEach((r, i) => {
r.z += g.speed * d;
if (r.z > 8) r.z -= FAR;
o.position.set(r.x, r.y, r.z);
o.scale.setScalar(r.r);
o.rotation.set(i * 0.7 + clock.elapsedTime * 0.2, i, 0);
o.updateMatrix();
om.setMatrixAt(i, o.matrix);
});
om.instanceMatrix.needsUpdate = true;
}
// Stars stream past faster than the rocks, wrapping round when behind us.
const pos = stars.current?.geometry.getAttribute("position") as BufferAttribute | undefined;
if (pos) {
const arr = pos.array as Float32Array;
for (let i = 0; i < STARS; i++) {
arr[i * 3 + 2] += g.speed * d * 1.6;
if (arr[i * 3 + 2] > 8) arr[i * 3 + 2] -= FAR * 1.4;
}
pos.needsUpdate = true;
}
// The ship banks into turns; its engine flame grows when boosting.
if (ship.current) {
ship.current.position.set(g.ship.x, g.ship.y, 0);
ship.current.rotation.set(g.vel.y * 0.03, 0, -g.vel.x * 0.035);
}
if (flame.current) flame.current.scale.set(1, 1, (g.boosting ? 2.2 : 1) * (0.85 + Math.sin(clock.elapsedTime * 40) * 0.15));
// The camera trails the ship, leans with it, widens on boost, shakes on a crash.
const shake = g.shake * (Math.sin(clock.elapsedTime * 90) * 0.3);
camera.position.set(g.ship.x * 0.55 + shake, g.ship.y * 0.55 + 1.5, 6.5);
camera.up.set(-g.vel.x * 0.012, 1, 0);
camera.lookAt(g.ship.x * 0.8, g.ship.y * 0.8, -12);
if (camera instanceof PerspectiveCamera) {
const fov = g.boosting ? 82 : 68;
camera.fov += (fov - camera.fov) * Math.min(1, d * 6);
camera.updateProjectionMatrix();
}
});
return (
<>
<color attach="background" args={[BG]} />
{/* Fog hides the far end, so rocks fade in instead of popping into view. */}
<fog attach="fog" args={[BG, 40, FAR * 0.95]} />
<ambientLight intensity={0.35} />
<directionalLight position={[3, 6, 4]} intensity={2.4} />
<pointLight position={[0, 0, 2]} intensity={6} distance={14} color="#7b6cf0" />
<points ref={stars} geometry={starGeometry} frustumCulled={false}>
<pointsMaterial size={0.12} color="#cfd6ff" sizeAttenuation fog={false} />
</points>
<instancedMesh ref={rocks} args={[undefined, undefined, ROCKS]} frustumCulled={false}>
<icosahedronGeometry args={[1, 0]} />
<meshStandardMaterial color="#8a8296" roughness={0.9} flatShading />
</instancedMesh>
<instancedMesh ref={outer} args={[undefined, undefined, OUTER]} frustumCulled={false}>
<icosahedronGeometry args={[1, 0]} />
<meshStandardMaterial color="#5d5868" roughness={1} flatShading />
</instancedMesh>
<group ref={ship} scale={1.15}>
{/* A small dart: a cone for the body, swept wings, two tail fins. */}
<mesh rotation-x={-Math.PI / 2}>
<coneGeometry args={[0.3, 1.4, 6]} />
<meshStandardMaterial color="#e8e6ff" metalness={0.6} roughness={0.3} />
</mesh>
<mesh position={[0, -0.04, 0.3]}>
<boxGeometry args={[1.5, 0.06, 0.5]} />
<meshStandardMaterial color="#6a5ae0" metalness={0.4} roughness={0.4} />
</mesh>
{[-0.45, 0.45].map((x) => (
<mesh key={x} position={[x, 0.16, 0.45]}>
<boxGeometry args={[0.05, 0.34, 0.36]} />
<meshStandardMaterial color="#e0577a" metalness={0.3} roughness={0.5} />
</mesh>
))}
<group ref={flame} position={[0, 0, 0.7]}>
<mesh position={[0, 0, 0.25]} rotation-x={Math.PI / 2}>
<coneGeometry args={[0.18, 0.6, 12]} />
<meshBasicMaterial color="#3ee0c5" transparent opacity={0.85} />
</mesh>
</group>
</group>
</>
);
}