iolinked.com Ajai's space

IO-RAID

Fly the canyon, shoot what shoots back, and stay fed: every iolinked depot you fly over tops up your fuel.

SCORE0000000
Fuel100%

IO-RAID

Steer ← →, throttle ↑ ↓, fire with Space.
Refuel on the iolinked depots. Don't hit the banks.

Launch ▶

Controls

steer across the river
throttle up / ease off
Spacefire
Ppause

On mobile, the on-screen ◀ ▶ buttons steer and the FIRE button shoots.

On the river

iolinked depot — fly over to refuel
Ships & choppers — shoot or dodge
Bridge — blast it, +500
Banks & islands — instant crash
iolinked.com built for the arcade banner · © Ajai Raj
Home / Arcade playground / IO-RAID Explained ← Back to home

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.

Anatomy of the screen The parts of the IO-RAID playfield and what draws each one. fuel gauge — HUD overlay depot — the iolinked logo, drawn on canvas enemy — moves, shoots, dies the jet — you touch controls (mobile only) banks — land = death river — the safe channel
Everything you see is drawn straight into the canvas, frame by frame. The jet, the river, the enemies, even the iolinked logo on the fuel tank are maths and paths rather than images — which is the part I think is actually worth understanding.

◆ The diagrams in this piece are drawn by hand in Excalidraw on my Wacom.

Read along

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.

How I actually work

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.

If you're starting out

You'll leave knowing what a "game loop" is and why every game has one. That single idea unlocks most of the rest.

If you ship software

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.

If you've done this before

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 refreshabout 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:

river-raid.htmljavascript
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.

One turn of the loop The update, render, schedule cycle. update() move · burn · collide render() paint one frame rAF(loop) wait for refresh ~16.7 ms later, do it all again
The whole game is this triangle, spinning sixty times a second. Slow the spin and the game slows with it; that's why heavy work inside the loop is the cardinal sin.

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.

Five seconds, two machines
Two-lane stopwatch stage: one wall clock, a frame-tick comb, and per-lane distance rulers and fuel gauges. WALL CLOCK · ONE STOPWATCH, BOTH LANES 0.00 s 0s 1s 2s 3s 4s 5s ONE SECOND OF THAT CLOCK, MAGNIFIED — 60 frame ticks LANE A · FRAME-LOCKED this engine — scroll += speed, once per frame distance ruler · 0 → 2000 px 0 px flown FUEL 100.0 LANE B · DELTA-TIMED an illustrative alternative loop, not code from this engine distance ruler · 0 → 2000 px 0 px flown FUEL 100.0

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.

1 · predict — this unlocks the stage

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?

2 · the reader's refresh rate

Nothing in the engine changes when you press these. Only the comb does.

3 · run the clock

Both lanes tick on the one stopwatch above. Watch the two rulers, not the frame count.

Framesboth lanes tick alike
Px flownlane B: —
Fuel burntlane B: —
Shots firedlane B: —
Death anim (s)lane B: 1.17 s

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.

If a frame is the clock, what is the odometer? One scalar, and the jet is not on it.

■ 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.

Commit a prediction — the stage stays inert until you do. Then pick a refresh rate and press run 5 seconds: one stopwatch drives both lanes, and the comb beneath it is one second of that clock drawn in frame ticks. Press run the tank dry to race both fuel gauges to zero and read the wall-clock seconds off the stopwatch. Every count in the readout is computed here from the engine's own constants — 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:

the bridgejavascript
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:

where the jet actually isjavascript
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.

Screen pixels vs. world distance Mapping a fixed screen to an ever-growing world ribbon. SCREEN (fixed) y=0 y=H jet ≈ stays put WORLD (grows ↑) d = scroll + H d = scroll top of screen = furthest ahead bottom = where you are now dToY(d) = H − (d − scroll)
One number, 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.

World space, screen space, one step at a time
A world-space ribbon, the fixed screen strip, and the two bridge formulas between them. WORLD SPACE · drawn once one screenful off this map world px dToY(d) = H − (d − scroll) a world distance → a screen row same shape as offsetTop − scrollTop the number the loop advances scroll = 0.0 · yToD(y) = scroll + (H − y) a screen row → a world distance yToD(522) = 0.0 + 88 = 88.0 SCREEN SPACE · 420 × 610, fixed collision row 522.0 ONE SAMPLE, ALWAYS THE SAME ROW
frame0
scroll0.0
jet.x210.0
jet.y522.0
yToD(jet.y)88.0
predict
drive
camera
naive lane — an illustrative alternative, not code from this engine ·
Commit a prediction, then the stepper unlocks.
·
shipped · the jet is pinned storesscroll 0.0 per drawone subtraction collisionrow 522.0, moved 0× in 60f
naive · a camera follows the jet storesjet.d 0.0 · cam 0.0 per drawcamera on every object collisionrow 522.0, moved 0× in 60f
world · screen · the jet · the same bend, both panels · the map covers the first 1,400 world px
Right now: this page is not moving, scrollTop is — an element's screen position is offsetTop − scrollTop, the same subtraction as dToY above.
Commit a prediction, then drive the loop. Step one frame or ten and watch which of the five cells flash; the arrow between the two panels is the only arithmetic that ties a world distance to a screen row, and the violet diamond is one river bend marked in both spaces at once. Then switch the camera to the naive alternative and watch what happens to the row that collision has to test. The river drawn here is a fixed stand-in for the shape 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:

ensureNodes()javascript
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:

riverAt(d)javascript
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.

Four numbers become a canyon Sparse nodes interpolated into smooth banks. node · cx,hw smoothstep between nodes → flowing banks cx (centre) hw hw left bank = cx − hw right bank = cx + hw
A node is two numbers; smoothstep does the rest. Widen 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.

Three questions, one answer
THE WORLD, AT ONE ROW DRAW drawBanks() — banks + ripples cx ---.- hw ---.- left bank ---.- SPAWN spawnObjs() — lane(), depot x cx ---.- hw ---.- depot x ---.- COLLIDE update() — the kill test cx ---.- hw ---.- fatal bank ---.- AGREE
depth d
cx
hw
disagreement0.0 px
1 · drag the row 88 px above the bottom
2 · give each one a private copy. how long until they disagree by more than a jet width?
3 · who owns the river?
an illustrative alternative; this engine has only ever had one riverAt
red the bank 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.
Commit a timescale, then the copies split.
Drawing samples every 8 screen pixels and joins the samples with straight lines; collision samples the exact jet row. Same source, different resolution.
you met this on your phone this morning: the badge says 3 unread and the inbox shows 5, because the count is a second copy of a truth the list already holds
One description, then. So what stops that single description from drawing a canyon you cannot fly?
Drag the depth marker down the river — click a row, or use the arrow keys. DRAW, SPAWN and COLLIDE each print the 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.

Two guarantees, drawn to scale
▸ answer the fork to unlock the drives how far a segment can lean Δcx = 32 px node n+1 node n 64 px spacing 0.75 px sideways per 1 px forward both axes share one scale, so the drawn lean is the real lean what each can reach answer the fork to draw both envelopes TRAPPED at shift = 44 px and that shift is legal holding left, frame by frame 5.4 clamp 3.26 settled press the button to run it 18 frames = 0.3 s at 60 Hz
shift / node
node spacing
segment average
peak ×1.5
margin
Should a river be allowed to bend faster than the player can steer?
Both answers get priced here. There is no wrong choice — there are two different games.

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.

centre shift per node 32 px
right half: beyond anything pushNode can produce
node spacing 64 px
64 + rand×56, so 64 px is the shortest one built
throttle 2.70
SPD_MIN · SPD_DEF · SPD_MAX, px per frame
the steering recurrence vx = (vx − 0.62) × 0.84, frame by frame
stage
guarantee 1 · guarantee 2
verdict Commit an answer, then both worlds get priced.
the bank's steepest lean what the jet can reach bank outside the jet's reach corridor that survives
Two labelled simplifications. The steepest legal bend is the WORST case, not the typical one: it needs the full 32 px shift over the shortest 64 px segment, and every other segment leans less. And the settled steering speed is computed by this widget from the engine's own 0.62 and 0.84, not measured in play. Next: this guarantee holds per spawn, not per row. The next figure is where that distinction gets expensive.
Commit to the fork before the drives unlock, then push the centre shift past anything 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:

pushNode() — the width curvejavascript
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.

The squeeze, plotted Channel width over distance, eased. wide tight start ~40s in (d = 6500) t·t keeps it forgiving early — — linear (rejected: ramps too soon)
Squaring the progress is the difference between "this is fun" and "this is unfair." The dashed line is the version I threw away.
Principle

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.

Scrub the ramp until it breaks
▸ commit a burst size five ramps, one lid unlucky end heli lucky end 10,800 12,960 21,600 0 25,000 scroll the spawn tape — every object at its real depth one division = one screenful, 610 px press “roll 300 objects” to fill the tape first object tape empty +2,440 px of river enemy mix at this scroll: the five ramps, read at this scroll past the dotted line = past its own breaking point ceiling
scroll
prog = scroll/900
gap low
gap high
time · 60 Hz
Once the gap has closed to zero, enemies arrive in bursts until a depot or bridge roll re-opens it. How many enemies in a typical burst?
Pick one. Then measure it against the widget’s own rolls rather than mine.

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.

scroll 0
shaded: past 10,800, then past 21,600
jump to a marker
the scrubber also takes arrow keys, 20 px a step
throttle 2.70
moves the clock only; the tape always rolls at cruise 2.70
the spawner, from here 0.20 depot · 0.10 bridge · the rest enemy, frame by frame
how long is a burst this widget rolls them; nothing here is a stored constant
verdict Commit a burst size, then the scrubber unlocks.
enemy, 28 px deep fuel depot, 46 px bridge, 18 px a bound the code asserts a ramp with nothing stopping it notch = one object’s exact centre ticks are half-lit, so overlap goes solid
Three things this stage is honest about. Every second here is a frame count divided by 60, because the engine has no delta time — the loop figure earlier on this page is why that caveat has to be there, and on a 120 Hz screen every one of these numbers halves. The width ramp’s 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.
Commit a burst size first — the scrubber stays locked until you do. Then jump the scroll to each marked stop and read the gap low and gap high cells, roll 300 objects onto the tape at each one, and measure the bursts yourself instead of taking a number on trust. Every tick is drawn at the object’s real depth in the river, on one scale that never changes, so when they fuse it is because they really are on top of each other. One bar under the tape has a ceiling drawn on it. Four do not, and for the first minute you cannot tell them apart.

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 rowsy = 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.

drawBanks() — phase-locked samplingjavascript
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.

Beginner takeaway

Drawing a curve = pick points along it and connect them. More points = smoother. These points are spaced 8 pixels apart.

Builder takeaway

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.

Deep cut

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.

Stack eight frames and look at the edge
▸ answer to unlock the drives SCREEN-LOCKED samples at y = 0, 8, 16 … SAMPLES STANDING STILL IN A MOVING RIVER y = 64.0 fixed row water flows down the phase ((scroll + H) mod 8) 0.00 px step = 8 px H = 264 px speed 2.70 left: phase 0 right: live WORLD-LOCKED samples at y = phase + 8k y = 64.0 d = 200 both strips are drawn from the same curve — only the phase differs
frame0
scroll px0.0
phase px0.00
mark y s/w
bank jump s/w
chord err s/w
Both strips sample the same river every 8 px and join the samples with straight lines. One of them crawls. Which?
Commit either way — the drives stay locked until you do, and both readings get answered by the same eight frames.

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.

one frame scroll += speed, then both polylines are rebuilt
the proof ghosts eight consecutive frames and leaves them there
mark one vertex
screen row 64 · one fixed world distance
speed 2.70
SPD_MIN 1.70 · SPD_DEF 2.70 · SPD_MAX 4.60
start over frame 0, phase 0.00, ghosts cleared
verdict Commit which strip crawls, then step a frame.
The payoff line lands here once you have stepped a frame and stacked eight — one drive shows you the numbers, the other shows you the picture. Same river, same 8 px spacing, same maths. The only difference is where the first sample starts — so the fix was one modulo and not better maths: the numbers were always right, and the bug lived entirely in what the samples were nailed to.
the river's true edge the drawn polygon and its 8 px samples the marked sample ring = start · dot = 8 frames later s = screen-locked · w = world-locked
What is real here and what is not. Both strips are drawn from the same curve, evaluated by the same function at the same 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.
The name for this class of bug, the version of it you can reproduce in your own browser, and the hand-off to the next figure are all held here until you commit. The name for it is temporal aliasing — sampling a moving signal on a stationary grid — the same family as the wagon wheel that spins backwards on film, which this section's own builder takeaway already names. Note what the chord err cell keeps printing: at an 8 px step the drawn chords never leave the curve by much, in either strip. The accuracy was never the problem. What changes is whether that error is nailed to the glass or to the water. The same choice as 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.
Commit which strip crawls — the drives stay locked until you do — then press step 1 frame and watch the two sample grids part. One frame cannot show a crawl, so press stack 8 frames: eight consecutive frames are ghosted on top of each other and left there, which turns a motion artefact into a spatial one you can still read off a screenshot. Turn on mark one vertex and push the speed to 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.

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:

update() — steeringjavascript
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:

one handler, two input methodsjavascript
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.

The input pipeline How a press becomes movement. key ← or ◀ button keys.left = true a shared flag vx −= 0.62 velocity, with friction jet.x += vx + banking tilt
Two very different inputs collapse into one flag, and from there it's pure physics. Decouple early; your future self will thank you.

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.

Move a fraction of what is left
Three-lane stage: a position trace with per-frame step arrows, the recurrence written twice, and a noisy input with its output. ONE RECURRENCE, THREE JOBS — SHARING ONE FRAME AXIS a = 0.06 1 · THE RETURN — jet.y += (jetBaseY() − jet.y) * a the engine’s line, driven on a bench 0 15 30 45 60 frames frame — / 60 — resting line STEP THIS FRAME = a × the gap left 2 · ONE RECURRENCE, WRITTEN TWICE — THE COEFFICIENTS SUM TO ONE y += (target − y) * 0.06 ≡ identical, by algebra y = 0.94 * y + 0.06 * target ← 0.94 + 0.06 = 1.00 a weighted average 3 · THE SAME LINE, FED A NOISY TARGET an illustrative input trace, not code from this engine INPUT SWING OUTPUT SWING

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.

1 · predict — this unlocks the stage

Set the fraction to 0.06 and drop the jet 100 px below its resting line. What happens?

2 · the fraction a

The two marked ticks are the only fractions this engine actually uses. Everything between them is yours.

3 · how the step is chosen

Same starting gap, same 60 frames. Only the rule for choosing this frame’s step changes.

4 · drive it

Watch the little arrows in lane 1: each one is a single frame’s step.

a1 − a kept each frame
Frames to 50 %at 60 Hz
Frames to 90 %at 60 Hz
Gap left after 60 framesof the original 100 px

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.

Feel priced in one multiply. Next: an economy nobody wrote, priced in one addition.

■ 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.

Commit a prediction — the stage stays inert until you do. Then drag the fraction a and press drop the jet 100 px: lane 1 draws one small arrow per frame, so you are watching the step, not an easing curve. Lane 2 rewrites the same line as 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.
Eyes glazing over? No judgement — theory is heavy going. I'm bored — take me to the game ▶

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?

bank collisionjavascript
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:

circle overlap, no square rootsjavascript
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();
The unlock

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.

Two cheap tests Bank test and circle test. inside cx ± hw ? you live gap gap² < (r₁+r₂)² ? overlap
The bank test reuses the same 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:

the fuel loopjavascript
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.

One pass, counted frame by frame
▸ predict, then drive one pass, to scale drawn 2.9× one tick = one frame −34 +34 68 px depot 34 × 46 FRAMES INSIDE × 0.9 each COLLECTED BURNED BACK the test is on centres, not edges the tank, and what a pass buys FUEL 25 % BURN / FRAME 1.70 2.70 4.60 a full tank lasts — THREE DEPOTS, STILL NOT FULL
throttle
px / frame
burn
per frame
tank endurance
seconds
frames over
the depot
fuel per pass
net of burn
passes
20 → 100
At full throttle, one clean pass over a depot is worth how much of a 100-unit tank?
The engine has one refuelling line and it says 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.

throttle 2.70
SPD_MIN 1.70 → SPD_MAX 4.60 in the engine's own ±0.05 steps
the pass ticks the overlap frames one at a time, +0.9 each
the chain back to back at the throttle you have chosen
the tank 20 is the number that sends you into the guarded water
verdict Commit a fraction of a tank, then fly the pass.
a frame the overlap test is true fuel this pass put in burn, and the quarter-tank band the full end of the gauge a frame you never get at this throttle
Four labelled simplifications. A clean pass means crossing the window dead centre; a glancing pass overlaps for fewer frames and is worth less. 68/speed is rarely a whole number, so the last tick is a part-frame and the widget adds 0.9 × that fraction rather than a whole one. The three-pass chain counts only the burn during the passes themselves — the flying between depots costs more on top. And every number here is computed from FUEL_BURN 0.052, FUEL_MAX 100, the +0.9 refuel and the 68 px overlap window, not measured in play. On a phone this whole decision is missing. 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.
Commit a guess before the throttle unlocks, then drag it and watch the comb of frame ticks thin out — every tick is one frame the overlap test is true, and every tick is worth exactly +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:

loseLife() → the dying statejavascript
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:

one emberjavascript
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:

stepParticles()javascript
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:

updateDeath()javascript
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.

Anatomy of the boom The five ingredients of the death effect. ◍ shock rings — expand & fade ● fire — warm, gravity-pulled ● smoke — grey, swells, rises ▰ debris — chrome, tumbles / sparks — fast, short-lived each is one object in a list, fading to zero
A particle system is a to-do list that deletes its own items. The same five-line pattern powers the jet's exhaust trail and every enemy you pop.

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.

Crash it and watch the machine
Six live fields, a four-node machine with five conditional arrows, and a six-lamp subsystem strip. THE STATE OBJECT · six fields, one frame at a time THE MACHINE · loop() routes on state, and only one branch runs start() loseLife() deathT<=0 && lives<=0 deathT<=0 && lives>0 start() idle waiting play update() dying updateDeath() over nothing WHAT IS ACTUALLY RUNNING · six subsystems, this state ·
frames since impact deathT 0 particles 0 of 71 alive stepParticles() runs 0 shake 0.00 render() calls 0
predict You crash with two lives left. How many frames pass between the impact and the next frame in which a bank can kill you again?
drive
death is
The engine lane is this page's own code. The other two lanes are illustrative alternatives, not code from this engine. ·
Commit a frame count, then arm the crash.
· ·
gold · the field or arrow that mutated on this frame · green lamp · that subsystem runs in this state · dark lamp · it does not · pink · what an alternative design costs.
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.
Commit a frame count first — every drive stays locked until you do. Then press crash and walk the machine with step 1 frame, watching which of the six fields flash gold, which arrow lights, and which of the six lamps go dark. While the shield is up, press steer into the bank and read what the clamp does to 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:

the fix, in CSScss
/* 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.

Beginner takeaway

Fancy visual effects cost performance. If something runs badly, the first suspect is the prettiest thing on screen.

Builder takeaway

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.

Deep cut

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."

Still here? Respect. But the canyon is calling. Enough reading — let me play ▶

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:

#e10600
--chrome
The edge of the world, and the things worth hitting: chrome strokes the bank edge (COL.landEdge), the bridge, the logo on a fuel depot, and the debris when something dies. Not the jet — the jet is gunmetal.
#7fdbff
--accent · cyan
Friendly & system. Fuel, your bullets, the UI. Cool = safe.
#ffb454
--accent2 · gold
Energy. Afterburners, sparks, numbers worth noticing.
#ff6b81
--bad · red
Danger, always. Enemies, enemy fire, the moment you crash.
#6be58b
--ok · green
Health. The full end of the fuel gauge, and "go".
#b69cff
--pop · violet
Accent & numbers in code — the rare, special note.
#12161b
COL.land
The banks. Deliberately dull so the river reads as the path.
#05080f
COL.river
The safe channel. Almost black, with a faint blue current.

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.

Steal this

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:

  1. Change the feel. Open river-raid.html, find vx *= 0.84, and try 0.92 then 0.70. Feel the difference between ice and rigidity. You now understand friction.
  2. Reskin the river. Swap COL.river and COL.land in the engine's COL object for a desert palette — sand banks, dark canyon floor. You'll see how one source of colour drives the whole look.
  3. 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.
  4. 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.
  5. 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.
Go play first

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 loopupdate() changes numbers · render() draws them · rAF repeats
cameraone number, scroll; objects live in world distance, not pixels
bridgedToY(d) = H − (d − scroll) places anything on screen

The world

riversparse nodes (cx, hw) blended with smoothstep
difficultywidth = maxHalf · (0.94 − 0.60·t²), t = distance / 6500
no shimmerlock sample points to world space, not screen rows
the logo3 fillRects + one 18px-thick stroked circle

Feel

controlskey → flag → velocity → position; friction = 0.84
bank hitjet.x outside cx ± hw → dead
circle hitgap² < (r₁+r₂)² — never use square roots
fuelalways falling; overlap a depot to refill
deathits own state so particles animate during the pause

Web gotchas

flickerkill backdrop-filter over an animating canvas; translateZ(0)
colourwarm = you · cyan = friendly · red = danger

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 byRiver 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.

Author · Ajai Raj iolinked.com