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.
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
vite build- Deploy
dist/to CF Pages, Vercel, or GitHub Pages - Tweet a 10-second screen recording
- Add it to your portfolio
UP NEXT
S13-11 — R3F VR & WebXR → Take this game into VR.