Arcade · Build notes · How it works
River Raid Explained
I rebuilt the arcade classic — a jet climbing a narrowing canyon, shooting what shoots back, scrounging for fuel — and this is me walking through how it actually works. The loop, the scrolling river, how a finger-press becomes a banking jet, the collisions, the fuel clock, and the explosion when it all goes wrong. Less a victory lap, more the notes I wish someone had handed me when I started. By the end you should be able to build your own.
◆ The diagrams in this piece are drawn by hand in Excalidraw on my Wacom.
Everything below is the real code behind the game you just played, pulled apart so you can see how it works. New to this? Follow the diagrams. Done it before? The good bits are §5 and §10. And any time your eyes glaze over, there is a button to bail straight back to the game.
00Why a game, of all things
I trained as an electronics and communications engineer and was later licensed as an electrical engineer, and along the way I've spent a lot of time in computer science and in industrial automation. Software is one of several rooms I move between, and they bleed into each other more than people expect. The fastest way I know to test whether I actually understand something is to build a thing that breaks loudly when I'm wrong — and nothing breaks more loudly than a game. A web form can be subtly broken for months and nobody notices. A game is wrong the instant the jet slides through a wall, and you feel it in your hands.
Part of why I reach for games at all is Demis Hassabis. He spent years designing them — Theme Park, Republic — before founding DeepMind and turning games into the proving ground for machine intelligence. I'm not putting myself anywhere near that company; it's the framing that stuck with me. A game is a small, honest world where you find out quickly whether your model of how a system behaves is actually right. That's a deeply engineering idea, and it's why building one never feels like a detour from the day job.
It also packs a surprising amount of computer science onto one small screen: a real-time loop, a simulation with its own clock, two coordinate systems, a little procedural generation, collision maths, a particle system, input handling, a small state machine. And most of it rhymes with work I already do — a game loop is a control loop with a prettier output, the bank shimmer in §5 turned out to be an aliasing problem I first met in signals rather than graphics, and the enemy behaviour is a state machine no different in spirit from one driving a PLC on a factory line. Here those ideas are lined up where you can watch them hand work to each other — and that's what I want to walk through, slowly, with the real logic.
I don't keep all of this in my head, and I won't pretend to. When a piece of logic knotted up — the river interpolation cost me an evening — I'd talk it through with an AI assistant, the way you'd corner a sharper colleague or argue with a rubber duck that argues back. It's a tool: use it to get unstuck, and to pressure-test the parts you only half-understand. Then go back and make sure you do understand them, because the AI isn't holding the controller when it ships.
You'll leave knowing what a "game loop" is and why every game has one. That single idea unlocks most of the rest.
Watch how one hard requirement — "it has to feel smooth on a cheap phone" — quietly drives nearly every decision, from the coordinate system down to a one-line CSS fix.
The parts I'd flag are the world-locked sampling that kills the bank shimmer (§5) and the death state that exists only so an explosion can animate during a pause (§10).
01The loop: a flip-book that never stops
A game is a flip-book. Each page is one still image — a frame — and if you redraw the page fast enough with everything nudged a little, your eye reads motion. The job of the engine is to draw a new page, forever, as fast as the screen can show it.
The browser hands you exactly the right tool: requestAnimationFrame. You give it a function, and it calls that function right before the next screen refresh — about 60 times a second on most displays, and it politely pauses when the tab is hidden so you don't cook the battery. Here is the entire heartbeat of the game:
function loop(){ if (state === 'play' && !paused) update(); // 1. advance the world by one tick else if (state === 'dying') updateDeath(); // (special case: play the explosion) render(); // 2. paint everything where it now is requestAnimationFrame(loop); // 3. ask the browser for the next frame }
That's it. Three steps, on repeat: think, draw, schedule the next think. Notice the split — update() changes numbers (positions, fuel, score) but draws nothing; render() draws but changes nothing. Keeping those two jobs apart is the single most important habit in game code. When something is in the wrong place, you always know which function to open.
02Two coordinate worlds, and the bridge between them
This is the idea that makes scrolling games click, and it's the one most tutorials skip. There are two ways of talking about position, and you constantly translate between them.
The screen has pixels. y = 0 is the top edge, y = H is the bottom. That never changes. The world is the river — an endless ribbon the jet flies up. You measure your place in the world as a single number, scroll: total distance travelled since the start. It only ever grows.
The jet looks like it's flying forward, but here's the trick: the jet barely moves. The world slides past it. The river that's currently at the top of your screen is "further ahead" in world terms than the river at the bottom. So I need two tiny functions to convert between a screen row and a world distance:
function yToD(y){ return scroll + (H - y); } // a screen row -> a world distance function dToY(d){ return H - (d - scroll); } // a world distance -> a screen row
Read dToY out loud: "take a thing's world distance d, subtract how far we've scrolled, and flip it so bigger distances sit higher on screen." Every object in the game stores its position as a world distance and never thinks about pixels. At draw time I run it through dToY to find out where on the glass it should appear this frame. Increase scroll and every object slides down on its own — that's the entire illusion of forward flight, falling out of one subtraction.
scroll, is the whole camera. Objects live in world space and forget about the screen; the bridge functions place them each frame. This is also why the game never "runs out of level" — there is no level, only a number going up.03The river is just data
The canyon looks organic, but it's a handful of numbers with smooth blending between them. I describe the river as a list of control nodes. Each node says, at some world distance d: here is the centre line cx, and here is the half-width hw (how far the water reaches from the centre to each bank).
I only keep the nodes near the player. As the world scrolls, I generate new nodes just beyond the top of the screen and throw away the ones that have slid off the bottom. The river is effectively infinite because I'm always inventing the next ~240 pixels of it and forgetting the part nobody can see:
function ensureNodes(){ // invent river ahead of the player until we have enough buffer while (lastNodeD < scroll + H + 240) pushNode(nodes[nodes.length-1]); // forget nodes that have scrolled well past the bottom while (nodes.length > 3 && nodes[1].d < scroll - 80) nodes.shift(); }
Nodes are sparse — one every 60–120 pixels. If I drew straight lines between them the banks would look like a folded paper fan. So when I need the river at any exact distance d, I find the two nodes that straddle it and blend between them. A plain blend (linear) still has visible corners at each node, so I run the blend factor through smoothstep, a tiny curve that eases in and out and makes the banks flow like water:
function smooth(t){ return t*t*(3 - 2*t); } // the S-curve that rounds the corners function riverAt(d){ var a = nodes[0], b = nodes[nodes.length-1]; for (var i = 0; i < nodes.length-1; i++){ // find the pair that brackets d if (nodes[i].d <= d && d <= nodes[i+1].d){ a = nodes[i]; b = nodes[i+1]; break; } } var t = smooth((d - a.d) / (b.d - a.d)); // 0 at node a, 1 at node b, eased var cx = a.cx + (b.cx - a.cx) * t; // blended centre line var hw = a.hw + (b.hw - a.hw) * t; // blended half-width return { cx: cx, hw: hw }; // the river, at exactly this depth }
That for loop is a plain linear scan, and that's deliberate — there are only ever a handful of nodes on screen at once, so a binary search would be more code for no speed you could measure. Right-sizing the cleverness to the problem is half of engineering. Now riverAt(d) answers one question — "where are the banks at this depth?" — and the rest of the game leans on it constantly. Drawing the banks calls it. Spawning a fuel depot in the channel calls it. Checking whether you've crashed calls it. One source of truth for the shape of the world.
hw and the river opens; drift cx and it meanders. The banks you crash into are literally cx ± hw.04Difficulty is a curve, not a switch
The first playable version had one flaw you felt immediately: the channel was tight from the first second. No room to learn the controls, no ramp. Good difficulty isn't a wall you hit — it's a slope you don't notice you're climbing. So the river's width is a function of how far you've flown.
When I generate a node, I compute a progress value t that crawls from 0 to 1 over a long distance, square it so it stays gentle early and bites later, and use that to shrink the channel from nearly full-width down to about a third:
var maxHalf = W/2 - BANK_M; // the widest the channel could ever be var t = Math.min(1, d / 6500); // 0 → 1 across a long run (~40s) var ease = t * t; // square it: stay wide early, narrow late var center = maxHalf * (0.94 - 0.60*ease); // 94% of full → ~34% deep in
The two constants 0.94 and 0.60 are the whole feel of the game. The first is where you start (almost the full width — generous). The second is how much you lose by the end (most of it — claustrophobic). I tuned those two numbers by playing, not by theory. That's the honest part of game design: you guess, you play, you nudge, you play again.
Expose the feel of your game as a few named numbers near the top of a function. When a friend says "it's too hard," you want to change one constant, not rewrite a system.
05Painting the canyon — and the bug that taught me the most
To draw the banks I walk down the screen in small steps, ask riverAt for the bank positions at each step, and join the points into two filled shapes — land on the left, land on the right, water in the gap. Simple. But my first version had a flaw that nearly drove me off a cliff: the banks shimmered. The edges crawled and vibrated like a bad GIF, even though the logic was "correct."
Here's why. I was sampling the banks at fixed screen rows — y = 0, 8, 16, 24… every frame. But the river was sliding underneath those fixed rows. So the straight segments I drew between sample points were constantly being recut against a moving curve, and the little kinks between samples crawled. The fix is subtle and worth keeping forever: lock the sample points to the world, not the screen.
var step = 8; // phase makes each vertex sit on a fixed WORLD grid, so it rides the water down var phase = (((scroll + H) % step) + step) % step; var pts = []; for (var y = phase - step; y <= H + step; y += step){ pts.push({ y: y, r: riverAt(yToD(y)) }); // each point is anchored to the river itself }
With that one phase offset, every vertex now glides smoothly downward with the current and exits the bottom exactly as a new one enters the top. The shimmer vanished. I'd describe the lesson as: if a thing looks like it's vibrating, check whether you're measuring it against the wrong frame of reference.
Drawing a curve = pick points along it and connect them. More points = smoother. These points are spaced 8 pixels apart.
Sampling a moving signal at a stationary rate causes aliasing — the same family of bug as a wagon wheel spinning backwards on film. Anchor your samples to the thing that's moving.
The modulo dance ((x % n) + n) % n is there because JavaScript's % keeps the sign of the dividend; this forces a always-positive phase so the loop's start point never jumps a step.
06The logo on the fuel tank
This is my favourite detail. In the original game you refuel by flying over depots marked FUEL. Here, the depots wear the iolinked logo — and there's no image file. The mark is drawn directly into the canvas with three rectangles and one clever stroke.
Look at the logo: a tall bar, a little tab, a baseline, and a ring. The three bars are just filled rectangles. The ring is the trick — instead of drawing a filled circle and punching a hole, I stroke a circle with a very thick pen. A stroked circle is a donut, and its thickness is the difference between the outer and inner radius:
// the three strokes of the mark ctx.fillRect(0, 0, 18, 64); // tall vertical bar ctx.fillRect(18, 23, 32, 18); // the little tab ctx.fillRect(0, 72, 96, 18); // the baseline // the ring 'o' — a circle stroked with an 18px-thick pen IS a donut ctx.lineWidth = 18; // = outer radius 32 − inner radius 14 ctx.beginPath(); ctx.arc(64, 32, 23, 0, 6.283); // radius 23 = the middle of that thickness ctx.stroke();
Because it's vector maths and not a bitmap, the same five lines render the logo crisp at any size — on the depot, on the start card, in the legend. And it ties the game back to the brand without a single asset to load. That's the whole iolinked philosophy in miniature: the logo is a recipe, not a file.
<img>, no SVG file at runtime — the brand is computed. A stroked circle saves you the whole "subtract one shape from another" dance.07From thumb to thrust
A control should feel like weight, not teleportation. If pressing left snapped the jet sideways, it would feel like a spreadsheet cursor. So a key press doesn't set position — it sets velocity, and velocity moves position, with friction bleeding it off when you let go. Three numbers, one feeling:
var acc = 0.62; if (keys.left) vx -= acc; // hold = keep pushing velocity left if (keys.right) vx += acc; // hold = keep pushing velocity right vx *= 0.84; // friction: release and it coasts to a stop vx = Math.max(-5.4, Math.min(5.4, vx)); // clamp the top speed jet.x += vx; // velocity finally moves the jet jet.y += (jetBaseY() - jet.y) * 0.06; // drift back to the racing line
That 0.84 is the soul of the handling. Closer to 1 and the jet feels like it's on ice; closer to 0 and it feels rigid. The clamp stops you from building up silly speed by mashing the key. And because steering reads a flag (keys.left) rather than the keyboard directly, the on-screen buttons on mobile can drive the exact same code — they just flip the same flags:
function hold(btn, key){ var press = function(e){ e.preventDefault(); keys[key] = true; btn.classList.add('held'); }; var release = function(e){ e.preventDefault(); keys[key] = false; btn.classList.remove('held'); }; btn.addEventListener('touchstart', press, { passive: false }); btn.addEventListener('touchend', release, { passive: false }); } hold(btnL, 'left'); hold(btnR, 'right'); hold(btnFire, 'shoot');
The keyboard and the touchscreen never know about each other. They both just write to keys, and update() reads it. That's a small decoupling that paid off twice — once when I added mobile buttons, and again when I made the buttons bigger without touching a line of game logic.
08Collisions without the heavy maths
"Did I crash?" sounds like it needs a physics engine. It needs two if statements. Because the river is defined by cx ± hw at any depth, checking whether the jet has hit a bank is just asking: is the jet's x outside the channel at the jet's row?
var rv = riverAt(yToD(jet.y)); // the river, exactly at the jet's row var left = rv.cx - rv.hw + JET_R; // leftmost the jet may sit var right = rv.cx + rv.hw - JET_R; // rightmost if (jet.x < left || jet.x > right) loseLife(); // outside the water = into the land
For enemies and bullets I use circles, because a circle-to-circle test is the cheapest collision in games. Two things overlap if the distance between their centres is less than the sum of their radii. And you never compute the actual distance — square roots are slow — you compare the squared distance to the squared radius, which gives the same answer with only multiplications:
function dist2(ax,ay,bx,by){ var dx=ax-bx, dy=ay-by; return dx*dx + dy*dy; } // a bullet hits an enemy of radius e.r if the squared gap is small enough if (dist2(bullet.x, bullet.y, e.x, ey) < (e.r + 4)*(e.r + 4)) hit();
Most "advanced" game maths is a trick to avoid expensive operations. Squared-distance comparison is the first one every game programmer learns, and you'll reach for it for the rest of your life.
riverAt that drew the bank — so what you see is exactly what kills you. No second source of truth to drift out of sync.09Fuel: the clock that makes you brave
Without fuel, the safe play is to crawl up the middle forever. Fuel is the pressure that pushes you toward the depots — which are guarded, which is where the game lives. It's a number that always falls, a little faster when you push the throttle, and you top it up by overlapping a depot instead of shooting it:
fuel -= FUEL_BURN * (0.7 + speed/SPD_DEF * 0.5); // always falling; faster when you push if (fuel <= 0) loseLife(); // empty tank = you're done // flying over a depot tops you up (shooting it gives points instead) if (overlap(jet, depot)) fuel = Math.min(FUEL_MAX, fuel + 0.9);
That single subtraction creates a real decision every time a depot appears: do I dive into the narrow part to refuel, or do I have enough to skip it and stay safe? Tension, from one line. The depot is the one object you're rewarded for not shooting, which is a nice little inversion of everything else on screen.
10Dying, properly
The first crash was a let-down — a shake and an instant respawn. A crash should be a moment. I wanted fire, smoke, spinning debris, sparks. But there was a structural problem: when you die, the game pauses to let the moment land — and a paused game doesn't run update(), which means the explosion would freeze on its first frame.
The fix is a tiny state machine. Death isn't an event, it's a state the game lives in for about a second. The loop you saw in §1 routes to a separate updateDeath() while state === 'dying', so the wreck keeps animating even though normal gameplay is suspended:
function loseLife(){ if (state === 'dying') return; // already exploding — ignore lives--; explode(jet.x, jet.y); // throw fire, smoke, debris, sparks deathT = 70; // ~1.2s of carnage before respawn state = 'dying'; // hand the loop to updateDeath() }
The explosion itself is a particle system, which sounds fancy and is just a list of tiny objects, each with a position, a velocity, a colour, and a life that ticks toward zero. Every frame I move them, fade them, and delete the dead ones. One particle looks like this:
parts.push({ x: x, y: y, vx: Math.cos(a)*sp, vy: Math.sin(a)*sp, // fly outward in a random direction life: 1, decay: 0.013 + Math.random()*0.02, // fades over ~1s c: fire[Math.random()*fire.length | 0], // a random ember colour type: 'fire', grav: 0.03 // gravity tugs it back down });
I spawn about seventy of them at once in four flavours — bright fire, dark smoke that swells as it rises, chrome debris that tumbles, and fast sparks — plus a couple of expanding shock rings and a quick fireball flash. None of it is expensive; it's a few dozen squares and circles. But the result reads as a real explosion, because real explosions are also just a lot of small hot things moving apart and cooling down.
Every frame, one function ages the whole list. The detail worth stealing is the backwards loop: when you delete an item from an array while walking forwards, everything after it shifts down and you skip the next one. Walk from the end and removal never disturbs the indices you haven't visited yet:
function stepParticles(){ for (var i = parts.length-1; i >= 0; i--){ // walk backwards so splicing is safe var p = parts[i]; p.x += p.vx; p.y += p.vy; p.vy += p.grav; // move, then gravity bends the path p.life -= p.decay; // age it toward zero if (p.life <= 0) parts.splice(i, 1); // dead — drop it from the list } }
That's the entire engine behind the fire, the exhaust trail, and every enemy you pop — one list, moved and aged and pruned each frame. Learn this pattern once and you'll reach for it forever.
11The flicker hunt — a short war story
The game looked great on my laptop and flickered like a faulty tube on my phone. The whole screen — river, jet, everything — strobed. This is the kind of bug that humbles you, because the game logic was perfect. The problem wasn't in the JavaScript at all.
The culprit was a CSS line on the mobile control buttons: backdrop-filter: blur(2px). It looked classy, but a blurred backdrop has to re-read the pixels behind it every time they change — and behind those buttons, the canvas was repainting sixty times a second. So the browser was re-blurring the entire frame, constantly, and losing the race. Two changes fixed it:
/* BEFORE — this forced the whole canvas to re-blur every frame */ .tc-btn { backdrop-filter: blur(2px); } /* deleted */ /* AFTER — give the canvas its own GPU layer so the buttons can't dirty it */ .strip canvas { transform: translateZ(0); }
That translateZ(0) is an old trick: it promotes the canvas to its own compositor layer, so the buttons floating above it composite separately instead of forcing a shared repaint. I also over-fill the background a few pixels past the edges, so the brief screen-shake on a crash can never expose a one-pixel gap of the layer beneath. The flicker was gone, and the lesson stuck: on the web, the most expensive thing on screen is often the prettiest CSS property you forgot you used.
Fancy visual effects cost performance. If something runs badly, the first suspect is the prettiest thing on screen.
Layout/paint/composite is a pipeline. Animating under a backdrop-filter drags work into the paint stage every frame; isolating layers moves it to the cheap composite stage.
The bug never showed on desktop because a discrete GPU swallowed the cost. "Works on my machine" is, almost always, "works on my hardware budget."
12The palette: colour as a language
I didn't pick these colours because they're pretty (though they are). I picked them so that colour means something the instant you see it, before you've read anything. The whole game runs on a near-black canvas so every lit element pops, and each hue has exactly one job:
The rule that holds it together: warm is you and your energy, cool is friendly systems, red is the only danger. A new player learns that grammar in the first ten seconds without being told, and from then on the screen is readable at a glance even when it's chaos. Good game colour isn't decoration — it's an interface.
Define your colours as named CSS variables with a one-line "job" comment each, like the swatches above. The day you want to reskin the whole game, you edit eight lines.
13Build it yourself
Reading code is not knowing code. If you actually want this in your hands, do these in order — each is small, and each forces you to understand a section above:
- Change the feel. Open
river-raid.html, findvx *= 0.84, and try0.92then0.70. Feel the difference between ice and rigidity. You now understand friction. - Reskin the river. Swap
--riverand--landfor a desert palette — sand banks, dark canyon floor. You'll see how one source of colour drives the whole look. - Make a new enemy. Copy the ship branch in
spawnObjs, give it a different movement rule (a sine-wave weave is three lines), and a colour. You'll learn how objects live in world space. - Add a powerup. A pickup that doubles your fire rate for ten seconds. You'll touch spawning, collision, and a timer — a complete little feature.
- Break the flicker on purpose. Re-add
backdrop-filter: blur(4px)to the buttons and watch it strobe on a phone. Then remove it. Now you'll never forget §11.
If you haven't yet — here's the game. Everything above is more interesting once you've lost a few jets to a narrowing canyon.
14Cheat sheet
The shape of it
The world
Feel
Web gotchas
15Glossary
- Game loop
- The function the browser calls every frame to advance and redraw the game. The heartbeat of any real-time program.
- requestAnimationFrame
- A browser API that runs your function just before the next screen refresh, syncing your drawing to the display and pausing when the tab is hidden.
- World space vs. screen space
- World space is the game's own ruler (distance flown); screen space is pixels. You convert between them every frame.
- Interpolation
- Filling in values between two known points. Here, the bank position between two sparse river nodes.
- Smoothstep
- The little curve
t·t·(3−2t)that eases a blend in and out so interpolated shapes look organic instead of jagged. - Particle system
- A list of small, short-lived objects (sparks, smoke) each with a position, velocity and fading life. Cheap to run, expressive to watch.
- State machine
- A program that is always in exactly one named state (
play,dying,over) and moves between them on events. Keeps complex behaviour from tangling. - Compositor layer
- A surface the browser can move and blend on the GPU without repainting. Forcing the canvas onto its own layer (with
translateZ(0)) stopped the flicker. - Aliasing
- Sampling a moving signal at a fixed rate, producing false motion — the cause of the bank shimmer, and of wagon wheels spinning backwards on film.