Arcade · Build notes · How it works
IO-RAID Explained
A vertical scrolling shooter, built from scratch — in the tradition of River Raid (Activision, 1982, designed by Carol Shaw).
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.
Now look at the first line of that listing again, because something is missing from it and the omission is mine. requestAnimationFrame does not call your function empty-handed — it hands over the current time in milliseconds, already measured, every frame. My signature is function loop(){. There is nowhere for that number to land. Search the engine and there is no performance.now, no Date.now, no dt. I did not weigh the options. I never reached for the clock at all.
So every quantity in this game advances once per frame, never once per second. scroll += speed. Fuel takes one sip. The invulnerability counter drops by one; so do the reload counter and the death timer. The engine's only clock is how many times the browser has called the loop.
Which means a 120 Hz display does not make this game smoother. It makes it run twice as fast. The river arrives at 324 pixels a second instead of 162, and the tank drains in half the time. Not one character of the source changed. Your monitor did.
You have probably met this bug, under an older name. Software for the original IBM PC timed itself by counting processor cycles, so on a faster clone everything sprinted. That is what the turbo button on those machines was for, and it was badly named: the useful position switched it off, dropping the machine back to a speed the software still worked at.
The decision I skipped has a name: the timestep. A variable one scales every motion by the real milliseconds elapsed, which fixes the speed and quietly sells off determinism — that value is a different float every frame, so the same five key presses never replay the same run twice. A fixed one advances the simulation in constant slices and lets the drawing catch up, which keeps both. This engine took neither, and it is owed one credit: a delta-timed engine returning from a tab hidden for a minute holds a minute-wide step it must throw away before that number teleports you into a bank. Frame-locked, there is nothing to throw away.
A frame is not a unit of time. It is a unit of work the display asked for, and how long it lasts belongs to whoever is holding the machine. Every engine has to choose. This one chose by not choosing, and so your refresh rate is quietly a difficulty setting.
The loop just promised sixty frames a second, and every line of this engine trusts that promise. Nowhere does the code measure how long a frame took. scroll += speed advances the world once per frame, never once per millisecond. So a 120 Hz screen does not draw the same flight more smoothly; it flies a faster one. Predict what that does to five seconds of this game, then start both clocks.
Predict before you run anything. The refresh-rate buttons change one thing on this stage and nothing at all in the engine; the constants below are the engine's own, unedited.
Five seconds of wall clock on a 120 Hz screen. How far does this jet fly, compared with five seconds on a 60 Hz screen?
Nothing in the engine changes when you press these. Only the comb does.
Both lanes tick on the one stopwatch above. Watch the two rulers, not the frame count.
Commit a prediction, then both clocks start.
Lane B is an illustrative alternative loop, not code from this engine.
Seconds shown on this stage are at the stated refresh rate. Frames, pixels, fuel and shots are computed here from the engine's own constants: SPD_DEF 2.7, FUEL_MAX 100, FUEL_BURN 0.052, fireCD 8, deathT 70.
■ gold = lane A, the loop this page actually runs · ■ blue = lane B, an illustrative delta-timed loop · ■ red = the fault stamp · the comb = one second of the clock drawn in frame ticks.
SPD_DEF 2.7, FUEL_BURN 0.052, fireCD 8, deathT 70 — and the verdict says in plain words what the two rulers just did. The name for the choice underneath all of it is fixed versus variable timestep, and determinism goes with it.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.
Now put a plain question to that machinery: how far has the jet flown? Not how far the world has scrolled. The aircraft. Go looking for its world distance and there is no such variable. The jet object holds an x and a y, and both of those are screen pixels. Neither one grows.
The y is stranger still. One line in update() looks like a spring, easing the aircraft back to its resting row by six per cent of the remaining gap each frame. Follow every write to that field and it never has anything to ease. The row is set when the run starts, set again on respawn, and snapped by the resize handler — all three to exactly the value the easing line is easing toward. At a fixed window size, the jet's vertical position is a constant. Sideways is the only freedom the aircraft has.
Which turns the opening question into arithmetic. Substitute the resting row into the bridge and the H cancels:
function jetBaseY(){ return H - (isTouch ? 150 : 88); } // the resting row function yToD(y){ return scroll + (H - y); } // screen row -> world distance // put one inside the other and the screen height falls out: // yToD(jetBaseY()) = scroll + (H - (H - 88)) = scroll + 88
The jet's world distance is scroll plus 88 on a desktop and scroll plus 150 on a phone. It is not a quantity the game maintains. It is a constant offset from the one number that moves.
That is a change of reference frame, and it is the whole trick of this section. I never built a jet flying up a river. I sat inside the jet, where the jet is at rest by definition, and gave the river a velocity instead. The physics is identical; only the bookkeeping moved, and the bookkeeping is the part you pay for. Fly the player through the world and you owe it a coordinate that grows without bound for as long as anyone keeps playing, a camera transform on every draw call, and a bank test at a row that moves every frame. Pinned, there is exactly one river sample per frame, riverAt(yToD(jet.y)), and because jet.y is a constant it is always the same row.
Meanwhile scroll holds down three jobs and the extra two cost nothing. It is the camera. It is the odometer the difficulty curve reads. It is the front line the river generator builds ahead of. The cheapest way to move through a world is not to move at all, and to move the world instead.
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.Read that subtraction once more, because it hides the strangest fact in the engine: the jet has no world position at all. Its row on the glass is eased toward jetBaseY() and stays there, while only scroll climbs. Everything that looks like forward flight is other things being placed lower each frame. Step the loop one frame at a time and watch which of the two panels actually moves.
Right now: this page is not moving, scrollTop is — an element's screen position is offsetTop − scrollTop, the same subtraction as dToY above.
riverAt() generates from random nodes; the geometry it stands in for is exact, and every other number on the stage is the engine's own.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.That last sentence is doing more work than it looks, because riverAt is not one of several descriptions of the river. It is the only one, and three unrelated subsystems interrogate it every frame: the renderer, the spawner and the kill test. Drag a depth marker and watch all three read the same two numbers. Then let each keep its own copy, and time how long the world stays honest.
drawBanks() paints ·
blue the depot spawnObjs() would drop on that row ·
pink dashed the bank the kill test would enforce ·
gold the row you are asking about. Each tick above the row is that
subsystem's left bank, cx − hw — the one number drawBanks paints,
lane() insets 18 px from, and update kills at. The run stops on the
first frame the painted and the fatal bank stand more than a jet width — 22 px — apart.
cx and hw they read at that row, and while all three ask one riverAt the leader lines land on a single point. Then commit a timescale, hand each subsystem its own private copy of the river stepped by the engine's own node drift — cx += (rand−0.5)*64, so up to 32 px per node, every 64 to 120 px — and press run until they disagree. The widget measures the first frame at which the painted bank and the fatal bank stand more than a jet width apart, and stamps what happens to a jet flying legally against a wall that has moved. The honest caveat is printed with it: drawing samples the river on an 8 px screen grid and joins the samples with straight chords, while collision samples the exact jet row — same source of truth, different resolution. One riverAt is not tidiness. It is the only thing stopping the game from lying to you about where the world is.This engine never plays a run to prove the canyon stays flyable. The guarantee is geometric instead, and it falls out of two numbers already on the page. A node may shift the centre line by at most 32 px, and nodes sit at least 64 px apart. Set the meander as steep as the generator can legally build it, then try to out-steer it at every throttle.
Said yes? A river that out-turns you is not harder, it is arbitrary: the death was settled by the generator before you touched a key.
Said no? Then the ceiling on the bend has to sit under the floor on your steering — and nobody wrote that rule down. Go and check it.
pushNode can actually produce, and run the steering recurrence for yourself instead of taking the clamp's word for it. Switch the stage to the enemy lane for the second guarantee. The question worth holding while you drive: is this canyon's fairness a rule somebody wrote, or arithmetic that could not have come out any other way — and what is the throttle quietly spending?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 channel. 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.
And here is where I owe you an honesty note, because writing this page is what made me look.
That Math.min(1, …) is a clamp, and it is the only one in the difficulty
system. The channel width really does level off. But four other things ramp from a
second progress value that has no clamp at all:
var prog = scroll/900; // no Math.min — this grows forever if (er > 0.72 - prog*0.05) type = 'heli'; // helicopter share sp: 1.0 + prog*0.5 + Math.random()*0.8 // enemy speed nextObjD = scroll + 120 + Math.random()*120 - prog*10 // gap to the next spawn
Read that last line again. The gap to the next spawn shrinks by 10 for every
900 you fly, and nothing stops it reaching zero. Work it through: at
scroll = 10,800 the unlucky end of that random range hits zero, and by
scroll = 21,600 even the lucky end does — from there the spawner
fires on every frame. The river scrolls at speed per frame, so at the
default throttle that is about 67 seconds in, fully degenerate by 133. Hold
full throttle and you arrive in 39. The helicopter test rots the same way: once
prog passes 14.4 the threshold drops below zero and every
enemy spawns as a helicopter.
So the honest title for this section is: difficulty here is a curve and a cliff. One ramp was designed and bounded; the others were written as though every run would be short, and a good player walks straight off the end of them. I have left the code as it is and told you instead, because the lesson outlives the fix — every ramp needs a ceiling, and the one you forget is the one nobody reaches until somebody gets good at your game.
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.
You have just been told that the spawner melts, and being told is not the same as watching it. This scrubber evaluates the real expression, 120 + rand*120 - (scroll/900)*10, at whatever distance you drag it to. Push past ten thousand and watch the gap between one enemy and the next close to nothing. The two markers on the track are the distances the section named.
about 1.4 would need the depot and bridge rolls to fire 42 % of the time between enemies. Together they fire 30 %.
about 2.3 is what a chain that continues with probability 0.7 gives you: 0.7/0.3 enemies before something else rolls.
about 10 is the tail, not the middle. 0.7 to the tenth is 2.8 %, so roughly one burst in thirty-five — and one is plenty.
t is really computed from each node’s own depth d; the bar reads it at the jet’s scroll, which trails it by under one screen. And the section’s claim that past 12,960 every enemy is a helicopter is one line short: er > 0.90 && scroll > 620 still re-labels the top tenth as a diving jet, so what really ends there is the ship — nine in ten helicopters, one in ten jet, no ships ever again.
The shape is a familiar one: a retry backoff with no maximum, or a progress bar that sails past 100 %. A value with a bound nobody asserted. Four of the five bars below the tape have nothing drawn at their right-hand end, and that is the whole defect.
One ramp was bounded and four were not. Next: the one place where getting the frame of reference wrong is not a bug in the numbers at all.
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.
The paragraph above says the shimmer vanished, which is easy to write and hard to believe. Two strips below run the same river at the same speed. One samples at fixed screen rows, y = 0, 8, 16, and the other offsets its start by scroll mod 8. Only one of them is stable, and the whole difference is a single modulo.
Said the moving points? Sensible: the things that move are usually the things that wobble. The ghost stack is where that intuition gets tested.
Said the still points? Then you are betting the fixed grid is the problem, not the moving water. Stack eight frames and check.
scroll — the engine's own smoothstep between nodes, with the nodes pinned instead of random — so the only thing under test is where the sample grid starts. step 8, the phase expression, SPD_MIN 1.70, SPD_DEF 2.70 and SPD_MAX 4.60 are the engine's own; the 264 px strip is a stand-in for the canvas, chosen so that H is a whole number of steps.
background-attachment: scroll versus fixed, which decides whether a page's pattern is nailed to the document or to the window — flip that property in your own browser and you get these two behaviours in about ten seconds.
Fixing feel by choosing a frame of reference. Next: fixing feel by choosing a fraction.
SPD_MAX 4.60 to make it worse on purpose. Both strips are drawn from the same curve at the same scroll, so the only thing under test is where the first sample starts; the name for what you are looking at is temporal aliasing, the family the wagon wheel belongs to.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.
One line in that listing was never explained, and it is the one worth stealing: jet.y += (jetBaseY() - jet.y) * 0.06. It does not move the jet a fixed amount. It moves it six percent of whatever gap is left, every frame, so the step shrinks as the jet arrives. Set the fraction yourself and watch a snap turn into a settle.
Predict before you drop anything.The slider changes exactly one number, a, and every frame count printed below is computed here from it — nothing is looked up, and nothing is carried over from a previous run.
Set the fraction to 0.06 and drop the jet 100 px below its resting line. What happens?
The two marked ticks are the only fractions this engine actually uses. Everything between them is yours.
Same starting gap, same 60 frames. Only the rule for choosing this frame’s step changes.
Watch the little arrows in lane 1: each one is a single frame’s step.
Commit an answer, then drop the jet.
The fixed-step lane and the noisy input trace are illustrative alternatives, not code from this engine.
Ground truth, read off the engine rather than the prose: the steering listing above contains jet.y += (jetBaseY() − jet.y) * 0.06, and vx *= 0.84 on the line before it is that same recurrence aimed at zero with a = 0.16. The friction line runs and is felt every frame. The y line is written but inert in this build — every write to jet.y already sets it to jetBaseY(), so it computes zero — which is why the 100 px drop here is a bench for the recurrence rather than something you can watch in play. The fixed-step lane and the noisy input trace are illustrative alternatives, not code from this engine.
■ gold = the recurrence and its fraction a · ■ blue = the part it keeps, 1 − a, the resting line and the noisy input · ■ red = the fault stamp · in lane 1 the faint line is only the path taken; the arrows are the mechanism, one per frame.
y = (1−a)·y + a·target while you drag, and lane 3 feeds that identical line a jittery target and prints the input and output swings it measured. Push a to 1.00, or switch to the fixed 6 px step, and the stage stamps what went wrong. The names for what you just built are linear interpolation and the exponential moving average.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 = left; if (invuln <= 0) loseLife(); } // clamp FIRST, judge second if (jet.x > right) { jet.x = right; if (invuln <= 0) loseLife(); }
That clamp is doing quiet work. The jet is pushed back inside the channel whether or not it dies, so during the invulnerable seconds after a respawn you scrape along the bank instead of drifting through it into open land. Move first, judge second — otherwise the one moment the player is not being killed is also the moment the world stops containing them.
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.
Read that comment again, because it is the one place this page understates the game. A depot does not top you up. It adds 0.9 per frame for exactly as long as you overlap it, and how long you overlap it is set by your throttle. Fly faster and you cross the same 68 px in fewer frames. Choose a throttle and count what one clean pass is actually worth.
px / frame—
per frame—
seconds—
the depot—
net of burn—
20 → 100—
fuel + 0.9. Commit, then count what that buys.
The line never mentions speed. It adds 0.9 while the overlap test is true, and the throttle decides how many frames that is.
So the throttle is priced twice — once in what it burns, once in what it stops you collecting. Drive both and watch the comb.
isTouch builds only the left, right and fire buttons, so keys.up and keys.down can never be set and speed is pinned at 2.70 for the entire run.
That is the price of flying. Next: what the engine does the moment you stop being able to pay it.
+0.9. Fly a pass and count them yourself instead of taking a total on trust, then chain three from a 20-unit tank and see where the gauge stops. The question worth holding: the refuelling line never mentions speed, so who wrote the price of the throttle?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.
So why not take the consequence on impact and be done? Death as an event is not a strawman: it is the shorter code, and there are two honest ways to write it. Both break somewhere you can point at.
Do it on impact and there is nothing left to animate into: the particles are stepped from inside update(), so a fresh jet on the next frame gives all seventy-one of them one frame of life before they die under a living aircraft. Set a flag instead and keep running update() and the opposite happens: the explosion plays, but so does every kill test in the function, against a body that is on fire. The bank clamp still shoves the wreck sideways. Five places can take a life, each needing an "unless we are already dead" clause bolted on.
updateDeath() is the third option: less a special case than a second set of rules for the same loop:
function updateDeath(){ frame++; scroll += Math.max(0.6, speed*0.45); // keep the river drifting under the wreck ensureNodes(); // and keep inventing river ahead of it stepParticles(); stepShocks(); if(shake>0){ shake*=0.85; if(shake<0.3) shake=0; } deathT--; if(deathT<=0){ if(lives<=0){ gameOver(); } else { respawn(); state='play'; } } }
Read it for what is absent. No input. No spawning. No collision test of any kind. What it keeps is the world: the water still slides under the wreck at 45 % of your last speed, floored at 0.6 so even a stalled crash drifts, and the generator keeps inventing river ahead of a jet that is no longer flying. Then deathT counts 70 frames to zero and hands the state on — back to play, or out to over.
Here is the part I only noticed while writing this page. Inside update() sit six tests asking whether the state is still 'play', and not one of them can ever be false. update() is reached from one place, and that place already checked. Those six lines are the guards the flag version would have needed — made unnecessary by the state machine, and never deleted.
That is what a finite state machine buys, and the name is honest about it: a fixed set of states, one live at a time, and transitions as the only way between them. The dispatch moves to one place, the top of the loop, and every rule underneath assumes it. Anyone who has held state in a UI framework knows the shape: a component does not play an animation, it enters a state and renders it.
A game loop is not a story running. It is a state machine ticking, and the tick never stops. Only the rules it ticks under change.
The listing above hands the loop to updateDeath() and then stops. What it does not show is the machine underneath: four named states, and the exact conditions that move between them. This panel runs the real transitions and lights each arrow as it fires. Crash it, then watch invuln count down until the kill test switches back on.
The channel used by the bank drive is a fixed stand-in,
cx 207 and hw 98; every other number on the stage is the engine's own.
The machine on the stage is a finite state machine — one variable, four named values, five conditional transitions, and exactly one of them live at a time.
One clause and no more: the panel is deliberately shaped like a state inspector, because anyone who has held state in a UI already reads this shape, and the transfer is theirs to make.
jet.x before anything decides whether you died. Then switch the model to either alternative and watch the middle of the machine stop existing — both of those lanes are illustrative, and the panel says so. The channel the bank drive steers into is a fixed stand-in for one riverAt() sample; every other number on the stage is the engine's own.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
COL.riverandCOL.landin the engine'sCOLobject for 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.
Credits, trademarks & what this is
This game and every line of its code were written from scratch by Ajai Raj as a personal engineering project, to learn in public and to teach how real-time software actually works.
- Inspired by — River Raid, published by Activision in 1982 for the Atari 2600, designed and programmed by Carol Shaw, one of the first women to design a commercial video game. This is an original re-implementation: no code, art, audio, or assets from that game are used here, and none of its files were consulted.
- Trademarks — all product and company names mentioned are the trademarks or registered trademarks of their respective owners, and are used here only to describe and credit the work that inspired this one. Their use does not imply any endorsement, sponsorship, or affiliation.
- Not affiliated — this page and this game are independent, unofficial, and have no connection to, and are not endorsed by, any of the rights-holders named above.
- No money is made here — this is free to play and free to read. There is no advertising, no tracking for profit, no payment, and nothing for sale on this page.
- Original work — the engine, the artwork drawn in code, the writing, and the interactive figures are © Ajai Raj. Happy to hear from anyone who thinks something here needs changing: hello@iolinked.com.