iolinked.com Ajai's space

River Raid

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

SCORE0000000
Fuel100%

River 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 / River Raid Explained ← Back to home

Arcade · Build notes · How it works

River Raid Explained

I rebuilt the arcade classic — a jet climbing a narrowing canyon, shooting what shoots back, scrounging for fuel — and this is me walking through how it actually works. The loop, the scrolling river, how a finger-press becomes a banking jet, the collisions, the fuel clock, and the explosion when it all goes wrong. Less a victory lap, more the notes I wish someone had handed me when I started. By the end you should be able to build your own.

Anatomy of the screen The parts of the River 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 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:

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.

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.

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.

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.

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.

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 game. The first is where you start (almost the full width — generous). The second is how much you lose by the end (most of it — claustrophobic). I tuned those two numbers by playing, not by theory. That's the honest part of game design: you guess, you play, you nudge, you play again.

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.

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.

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.

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.
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 > right) loseLife();   // outside the water = into the land

For enemies and bullets I use circles, because a circle-to-circle test is the cheapest collision in games. Two things overlap if the distance between their centres is less than the sum of their radii. And you never compute the actual distance — square roots are slow — you compare the squared distance to the squared radius, which gives the same answer with only multiplications:

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.

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.

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.

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
You. The jet, the logo, the brand. If it's chrome, it's the hero.
#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
--land
The banks. Deliberately dull so the river reads as the path.
#05080f
--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 --river and --land 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.

Author · Ajai Raj iolinked.com