What we're building, and why a single HTML file
Chapter 1 — The three things every Three.js scene needs
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.
Chapter 2 — The game loop, and why delta time matters
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);
}
Chapter 3 — Reading player input without a game engine
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;
});
- 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.
Chapter 4 — Collision detection with plain math, no physics engine
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]); }
}
Chapter 5 — Procedural generation: building a city with no 3D models
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
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);
}
Chapter 6 — Gamification: what actually makes you want to keep playing
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;
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);
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 },
// ...
];
What I'd do differently
- 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.