What we're building
Chapter 1 — Scene setup, and a detail worth noticing
Javascript
function initScene(){
scene = new THREE.Scene();
scene.background = new THREE.Color(0xffd28a);
scene.fog = new THREE.Fog(0xffd28a, 35, 95);
camera = new THREE.PerspectiveCamera(60, innerWidth/innerHeight, 0.1, 200);
renderer = new THREE.WebGLRenderer({ antialias:false });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.getElementById('game').appendChild(renderer.domElement);
- scene.background and scene.fog are set to the exact same color (0xffd28a, a warm orange). This is intentional, not a coincidence: when the fog color matches the background color exactly, distant objects fade seamlessly into the sky instead of fading into a visibly different-colored haze. It's a one-line trick that makes a cheap fog effect look deliberate rather than glitchy.
- antialias:false here, versus antialias:true in Pigeon Parisien. Antialiasing smooths jagged edges but costs GPU performance. On a fast-paced runner with dense, close-up traffic and constant motion, the visual gain from smooth edges matters less than keeping frame rate high — a small, deliberate performance trade-off you'll need to make on every real project.
Chapter 2 — A minimal game loop
Javascript
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(0.05, clock.getDelta());
update(dt);
renderer.render(scene, camera);
}
Chapter 3 — Discrete lanes instead of free movement
Javascript
function moveLane(dir){
if(!running || dead) return;
const nl = Math.min(2, Math.max(0, lane + dir));
if(nl !== lane){ lane = nl; beep(dir>0?520:440, 0.07, 'triangle', 0.05); }
}
Javascript
targetX = LANES[lane];
cow.position.x += (targetX - cow.position.x) * Math.min(1, dt*12);
cow.rotation.z = (cow.position.x - targetX) * 0.18;
Chapter 4 — Jump physics without a physics engine
Javascript
function jump(){
if(!running || dead) return;
if(jumpT < 0){ jumpT = 0; beep(660, 0.12, 'triangle', 0.06); }
}
// inside update(dt):
if(jumpT >= 0){
jumpT += dt;
const p = jumpT / JUMP_DUR;
if(p >= 1){ jumpT = -1; cow.position.y = 0; }
else cow.position.y = Math.sin(p*Math.PI) * JUMP_H;
}
Chapter 5 — Input handling: keyboard and swipe gestures
Javascript
addEventListener('keydown', e=>{
if(e.repeat) return;
if(e.code==='ArrowLeft'||e.code==='KeyA'||e.code==='KeyQ') moveLane(-1);
else if(e.code==='ArrowRight'||e.code==='KeyD') moveLane(1);
else if(e.code==='ArrowUp'||e.code==='Space'||e.code==='KeyW'||e.code==='KeyZ'){ e.preventDefault(); jump(); }
});
Javascript
addEventListener('touchend', e=>{
if(!running || dead) return;
const t = e.changedTouches[0];
const dx = t.clientX - tX, dy = t.clientY - tY;
const dt = performance.now() - tT;
if(Math.abs(dx) < 24 && Math.abs(dy) < 24 && dt < 300){ jump(); return; }
if(Math.abs(dx) > Math.abs(dy)) moveLane(dx > 0 ? 1 : -1);
else if(dy < 0) jump();
}, {passive:true});
Chapter 6 — Sound effects with zero audio files
Javascript
function beep(freq, dur, type, vol){
try{
if(!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
const o = audioCtx.createOscillator(), gn = audioCtx.createGain();
o.type = type||'square'; o.frequency.value = freq;
gn.gain.setValueAtTime(vol||0.08, audioCtx.currentTime);
gn.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
o.connect(gn); gn.connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + dur);
}catch(e){}
}
Chapter 7 — Tuning difficulty as a function of distance
Javascript
speed = Math.min(MAX_SPEED, BASE_SPEED + distance/90);
const maxVeh = Math.random() < 0.3 + Math.min(0.35, distance/2500) ? 2 : 1;
spawnTimer = Math.max(0.55, 1.5 - distance/600);
- Speed grows linearly with distance, capped at MAX_SPEED.
- Traffic density (how often two vehicles can occupy the road at once) grows too, capped so it can add at most a 0.35 probability bump on top of the base 0.3.
- Spawn rate (spawnTimer, seconds between new obstacles) shrinks with distance — but is floored at 0.55 seconds, so obstacles can never spawn faster than the player could possibly react.
Javascript
const blocked = blockedLanesNear(z, 14);
if(!blocked.has(ln) && blocked.size <= 1){
// ...spawn the obstacle
}
Chapter 8 — Gamification: milestones as the simplest possible hook
Javascript
const MILESTONES = [
[100, '100 m — Namaste ! 🙏'],
[250, '250 m — La rue t\'appartient 🐄'],
[500, '500 m — Vache légendaire ✨'],
[1000, '1 KM — MOOO-numental ! 🏆']
];
if(milestoneIdx < MILESTONES.length && distance >= MILESTONES[milestoneIdx][0]){
showToast(MILESTONES[milestoneIdx][1], 'gold');
beep(880, 0.12, 'triangle', 0.06);
milestoneIdx++;
}
What I'd do differently
- No automated tests, same as with Pigeon Parisien — the difficulty formulas and milestone logic are pure functions of distance and would be trivial to unit test (does speed actually cap at MAX_SPEED? does a milestone ever fire twice?), but nothing currently does.
- Balancing by feel, not by data. The difficulty constants (90, 2500, 600, 0.55) were tuned by playtesting, which is legitimate for a small project, but there's no telemetry to confirm where players actually die most often — real games use analytics for exactly this kind of tuning.
- Proprietary license, same note as the other game: the source is here to read and learn from, not to reuse or redistribute — Three.js itself remains MIT-licensed and separate from this restriction.