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
Official docs ↗

Play

Loading game…

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