Europe/Paris
Posts

Lanes, jumps, and beeps: building an endless runner with Three.js for junior devs (Holy Cow Road)

July 21, 2026 · 9 min de lecture
Holy Cow Road (play it here) is a 3D endless runner: you guide a sacred cow across three lanes of a busy Indian street, dodging tuk-tuks, buses, and market stalls, jumping over carts and cones, as the street gets faster and busier the further you get. Like Pigeon Parisien, it's a single self-contained HTML file with Three.js loaded from a CDN — no build tooling required. This article picks up different lessons than the pigeon one: instead of free-flying movement, this game is built entirely around discrete choices (which of 3 lanes, jump or not) — a genuinely different, and in some ways simpler, design pattern worth understanding on its own.
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);
Same three ingredients as any Three.js scene — renderer, scene, camera — but two small choices worth calling out for a junior:
  • 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.
Javascript
function animate(){
  requestAnimationFrame(animate);
  const dt = Math.min(0.05, clock.getDelta());
  update(dt);
  renderer.render(scene, camera);
}
This is about as small as a real-time game loop gets: request the next frame, compute a clamped delta time, update game state, render. If you read the Pigeon Parisien article, you'll recognize the exact same Math.min(dt, 0.05) clamp — protecting against huge time jumps after the tab was backgrounded. It's worth noticing that two completely independent codebases, written for different games, converge on the identical one-line fix: that's a sign it's not a one-off hack, it's a pattern every real-time loop needs. This is the core design difference from a free-flying game: the cow doesn't move to an arbitrary X position, it moves between exactly three lanes, indexed 0, 1, 2.
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); }
}
moveLane(1) or moveLane(-1) doesn't move anything visually by itself — it only updates an integer, lane, and clamps it between 0 and 2 so you can never request a lane that doesn't exist (Math.min(2, Math.max(0, ...)) is a common, compact "clamp a number between two bounds" idiom worth memorizing). The actual visual movement happens separately, every frame:
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;
LANES is presumably a small array like [-3, 0, 3] mapping a lane index to a world X coordinate. Rather than snapping the cow instantly to the new lane, cow.position.x is nudged a fraction of the remaining distance every frame ((targetX - current) * dt * 12) — this is a lerp (linear interpolation) pattern: instead of "be there instantly," it's "close some of the gap every frame," which produces a smooth glide instead of a teleport, for free, in one line. The small rotation tilt based on the remaining distance to the target is a nice touch too — the cow visibly leans into the turn while sliding, then straightens out as it settles into the new lane. Why discrete lanes at all, instead of free movement? It's a deliberate design simplification: with only 3 valid X positions, obstacle placement, collision checks, and "is this lane blocked" logic all become trivial integer comparisons instead of continuous geometry. Plenty of real, shipped runner games (Subway Surfers, Temple Run) make the same choice for exactly this reason — constraining player movement can make a game both easier to build correctly and easier to balance.
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;
}
No gravity constant, no velocity integration, no physics library — just a timer and a sine wave. jumpT counts up from 0 while airborne; -1 means "on the ground, jump available again" (a neat trick: reusing one numeric variable both as a countdown and as a boolean flag, via its sign). p is how far through the jump we are, from 0 to 1. Math.sin(p * Math.PI) is the trick worth understanding: as p goes from 0 to 1, p * Math.PI sweeps from 0 to π radians, and sin of that sweeps smoothly from 0 up to 1 (at the midpoint, p = 0.5) and back down to 0 — producing a perfectly smooth rise-and-fall arc, multiplied by JUMP_H (jump height), with a single trigonometric function and zero physics simulation. This is a genuinely useful general technique, well beyond jumping: any time you need something to smoothly go up and come back down (a bounce, a pulse, a UI element that "pops"), a sin(p * π) curve over a normalized 0→1 progress value gets you there in one line, with no simulation, no velocity, no acceleration to tune.
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(); }
});
if(e.repeat) return; is easy to miss but important: when a key is held down, the browser fires repeated keydown events after a short delay (the same repeat behavior you get typing in a text field). Without this guard, holding the left-arrow key would spam moveLane(-1) dozens of times per second instead of moving one lane per press. Touch is handled with a small gesture classifier, using nothing but distance and timing:
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});
This is a tiny, hand-rolled gesture recognizer, and it's worth reading closely because the same shape shows up in any touch-driven UI: capture the touch start position and time, then on release compare the start and end points. A short movement in a short time (< 24px, < 300ms) is classified as a tap (here, jump). Otherwise, whichever axis moved further — horizontal or vertical — decides whether it was a left/right swipe or an up swipe. No gesture library, about ten lines of arithmetic.
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){}
}
Every sound in this game — lane switch, jump, milestone, game over — is generated on the fly with the Web Audio API, not loaded from an .mp3 or .wav file. An OscillatorNode produces a raw tone at a given frequency and waveform ('square', 'triangle', 'sawtooth' — each has a distinctly different timbre), routed through a GainNode that controls volume. The exponentialRampToValueAtTime(0.001, ...) line is what turns a flat, buzzy tone into something that sounds like a game "blip": the volume decays smoothly to near-zero over the sound's duration, instead of cutting off abruptly. This matters practically, not just aesthetically: there are zero audio asset files, zero download weight, and zero loading time for sound in this entire game — every effect is a few milliseconds of synthesized waveform, generated the instant it's needed. For a junior building a small game or a prototype, this is a genuinely underused technique: you don't need a sound designer or a folder of .wav files to add satisfying audio feedback.
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);
Three separate difficulty knobs, each expressed as a simple function of distance (how far the player has run), each capped so the game never becomes literally impossible:
  • 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.
None of this is complicated math, but it's a genuinely important game-design lesson that has nothing to do with Three.js specifically: every difficulty parameter here is capped. An uncapped difficulty curve is a classic beginner mistake — it feels fine in the first minute of testing and becomes unplayable, or outright glitches out, the longer a skilled player survives. There's a second, related lesson in the obstacle spawner itself:
Javascript
const blocked = blockedLanesNear(z, 14);
if(!blocked.has(ln) && blocked.size <= 1){
  // ...spawn the obstacle
}
Before placing a new obstacle, the game checks which lanes are already blocked nearby, and refuses to spawn if that would leave the player with zero safe lanes. This is the difference between a game that's hard and a game that's unfair — a subtlety that only shows up once you actually playtest procedurally generated content, and a good habit to build early.
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++;
}
Compared to Pigeon Parisien's combo system and unlockable skins, this is gamification in its simplest possible form: a sorted list of [threshold, message] pairs, and a single index (milestoneIdx) tracking how many have already been shown. Since MILESTONES is sorted ascending and milestoneIdx only ever increases, each milestone fires exactly once, in order, with an O(1) check per frame — no need to loop over the whole list every time. It's a good reminder that gamification doesn't require an elaborate system to be effective. A short, celebratory message every so often (a toast plus a satisfying sound cue) is often enough to make a repetitive action feel like progress — the same underlying idea as a progress bar, a streak counter, or a "you're halfway there" notification in a completely different kind of app.
  • 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.
Full source: github.com/Rajekevin/holy-cow-road. Play it live first, then compare it side by side with the Pigeon Parisien article — same engine, same core loop shape, genuinely different movement and gamification design. Seeing the same fundamentals reused two different ways is a good way to tell which parts of what you just learned are "Three.js basics" versus "this particular game's design."
On this page