Three.js From Zero · Article s13-10

R3F Game Capstone

R3F Game Capstone is Article s13-10 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS13-10 · R3F Mastery

Season 13 · Article 10 · R3F Mastery

Build a complete 3D infinite runner in one article: player capsule, scrolling environment, obstacles, score, restart, deploy. Everything from previous S13 articles working together. ~400 lines of game.

Capstone walkthrough

This is a project, not a snippet. Clone the starter (link in your dashboard) or build from these snippets in a fresh Vite+React project.

Architecture

  • Zustand store — game state (score, alive, speed)
  • Rapier physics — player capsule + ground + obstacles
  • useFrame — scroll the world toward the player; spawn obstacles ahead; despawn behind
  • Drei — Environment for lighting, ContactShadows for grounding, Stats for FPS
  • Post-processing — Bloom on the player so it pops

The store

import { create } from 'zustand';
const useGame = create((set) => ({
  alive: true, score: 0, speed: 8,
  die: () => set({ alive: false }),
  reset: () => set({ alive: true, score: 0, speed: 8 }),
  tick: (dt) => set(s => s.alive ? { score: s.score + dt * 10 } : {}),
}));

The player

function Player() {
  const ref = useRef();
  const alive = useGame(s => s.alive);
  const [jumping, setJumping] = useState(false);

  useEffect(() => {
    const onKey = (e) => {
      if (e.code === 'Space' && !jumping && alive) {
        ref.current.applyImpulse({ x: 0, y: 7, z: 0 }, true);
        setJumping(true);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [jumping, alive]);

  return (
    <RigidBody ref={ref} colliders={false} lockRotations position={[0, 1, 0]}
      onIntersectionEnter={() => useGame.getState().die()}>
      <CapsuleCollider args={[0.5, 0.3]} />
      <mesh castShadow>
        <capsuleGeometry args={[0.3, 1, 8, 16]} />
        <meshStandardMaterial color="hotpink" emissive="hotpink" emissiveIntensity={2} toneMapped={false} />
      </mesh>
    </RigidBody>
  );
}

Scrolling world + obstacle spawner

function World() {
  const [obstacles, setObstacles] = useState([]);
  const speed = useGame(s => s.speed);
  const alive = useGame(s => s.alive);
  const nextSpawn = useRef(0);

  useFrame((_, dt) => {
    if (!alive) return;
    useGame.getState().tick(dt);

    // Spawn ahead
    nextSpawn.current -= dt;
    if (nextSpawn.current <= 0) {
      const id = Math.random();
      const lane = (Math.floor(Math.random() * 3) - 1) * 1.2;
      setObstacles(o => [...o, { id, x: lane, z: -30 }]);
      nextSpawn.current = 0.4 + Math.random() * 0.4;
    }

    // Move toward player
    setObstacles(o => o
      .map(ob => ({ ...ob, z: ob.z + speed * dt }))
      .filter(ob => ob.z < 8));
  });

  return (
    <>
      <RigidBody type="fixed" colliders="cuboid">
        <mesh receiveShadow position={[0, -0.6, -10]}>
          <boxGeometry args={[6, 1, 60]} />
          <meshStandardMaterial color="#1a1a2a" />
        </mesh>
      </RigidBody>
      {obstacles.map(ob => (
        <RigidBody key={ob.id} type="kinematicPosition" position={[ob.x, 0.5, ob.z]} sensor>
          <mesh>
            <boxGeometry args={[0.8, 1, 0.8]} />
            <meshStandardMaterial color="#22d3ee" emissive="#22d3ee" emissiveIntensity={1.5} toneMapped={false} />
          </mesh>
        </RigidBody>
      ))}
    </>
  );
}

HUD + restart

function HUD() {
  const score = useGame(s => s.score);
  const alive = useGame(s => s.alive);
  const reset = useGame(s => s.reset);
  return (
    <Html fullscreen>
      <div style={{ position: 'absolute', top: 20, left: 20, fontSize: 24, color: '#fff' }}>
        {Math.floor(score)}
      </div>
      {!alive && (
        <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }}>
          <h2>Game Over</h2>
          <button onClick={reset}>Restart</button>
        </div>
      )}
    </Html>
  );
}

Compose

<Canvas shadows camera={{ position: [0, 3, 6], fov: 60 }}>
  <Environment preset="city" />
  <directionalLight position={[5, 8, 3]} intensity={1.2} castShadow />
  <Physics gravity={[0, -18, 0]}>
    <Player />
    <World />
  </Physics>
  <EffectComposer>
    <Bloom intensity={0.8} luminanceThreshold={0.6} />
  </EffectComposer>
  <HUD />
</Canvas>

Common first-time pitfalls

"Obstacles don't trigger game over." Forgot <Physics> wrapper, or onIntersectionEnter on the wrong body. Obstacles are sensors, player has the handler.
"Player flies off after jump." Missing lockRotations on the player body — the impulse imparts angular momentum too.
"Score doesn't reset on restart." Action only sets some fields. Pattern: reset: () => set({ alive: true, score: 0, speed: 8 }) with all initial values.

Ship it

  1. vite build
  2. Deploy dist/ to CF Pages, Vercel, or GitHub Pages
  3. Tweet a 10-second screen recording
  4. Add it to your portfolio