Europe/Paris
Posts

Building a 3D browser game with Three.js: a step-by-step guide for junior devs (Pigeon Parisien)

July 20, 2026 · 10 min de lecture
Pigeon Parisien (play it here) is a 3D flying runner: you control a pigeon over the rooftops of Paris, dodging drones, cables, and cars, collecting baguettes for combo points, and unlocking new pigeon skins as your high score grows. The whole game is one HTML file, with Three.js loaded from a CDN — no build step, no npm install, no bundler. If you're a junior dev who wants to learn 3D-in-the-browser without fighting webpack configuration first, this is deliberately the easiest possible entry point: open the file, edit a number, refresh, see the result. This article walks through the real code, chapter by chapter, the way I'd have wanted it explained when I started. Every Three.js project, no matter how simple or complex, starts with the same three objects:
Javascript
const canvas   = document.getElementById('game-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);

const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x8fd3da, 70, 210);

const camera = new THREE.PerspectiveCamera(62, innerWidth / innerHeight, 0.1, 600);
  • The renderer is the thing that actually draws pixels to the screen, using your GPU through WebGL. You give it a <canvas> element and it takes care of the rest.
  • The scene is a container for everything that exists in your 3D world: meshes, lights, fog. Think of it as the "document" in a web page analogy.
  • The camera defines what part of that world is visible, and from where. A PerspectiveCamera mimics how human eyes see things — closer objects look bigger, parallel lines converge in the distance.
One detail worth noticing: renderer.setPixelRatio(Math.min(devicePixelRatio, 2)). Modern phone screens can report a pixel ratio of 3 or more; rendering at full native resolution on every frame would be needlessly expensive for a simple game. Capping it at 2 is a cheap, common performance guard. Why the fog? scene.fog fades distant objects into the background color instead of letting them pop in or disappear abruptly at the edge of the camera's view distance. On a game that generates buildings procedurally as you fly (more on that in Chapter 5), fog is what makes new buildings appear to emerge softly from the haze instead of visibly "spawning" in front of you. It's a classic trick to hide the seams of infinite procedural worlds — you'll see it in far bigger games too. Key takeaway: renderer draws, scene holds, camera frames. Every Three.js app you'll ever build starts from these three lines. A game is just a function that runs over and over, as fast as the browser allows, updating positions and redrawing the screen each time:
Javascript
function loop() {
  requestAnimationFrame(loop);
  const dt = Math.min(clock.getDelta(), 0.05);
  state.time += dt;
  if (state.scene === 'playing') update(dt);
  else {
    pigeon.position.y = 8 + Math.sin(state.time * 1.5) * 0.5;
  }
  musicTick();
  renderer.render(scene, camera);
}
requestAnimationFrame(loop) tells the browser "call this function again right before the next repaint" — typically 60 times per second, but that's not guaranteed. A phone under load, a background tab, or a slower device might only manage 30 frames per second, or drop frames unpredictably. This is exactly why the code never says "move the pigeon 2 pixels every frame." Instead it uses dt (delta time — the number of seconds elapsed since the previous frame) and multiplies every movement by it, so speed is expressed in units per second, not units per frame. A pigeon moving at dt * 10 covers the same real-world distance whether the game runs at 30 fps or 120 fps — only the number of steps changes, not the total distance covered per second. Look closely at Math.min(clock.getDelta(), 0.05). This clamps dt to a maximum of 50 milliseconds. Why? Imagine the browser tab loses focus for five seconds (the user switches apps) and comes back — clock.getDelta() would return 5.0, and without the clamp, every position update in that single frame would suddenly jump five seconds' worth of movement: the pigeon would teleport through several obstacles, or a physics calculation could go wildly unstable. Clamping delta time is a small line that prevents a very real, very common bug class in real-time games. Key takeaway: always multiply movement by delta time, and always clamp delta time to a sane maximum. This single pattern is the backbone of every real-time simulation, from a browser game to a professional game engine. There's no input framework here — just raw browser events, listened to once and turned into simple state:
Javascript
addEventListener('keydown', e => {
  if (e.code === 'Space' || e.code === 'ArrowUp') { flap(); }
  if (e.code === 'ArrowLeft'  || e.code === 'KeyA') keys.left  = true;
  if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = true;
  if (e.code === 'KeyC') firePoop();
});
Javascript
canvas.addEventListener('pointerdown', e => {
  touch.active = true;
  touch.lastX = e.clientX;
  flap();
});
canvas.addEventListener('pointermove', e => {
  if (!touch.active) return;
  const dx = e.clientX - touch.lastX;
  touchSteer = dx * 0.045;
});
Notice there are two different patterns at play, and knowing when to use each is a useful lesson:
  • Instant actions (flap, fire) call a function directly inside the event handler. They happen once, exactly when the key is pressed — you don't want a "flap" to keep firing every frame while the key is held.
  • Continuous state (moving left/right) just flips a boolean (keys.left = true) instead of moving the pigeon immediately. The actual movement happens later, every frame, inside update(dt), by checking if (keys.left) pigeon.position.x -= dt * speed. This is what lets the pigeon glide smoothly while a key is held, instead of jumping in one increment per keypress.
The pointerdown/pointermove pair does the same job for touch screens, using the browser's unified Pointer Events API — a single set of events that works for mouse, touch, and stylus alike, so you don't need to write separate touch-specific and mouse-specific handlers. Key takeaway: separate "did something just happen" (an event, handled once) from "is a button currently held" (a state, read every frame). Mixing the two is a common source of bugs like double-jumping or actions firing far too fast.
Javascript
const d2 = e.mesh.position.distanceToSquared(pigeon.position);
const rr = (e.r + pr) * (e.r + pr);
if (d2 < rr) {
  const isBonus = e.type === 'bread' || e.type === 'coin' || e.type === 'star';
  if (isBonus) collect(e);
  else if (isSuper) { /* destroy obstacle */ }
  else { gameOver(GAME_OVER_MSGS[e.type]); }
}
This game has no physics engine at all — collision detection is a single geometric check: are two spheres overlapping? Two spheres overlap when the distance between their centers is smaller than the sum of their radii. That's it — that's the entire collision system for the whole game. The interesting detail for a junior dev: notice it's distanceToSquared, not distance. Computing an actual distance requires a square root (Math.sqrt), which is meaningfully more expensive than basic arithmetic, and when you're running this check against every obstacle, every single frame, those costs add up. The trick: instead of comparing distance < (r1 + r2), you compare distanceSquared < (r1 + r2)² — mathematically equivalent (since both sides are non-negative, squaring preserves the comparison), but without ever computing a square root. It's a small optimization, but it's a pattern you'll see everywhere once you know to look for it. Key takeaway: you don't need a physics engine to build a fun game. A single squared-distance comparison is enough for anything that can be reasonably approximated as spheres or circles — which is most 2D and simple 3D games. The Paris skyline you fly over isn't made of downloaded 3D assets — every building is generated from code, on the fly:
Javascript
function makeBuilding(depth) {
  const g = new THREE.Group();
  const w = 6 + Math.random() * 3;
  const h = 10 + Math.random() * 6;
  const mat = new THREE.MeshLambertMaterial({
    color: facadeColors[Math.floor(Math.random() * facadeColors.length)]
  });
  const bloc = new THREE.Mesh(new THREE.BoxGeometry(w, h, depth), mat);
  bloc.position.y = h / 2;
  g.add(bloc);
  // ...windows, balconies, roofs, chimneys, awnings, flags, signage
Each call to makeBuilding() randomizes width, height, and facade color from a fixed palette, then layers on details (lit windows roughly 20% of the time, balconies, a roof, sometimes a chimney or an awning). No two buildings are identical, yet none of them were designed by hand or downloaded from anywhere — they're assembled entirely from boxes and cylinders at runtime. Even the Eiffel Tower is procedural, using a neat symmetry trick worth understanding:
Javascript
function makeEiffel() {
  const g = new THREE.Group();
  for (const [sx, sz] of [[-1,-1],[1,-1],[-1,1],[1,1]]) {
    const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 1.1, 26, 5), mat);
    leg.position.set(sx * 5, 13, sz * 5);
    leg.rotation.z = -sx * 0.28;
    leg.rotation.x =  sz * 0.28;
    g.add(leg);
  }
Instead of manually placing and rotating four separate legs, the code loops over four [sx, sz] sign combinations (-1/1 on each axis) and reuses the same tilt angle, just flipped by multiplying by sx or sz. This is a common pattern once you notice an object has mirror symmetry: build the logic for one instance in terms of a sign variable, then loop over the four (or two, or eight) sign combinations instead of writing out each case by hand. Key takeaway: procedural generation means writing rules that produce content, instead of authoring the content directly. It's how you get visual variety with zero art assets and zero download size — a genuinely practical skill even outside of games (think dashboards generating charts, or apps rendering user-configurable layouts). Everything above makes a pigeon fly through a city. None of it, on its own, makes anyone want to keep playing. That's a separate layer, deliberately layered on top: gamification. The combo system. Collecting baguettes chains a multiplier:
Javascript
if (e.type === 'bread') {
  state.combo = state.comboTimer > 0 ? Math.min(5, state.combo + 1) : 1;
  state.comboTimer = COMBO_WINDOW;
  const pts = 50 * state.combo;
  state.score += pts;
The rule is simple: collecting a baguette while a short timer (comboTimer) from the previous baguette is still running increases the combo by one, up to a cap of 5 (Math.min(5, ...)). Let the timer expire, and the next baguette restarts the combo at 1. This single mechanic does a lot of psychological work: it rewards sustained good play (staying close to a trail of baguettes) far more than isolated lucky pickups, and the capped multiplier means the game stays balanced instead of scores spiraling to infinity. The temporary power-up. Grabbing a star grants a few seconds of invincibility as "Super Pigeon":
Javascript
} else if (e.type === 'star') {
  state.superTimer = SUPER_DURATION;
  aura.visible = cape.visible = true;
  burst(e.mesh.position, 0x9c6bff);
  sfx.super();
  showToast('⚡ SUPER PIGEON ! ⚡', true);
SUPER_DURATION (6 seconds) is stored as a countdown timer, decremented every frame in update(dt), with the visual cues (aura, cape meshes) toggled on for as long as the timer is above zero. This is the same "timer as game state" pattern as the combo window above — a genuinely reusable idea: any temporary effect (a shield, a speed boost, a debuff) can be modeled the same way, as a number that counts down and gates some behavior while it's positive. Unlockable progression. Nine pigeon skins, gated by high score:
Javascript
const SKINS = [
  { id: 'gris',  name: 'Titi Parisien', desc: 'Le classique des toits', color: 0x8f97a3, dark: 0x6b727d, req: 0 },
  { id: 'blanc', name: 'Colombe Chic',  desc: 'Record 500 — élégance pure', color: 0xf2f2f2, dark: 0xcfcfcf, req: 500 },
  { id: 'violet',name: 'Mystique',      desc: 'Record 4000 — magie pure', color: 0x8a6bb8, dark: 0x5f4488, req: 4000 },
  // ...
];
Each skin carries its own req (required best score). A skin is shown as locked until state.best >= skin.req. This is deliberately generic — it's not really "pigeon skin" logic, it's a progression system: an array of rewards, each gated by a threshold on some player metric. The exact same shape works for achievement badges, unlockable levels, or feature-flags gated by usage in a completely unrelated app. Key takeaway: gamification isn't decoration bolted onto a finished game — it's a small number of general-purpose patterns (windowed combos, countdown timers gating temporary state, threshold-gated unlockables) that you can name, recognize, and reuse well beyond games. In the same spirit as the rest of this blog, a few honest limits worth naming:
  • No automated tests. The combo math, the skin unlock thresholds, the difficulty curve — all of it is pure, easily testable logic (given a score, is this skin unlocked? given a combo state and a new pickup, what's the next combo?), but none of it is currently covered by unit tests. Extracting these small pure functions out of the render loop and testing them in isolation would be the natural next step.
  • One giant file. Perfectly fine for a project this size, learned on purpose to stay dependency-free — but it wouldn't scale past a certain point. Splitting scene setup, input, gameplay state, and rendering into modules would be the natural evolution if the game grew further.
  • Proprietary license. Worth being upfront about: unlike most code I write about on this blog, this repository's source is visible for portfolio purposes but is not open for reuse, modification, or redistribution — all rights reserved. Feel free to read and learn from it (that's the whole point of this article), just not to copy it into your own project.
The full source is a single readable HTML file: github.com/Rajekevin/pigeon-parisien-game. Play it live first, then open the file and search for the function names mentioned in this article (loop, update, makeBuilding, applySkin) — seeing exactly how they connect to what you just played is the fastest way to make any of this actually stick.
On this page