Build Notes · Game
Building IO-GRID — Dirty Air
IOIO-GRIDDirty AirBy the end of this you'll understand how a whole Formula 1 racer works from the inside out — the fake 3D, the way the car feels in your hands, the rivals that hunt you down, the dashboard, the fireworks. There's no magic underneath any of it. It's small, knowable ideas, stacked patiently on top of each other.
▶ Play it first, then read on00Before we start
Open up almost any game and the magic quietly dissolves into a handful of knowable ideas. That's the good news this article is built on: nothing here is beyond you. We're going to take a complete racing game apart on the table — slowly, in plain words — until you can see exactly how every part works and why it was built that way.
IO-GRID is a cockpit-view racer. You sit behind the wheel with the halo over your head and you steer with two keys, left and right. The throttle looks after itself; the corners do the slowing. You start dead last on the 2026 grid and claw forward, one overtake at a time, by tucking into the turbulent air behind each car to get the run that lets you pass. That turbulent air is the slipstream, or dirty air, and the game takes its name from it.
Two promises about how this is written, so you can relax into it. First, when a beginner term shows up, I define it right there, the first time, in plain words — no quiet assumptions that you already know. Second, every snippet on this page is the real code from the game you can play right now, not a tidied-up fairytale version. Where the code is a little scrappy, that's because shipped code is a little scrappy, and you deserve the truth rather than the brochure.
There's one rule worth pinning up before any of the rest of it, because it shaped every decision in the build:
Feel before features. A game can have every mechanic imaginable and still be dead in your hands. So we'll spend as much time on why a number is 0.13 and not 0.5 as we do on loops and functions. That part — the feel — is the part nobody writes down, and it's the part that turns code into a game.
Read it top to bottom if you like a story, or use the contents on the left to jump straight to whatever curiosity is itching. Each section stands on its own, but they're laid out in roughly the order I built things — and there's a reason for that order, which you'll start to feel as we go.
01The beautiful lie that makes it all work
Here's the first thing to swallow, because everything stands on it: there is no 3D in this game. None. The road peeling toward the horizon, the kerbs rushing under you, the car ahead swelling as you close in — all of it is a flat drawing, repainted sixty times a second. We're faking depth the same way Pole Position faked it in 1982 and OutRun faked it in 1986. Once you see the trick you can't un-see it, and that's a gift, because the trick is small.
Picture the road as a long stack of thin horizontal slices, each one a little further away than the last. A slice far away is tiny and sits high up near the horizon. A slice under your nose is wide and sits low. If you take any point in the world and shrink it by an amount that depends on how far away it is, a believable 3D scene falls right out of flat 2D drawing.
The maths for "shrink things that are far away" is one of the oldest ideas in computer graphics, and here it is in full — five lines that are, with no exaggeration, the spine of the whole game:
function project(p, camX, camY, camZ, depth){
var cz = p.z - camZ; // how far this slice is in front of the camera
if (cz < 1) cz = 1; // never divide by zero (or by something behind us)
var sc = depth / cz; // THE TRICK: closer = bigger, farther = smaller
p.X = W/2 + sc * (p.x - camX) * W/2; // where it lands left↔right on screen
p.Y = H*HOR - sc * (p.y - camY) * H/2; // where it lands up↕down on screen
p.Wd = sc * ROADW * W/2; // how wide the road looks at this distance
p.sc = sc; p.cz = cz;
}
project() — the whole illusion lives in sc = depth / cz.
Read sc = depth / cz slowly, because it carries everything else. cz is the distance from the camera to the slice. Divide a fixed depth by a big distance and sc comes out small — far things shrink. Divide by a small distance and sc comes out large — near things grow. Every other line just multiplies the slice's real position by that scale to find where it lands. W and H are the canvas width and height; HOR is where the horizon sits, about a third of the way down. There's the entire depth system, and I'm not holding anything back.
One detail that's easy to skim past but worth a moment: p.x - camX and p.y - camY. Those subtractions are how the world moves around the camera instead of the camera moving through the world. We never move the camera at all; we move everything else past it. When you steer, camX shifts, and suddenly the whole road slides sideways under you. That's a common and freeing way to think in games — keep the player at the centre of the universe and shove the universe past them.
"Project" just means "work out where a real-world point should be drawn on a flat screen." Further away → smaller and more central. That's the whole job.
project() writes its results back onto the point object (p.X, p.Y, …) instead of returning a new one. Over thousands of slices a frame, not creating throwaway objects keeps the garbage collector quiet and the frame-rate steady.
It's a pinhole-camera projection with focal length folded into depth. No matrices, no depth buffer — visibility comes later from painting back-to-front and tracking the highest pixel drawn. Cheap, exact enough for a road.
Perspective is division. Anything far away is divided by a bigger number, so it gets smaller. Understand that one line and you understand the spine of nearly every arcade racer ever made.
The projection ended in a division, and we let that pass as ordinary arithmetic. It is nothing of the kind. Divide by distance and every point in the world converges onto one vanishing point. Subtract a fixed amount per unit instead and the road never converges at all. Predict which of those two laws can draw a horizon, then drag a slice away from the camera and watch both laws paint at once.
0.9/cz, matched to it at cz = 5000.—
depth = 0.9, and two nested similar triangles whose runs are labelled 0.9 and cz — which is the entire reason the ratio can only ever be depth/cz. Now press double the distance three times and read the ladder: 80.0 → 40.0 → 20.0 → 10.0 canvas px, the same 520-unit trackside hoarding, halved exactly every time, off one number. That single factor is what sizes the tarmac, the hoardings (520) and the catch fence (+1000) together — and it is why standing twice as far from a doorway makes it look exactly half as wide, because a camera lens is a pinhole with glass in front and runs the same one-over-distance law. Then flip to linear falloff — an illustrative alternative law, not code from the game — and drag the slice out to see for yourself what a subtraction does to a horizon. Labelled simplifications: this is a pinhole model with no lens distortion, and the ray diagram’s distance axis is logarithmic, so 0.9 and cz are not drawn to scale — the triangles are, and the numbers printed on them are exact.02The heartbeat — a loop that never stops
Every game ever made shares one shape underneath, from Pong to the one with the photoreal puddles: do a tiny bit of thinking, draw the result, repeat forever. The browser hands us a perfect metronome for this called requestAnimationFrame — it runs your function right before the screen refreshes, usually sixty times a second. Here's the whole beating heart:
var last = 0;
function loop(ts){
if (cv.offsetParent === null){ last = ts; requestAnimationFrame(loop); return; }
var dt = Math.min(0.05, (ts - last)/1000 || 0); // seconds since the last frame
last = ts;
try { update(dt); render(); } // think, then draw
catch(e){ /* never let one bad frame kill the whole game */ }
requestAnimationFrame(loop); // book the next frame
}
nextCircuit();
requestAnimationFrame(loop);
Advance the world by dt seconds, draw it, ask for the next frame.
Two details earn their place, and both separate code that works on your machine from code that works on everyone's. The first is dt — "delta time", the seconds since the last frame. I never move the car "10 pixels per frame", because frames aren't all the same length: a tired phone draws slower than a fast laptop, and the car would crawl on the phone. Instead I move it "so many units per second" and multiply by dt. Now it drives at the same real speed everywhere. I clamp dt to 0.05 so that if the browser freezes for half a second — you switched tabs, a notification landed — the car doesn't lurch across the track on the next frame. It just resumes, calmly.
The second is the try/catch around update and render. In a long session there will eventually be one strange frame — some value goes briefly odd at a lap boundary, say. Without the catch, that one frame throws an error and the screen goes black and the game is over. With it, the game skips a single sixtieth of a second and carries on, and nobody ever sees it. I added it the hard way, after watching a game die on someone else's device over a thing I couldn't reproduce on mine.
And render itself? It's a strict running order, top to bottom: sky, distant hills and clouds, the grandstands, then the road slices far-to-near, then the cars and trackside objects, then the cockpit and the steering wheel over the top, then the dashboard and minimap last of all. Drawing is layered like a stack of transparent sheets — whatever you paint last sits on top. Get the order right and a complex scene assembles itself without a single "is this in front of that?" check.
A function is a named recipe. requestAnimationFrame(loop) means "browser, run my loop recipe again at the next good moment." Because the last thing loop does is book itself again, it runs forever — a snake that politely eats its own tail sixty times a second.
Move things per second, not per frame, and your game runs the same on a flagship and a five-year-old handset. It's one little * dt and it is non-negotiable.
We clamped dt at 0.05 and waved it past as a safety belt. It is doing something far stranger than that. Picture your phone lurching down to ten frames a second, mid-corner, mid-overtake. The car has two honest ways to react and only one of them keeps the game playable. Predict which one the clamp forces before you drag the frame-rate down.
03The one idea you'll use everywhere
If you take a single technique away from this whole article, make it this one. It shows up in the steering, the speed, the camera, the rivals, the tow, the warning lights — everywhere something needs to feel smooth instead of mechanical. It's called easing, and it's one line:
value += (target - value) * k; // each frame, close a fraction k of the gap
Step a fraction of the way toward where you want to be — every frame, forever.
That's it. Each frame you don't jump the value to its target; you move it a fraction k of the remaining distance. If k is small the value drifts in slowly and heavily; if it's large it snaps in quickly. The motion you get is smooth, weighty, and organic, and you get it for almost nothing. Engineers call this a low-pass filter. You can just call it the difference between a door slamming and a door swinging gently shut.
There's one wrinkle that matters once you care about different devices. "Close 13% of the gap each frame" depends on how many frames there are. So in the game I scale that fraction by a little frame-rate factor, f, computed at the very top of update:
frame++;
var f = Math.min(2.2, dt*60); // ≈1 at 60fps, ≈2 at 30fps — and capped
// ...then everywhere:
vx += (want*6.4 - vx) * 0.13 * f; // the ease, frame-rate corrected
tow += (towT - tow) * 0.1 * f;
speed += (tgt - speed)* 0.045* f;
f keeps the easing honest across frame-rates: on a slow device each step is bigger, so the feel matches.
The dt*60 says "how many sixtieths of a second was this frame?" At a smooth 60fps it's 1 and nothing changes. On a phone running at 30fps it's about 2, so each ease takes a double step and the car still feels right. The Math.min(2.2, …) cap is a safety belt: if one frame is catastrophically long, we don't take a giant lurching step, we take a merely large one. This is the kind of unglamorous detail that makes the difference between "feels great on my laptop" and "feels great, full stop."
Almost any value that should feel alive — position, speed, a colour, a camera — wants to ease toward its target rather than snap to it. Learn this one line and you'll reach for it a hundred times.
One line closes a fraction of the gap every frame, and the section calls it a filter. Why a fraction, though, and not a steady step of fixed size? A fixed step is simpler to write and it will never sit still. Predict what a constant step does when it finally reaches the target, then flick the strip and watch three motions race.
f is for, though as the readout admits k × f is a straight line standing in for a curve. The fixed 0.1 step is drawn only for the comparison; it is not in the game.04A road is just data
Before you can drive a track you have to describe one, and the trap most people fall into is reaching for a 3D modelling tool. You don't need it. A circuit is nothing but a list of corner points, traced from the shape of the track like the dots in a kid's connect-the-dots book. Each circuit is a small array of [x, y] points called mini — and that same array doubles as the minimap outline, so each track is described exactly once.
The atom of the road is a segment: one thin slice carrying a curve value (how much it bends) and the world positions of its near edge (p1) and far edge (p2).
function addSeg(curve, y){
var n = segs.length;
segs.push({ i:n, curve:curve,
p1:{ x:0, y:lastY(), z: n *SEG }, // near edge of this slice
p2:{ x:0, y:y, z:(n+1)*SEG } }); // far edge
}
One segment = one slice, with a bend and a near/far edge spaced SEG apart in depth.
A whole corner is then a run of segments whose curve eases up, holds, and eases back down. That ramp matters more than it looks. A corner that snaps instantly from straight to full lock feels robotic and cheap. Real corners arrive — they tighten, hold, release. So one helper lays a curve down with an entry, a middle, and an exit:
function addRoad(enter, hold, leave, curve, h){
var sy = lastY(), ey = sy + h, tot = enter + hold + leave, i;
for (i=0;i<enter;i++) addSeg(ease(0, curve, i/enter), ease(sy,ey,i/tot));
for (i=0;i<hold; i++) addSeg(curve, ease(sy,ey,(enter+i)/tot));
for (i=0;i<leave;i++) addSeg(ease(curve, 0, i/leave), ease(sy,ey,(enter+hold+i)/tot));
}
A corner: ramp the curve up over enter, hold across hold, ramp back to zero over leave.
Notice ease() doing the gentle ramps — the same idea from the last section, used here on geometry instead of motion. The h parameter even lets a stretch climb or fall, so the road can roll over a crest; feed the segment's y into the projection and the horizon dips and rises as you crest a hill. Same five-line projection, no new machinery — just a non-zero height.
Don't model what you can describe. A handful of numbers — a list of points, a curve, an ease — can stand in for a mountain of hand-built detail, and it's far kinder to your future self.
A corner is a run of segments whose curve ramps up, holds, then ramps back down. That ramp looks decorative, and it is the difference between a car and a hinge. Set the entry and exit to zero and the road still bends. Predict what the driver feels, then type a corner one slider at a time and read the numbers it writes.
addRoad(enter, hold, leave, curve, h) — every slider below writes the array live.addRoad(enter, hold, leave, curve, h), and each slider rewrites the segment array live: the left panel is one bar per segment, bar height equal to that slice's curve, so a corner is a ramp‑hold‑ramp trapezoid you can see; the right panel is the array itself, and write the array ▸ types it eight rows at a time so you watch the numbers land rather than appear. (The four flat rows top and tail are the straight either side of the bend, laid by the same helper with curve = 0, so the corner has something to interrupt.) The ramp is drawn by the engine's own blend, ease(a,b,p) = a + (b−a)*(p*p*(3−2p)) — a smoothstep, which is why the traced line is an S and not the faint straight chord behind it. Worth saying plainly, because the same word does two different jobs on this page: this ease blends between two values across a known span you choose, while the per‑frame filter from the easing section chases a moving target forever and never quite arrives. Then drag enter and leave to zero and watch the fourth readout, biggest jump between neighbours — that one number is the whole argument, and it is the only thing on the panel that settles the question you committed to. Real roads are built with this same ramp — a motorway slip road has a transition curve so the curvature grows steadily and you turn the wheel at a constant rate. Zero the ramps here and you have just drawn the junction no engineer would sign off; look at the next slip road you take and you will see the ramp you just deleted.05Tracing a real circuit
So how does Monza become a track you can drive? I take its outline as those mini points and walk them, measuring how sharply the track turns at each one. A gentle kink gets a soft, short bend; a hairpin gets a hard, long one. Sharper angle, sharper curve — that's the whole translator:
for (var i=0;i<n;i++){
var turn = angNorm(head(i) - head(i-1)); // how much the track bends here
var sharp = Math.min(2.5, Math.abs(turn)*2.4 + 0.35);
var hold = Math.round(clamp(Math.abs(turn)*40, 16, 60));
var dir = turn >= 0 ? 1 : -1; // left or right?
if (Math.abs(turn) > 0.12)
addRoad(hold*0.55|0, hold, hold*0.55|0, dir*sharp, 0);
// ...then lay a straight for the rest of this edge
}
Reading a circuit's shape, corner by corner, and turning each one into track.
A few small things, because the small things are where the craft hides. head(i) is the compass heading of the edge leaving point i; subtracting consecutive headings gives the change in direction — the turn. angNorm wraps that into a tidy range so a turn never accidentally reads as "spun all the way round." The line turn >= 0 ? 1 : -1 is doing something almost philosophical in one expression: a positive turn bends one way, a negative turn the other, and this collapses that into a clean +1 or -1 to multiply the curve by. And hold*0.55|0 uses an old trick — the |0 chops the decimal off fast, turning 17.6 into 17. You'll meet it in a lot of game code; now it won't look like a typo.
The clamps — Math.min(2.5, …) and clamp(…, 16, 60) — are guard-rails. They stop a freak angle from producing an undriveable hairpin or a hold so long the lap never ends. Defensive numbers are every bit as important as defensive code; a value that can't go out of range is a bug that can't happen.
Because the same mini points draw the minimap and build the road, the circuit is honest with itself: the shape in the corner of your eye is the shape you're driving. Add a new track and you add exactly one array of points. That's the quiet payoff of treating the road as data — your content pipeline becomes "type some numbers."
"Tracing" means: look at the next dot, then the one after, and ask "did the line turn left or right, and by how much?" Build that much of a bend. Repeat for every dot.
This is procedural generation from a low-res polyline. It trades fidelity for control: you won't get Monza to the centimetre, but you get a track that plays like Monza — fast, flowing, light on corners — for the cost of twenty numbers.
Subtracting consecutive headings gives the turn, and two clamps then decide what that turn becomes. Those clamps are the whole difference between a circuit and an undriveable spike. Predict what happens when you drag one map point into a needle. Then step the tracer vertex by vertex and read the instruction it prints for each one.
turn ≥ 0 →
dir = +1 → a right-hander.turn = angNorm(head(i) − head(i−1)), drawn here as the actual filled angle between the two edges, because the turn is an angle. Three lines then decide everything, and the readouts print each one as it is computed: sharp = min(2.5, |turn|·2.4 + 0.35), hold = round(clamp(|turn|·40, 16, 60)), and the gate if (|turn| > 0.12). Watch the two gauges while you drag: each has its hard stop drawn on it, and the dashed marker beyond a stop is the value the formula wanted. step to the next vertex ▸ walks the tracer round the polygon so you can see every corner on the circuit priced the same way. Two things are worth breaking on purpose. Fold the point into a spike and watch both gauges; then ease it flat until |turn| drops under 0.12 and watch what becomes of the corner — and of the ribbon running through that point. And the pill under the map is the everyday version of exactly this arithmetic: your phone turns a list of GPS fixes into turn‑by‑turn directions by subtracting consecutive headings and calling anything past a threshold a turn. Drag the point flat and watch TURN RIGHT become CONTINUE STRAIGHT under your own hand — that is the decision your phone made for you on the way here. A polygon can hand this builder any angle at all, including one that folds the track back on itself — which is why three lines of arithmetic, and not the map, get to decide what a corner is. (Two labelled simplifications. The metres in the pill are illustrative, scaled so the lap totals the circuit's real 5.793 km; the engine's own number, the straight it writes after this corner, is printed beside them. And the right-hand ribbon previews the shape those curve values draw, at a fixed illustrative bend rate — the segment array itself still stores a straight tube, as the earlier figure showed.)06The camera that makes the road bend
Here's a question that stumps people the first time: each segment only stores a tiny curve number. So how does a long sequence of tiny curves add up to a sweeping corner that slides across your whole screen? The answer is a running total, accumulated as we walk the slices outward from the camera:
var x = 0, dx = 0;
for (var n=0; n<DRAW; n++){
var s = segs[ base + n ];
project(s.p1, camX - x, camY, camZ, CAM.depth);
project(s.p2, camX - x - dx, camY, camZ, CAM.depth);
x += dx; // shift this slice sideways by the curve built up so far
dx += s.curve; // and let the curve accumulate for the next slice
// ...draw the slice...
}
Curve accumulates twice: dx grows by each slice's bend, and x grows by dx. Slight bends compound into a sweeping corner.
This is the same maths that makes a thrown ball arc. dx is like a sideways velocity that grows a little with every curved slice; x is the sideways position that grows by that velocity. So a stretch of gently-curved slices doesn't just nudge the road over a touch — it bends it further and further as it recedes, exactly the way a real corner disappears off to one side. Two accumulating totals, and a flat list of slices becomes Eau Rouge.
And the camera follows you through it. camX is driven by your position on the track, so as you drift toward the outside of a bend, the whole world shifts to keep your car centred under the wheel. We're back to the idea from the projection section: the camera never really moves through the world — the world streams past a camera that mostly sits still, nudged left and right by where you've placed the car.
Accumulation — keep a running total, add a little each step — is how curve becomes corner here, and it's also how velocity becomes position, how the lap distance grows, and how the engineer's clock ticks. Spot it once and you'll see it all over games.
Earlier the segment data quietly pinned x to 0 on every edge, near and far. The road is a straight tube; nothing in the geometry ever bends. So where does this sweeping hairpin actually come from? Commit before you drag the curve below. Is the corner stored in the track, or painted into it one slice at a time?
p1.x and p2.x read 0.00 for every slice and never budge. The right panel is the cockpit render of that same array, and it bends into a full corner. step accumulator ▸ walks the draw loop slice by slice, printing x += dx then dx += curve, so you watch the bend built at draw time from a straight source — x climbing while p1.x stays pinned at 0. Then kill the fractional seed and drive: the road steps every 200 units, because dx = −(curve·basePct) was the only thing smoothing a quantised road into one continuous curve. The corner you have been fighting is not in the geometry anywhere — curve is a drawing instruction, and every corner on every circuit is a straight tube you choose to paint bent.07Painting the world, far to near
Now we have slices, a projection, and a camera. Drawing the road is one loop: project a slice and the next one, fill the trapezoid of tarmac between them, repeat toward the horizon. Each fill is a tiny helper:
function quad(x1,y1,w1, x2,y2,w2, col){
ctx.fillStyle = col;
ctx.beginPath();
ctx.moveTo(x1-w1, y1); ctx.lineTo(x1+w1, y1); // near edge, left → right
ctx.lineTo(x2+w2, y2); ctx.lineTo(x2-w2, y2); // far edge, right → left
ctx.closePath(); ctx.fill();
}
A trapezoid: wide near edge, narrow far edge. Stack a few hundred and you have a road.
But you can't just draw every slice blindly — some are behind you, some are hidden behind a crest. So before drawing, each slice gets two quick checks, and this little line earns its keep:
if (s.p1.cz <= depth || s.p2.Y >= s.p1.Y || s.p2.Y >= maxy) continue;
// ...draw the slice...
maxy = s.p2.Y; // remember the highest point we've painted up to
Skip slices behind the camera, slices that fold over, and anything hidden behind a hill we've already drawn.
cz <= depth drops slices behind the camera. p2.Y >= p1.Y drops a slice that has folded inside-out at the horizon. And p2.Y >= maxy is a poor man's occlusion: maxy tracks the highest pixel we've drawn so far, so a slice hidden behind a hill we already painted is simply skipped. That one variable replaces an entire depth buffer for the special case of a road that never doubles back over itself. It's the kind of trade — a whole subsystem swapped for one remembered number — that arcade rendering is built on.
The full per-slice recipe, in order: a flat tarmac quad, two thin edge strips for the kerbs (red-and-white), the white lane lines, a dashed centre line, then grandstands and trees as their own slivers further out. And the order is the whole trick — far slices first, near slices last, so nearer things naturally cover farther things, exactly like the lamppost in front hiding the one behind. Painters have done this for six hundred years. We borrowed it and called it an algorithm.
One bug from here is worth more than the code. Faint horizontal lines kept crawling across the road and drove me up the wall. The cause was vanity: I'd painted the tarmac in two slightly different greys, alternating slice by slice, to suggest motion. At speed those bands strobed into visible stripes — the same illusion that makes a spinning wheel look like it's going backwards. The fix was almost funny: paint the tarmac one flat grey, and let the dashed line and kerbs carry the speed. The road got simpler and looked better.
A bug is often the right instinct applied one notch too hard. I wanted motion, so I added contrast everywhere — and the contrast turned into strobing. The lesson I keep relearning: when something looks wrong, try doing less before you try doing more.
One remembered number just replaced a depth buffer, and that is a bargain worth inspecting. A ceiling that only ever rises can decide what is hidden, but only if the slices arrive in the right order. Run the same road three ways and count the paint. Predict which ordering paints each pixel once, then watch a dip behind a crest either vanish or smear straight through the hill.
maxy, started at H.maxy starts at H, the bottom of the picture, and every slice that lands drags it up. A slice whose far edge sits at or below that line is skipped without being drawn at all — the readouts print the slices painted, the total row-paints and the worst row for whichever of the three combinations you build. Then drag crest height: at 0 the road is flat, nothing is behind anything, and the ceiling saves nothing; push it to 900 — the same amplitude the engine rolls with (Math.random()−0.5)*900 — and the road beyond the brow simply stops existing. You have watched that happen from a car: cresting a hump-backed bridge, the road on the far side comes back one row at a time. Your eye never drew what was behind the hill either, and it did not need a depth buffer to work that out. 14 slices are shown here; the engine runs DRAW = 185 of them, so read the counters as proportions, not as frame times — and at part-height crests the widget will honestly show you one row painted twice, the seam where the road climbs back over the ceiling, spread in the real engine across slices about 13× thinner.08Steering with one finger
This game has exactly one axis of control: left or right. No brake, no separate throttle. I chose that so it plays beautifully on a phone with a single thumb. But "simple controls" is the opposite of "simple to get right." A car that snaps to your input feels like a supermarket trolley; a car that lags behind you feels like a barge. The good stuff lives in the narrow band between, and you reach it with easing:
if (spinT > 0) spinT -= f; // count down any spin first
var want = spinT>0 ? 0 : ((keys.r?1:0) - (keys.l?1:0)); // -1, 0, +1 (locked out mid-spin)
vx += (want*6.4 - vx) * 0.13 * f; // glide toward the target turn-rate
vx = clamp(vx, -6.4, 6.4);
playerX += vx * 0.0027 * f * (0.5 + sp*0.85); // faster car covers ground quicker
playerX -= pSeg.curve * sp * 0.0003 * f; // corners gently push you wide
if (!want) playerX *= Math.pow(0.975, f); // let go → drift back to the line
playerX = clamp(playerX, -1.04, 1.04); // can't leave the world entirely
The whole driving model. Every line is a feel decision wearing arithmetic as a disguise.
Look hard at the third line, because it's the most important pattern on this page. I don't set the turn rate, I approach it: vx += (target - vx) * 0.13 * f — "each frame, close 13% of the gap between where the steering is and where I want it." That single move gives smooth, weighty, organic steering for free. And that 0.13? I'll be honest about how it got chosen: I sat there for an afternoon and turned it. Lower, and the car felt heavy and reluctant, like steering through syrup. Higher, and it felt twitchy, darting at the smallest touch. 0.13 is where it felt like a car. There's no formula for that number — just you, the screen, and the willingness to keep nudging until your hands agree.
It isn't in the graphics or the physics. It's 0.13. Spend your time there. A racer lives or dies on whether the car feels good in the boring half-second before anything happens. Get that, and the player forgives almost everything else.
The rest of the block is the same "small constant nudge" idea, used three more ways. The (0.5 + sp*0.85) factor means the car turns in more sharply the faster you're going — speed makes the steering bite, which feels right because at speed you cover ground quicker. The corner-push line shoves you toward the outside of every bend, so you have to lean in; that's what makes a corner feel like a corner and not a painted curve. And when you release the keys, the car eases back toward the centre line on its own, so even a relaxed player looks planted. The car wants the racing line. You're only ever suggesting.
That steering block hid a decision players feel but never question. A real car fights you harder the faster it goes: grip bleeds away, and the corner flings you wider with the square of speed. This game does the exact opposite on both counts. Guess how the real car and this car pull apart as speed climbs, then drive the slider and watch the two lines split.
0.5 + sp*0.85 and a linear push. Flip to the REAL model at speed and the car you were holding on the line washes straight off. Next: if speed is this friendly, the only thing left to fear is another car — meet the rivals.0.5 + sp*0.85 grip and its linear push — pull away from the faint real-car sketch: game grip climbs to 1.35× while a real tyre's would fall, and the game's push stays gentle while reality's squares up. Then flip the car to the REAL model and hold the same line: somewhere past ~258 km/h it understeers straight off the road the game-model car was glued to. The two numbers most flatly wrong against physics are the two that turn arithmetic into a car you trust — a faithful simulation here would feel worse, not better.09Speed and the racing line
The car's speed is — you've guessed the pattern by now — a value easing toward a target. The target depends on where you are on the road: dead on the line you get full pace; drift toward the grass and the target drops, so wandering wide quietly costs you. Here's the core:
var edge = clamp(Math.abs(playerX), 0, 1); // 0 = on the line, 1 = on the grass
var tgt = MAXS*(0.82 + 0.18*(1 - edge)) + tow*2300; // your target speed right now
if (pushing) tgt += 3600; // ERS boost, if you're spending it
if (boost > 0) tgt += 3200; // a slipstream "pop" after a tow
if (spinT > 0) tgt = MAXS*0.3; // spun off? crawl for a moment
speed += (tgt - speed) * 0.045 * f; // ease toward it — no instant jumps
if (edge > 0.99 && speed > MAXS*0.62) speed = MAXS*0.62; // two wheels on the grass = capped
pos += speed * dt; // and finally, move down the road
Speed is a target you ease toward; the racing line and the slipstream raise the target.
Notice there's no "physics" anywhere — no forces, no friction coefficients, no torque curves. The car doesn't have a speed it accumulates; it has a speed it's always gliding toward. That's a deliberate simplification, and it's the right one for an arcade racer. Players don't want a simulation of a clutch. They want the road to rush at them when they're brave and ease off when they're sloppy, and a target-plus-ease delivers exactly that with a tenth of the code.
The last line, pos += speed * dt, is the odometer of the entire game, and it's worth dwelling on. pos is how far you've travelled in total, ever — it only ever grows. Your lap number, your dot on the minimap, which slice the camera sits on, the lap-change countdown — all of it is derived from that one number. When you cross the line I don't reset pos; I remember its value at the start of the lap (lapBase) and subtract. One honest source of truth, everything else computed from it. That habit alone will save you from a hundred sync bugs where two counters that should agree quietly drift apart.
For arcade feel, don't simulate forces — ease toward targets. "Where should the car be heading right now?" is a far easier question than "what are all the forces on this car?", and for a game it's usually the better one.
Wandering wide quietly costs you, and the section leaves the size of that bill unread. Every centimetre off the line moves one number, and the number is your top speed. Predict what half the road's width is worth before you drag. Then push the car past the white line and watch a gentle slope turn into a cliff.
tgt = MAXS*(0.82 + 0.18*(1−edge)), a straight, forgiving taper from the racing line out to the paint, priced by the readouts in km/h and in lap time. Then keep going. Past |x| = 0.99 — the hatched strip you could see coming — the taper stops mattering: one line of code slams your speed to 222.4 km/h and the bar collapses 72 km/h in a single step, the width of a white line. Switch the tow on and the slipstream’s +2300 units stacks on the same taper, then gets capped away with everything else. You have felt this shape from the passenger seat: a car drifting onto the ribbed edge of a motorway makes noise and drag while the wheels are still on the paint, and lurches the moment they leave the tarmac. That rumble strip, written as arithmetic — small errors cost you almost nothing, two wheels on the grass cost you everything.10Dirty air — the mechanic it's named after
In real Formula 1, the churned-up air behind a car is a curse in the corners but a gift on the straights: tuck into it and the hole the car ahead punches in the air drags you along faster. That's the slipstream, the tow, the dirty air. It is the soul of overtaking, so the game takes its name from it. Mechanically it's small and lovely — when you're close behind a rival and roughly on their line, your top speed quietly climbs:
// tucked in behind a rival → build up tow
if (!battle.passed && dz > 0 && dz < SEG*18 && Math.abs(battle.x - playerX) < 0.55)
towT = 1 - dz/(SEG*18); // the closer you are, the more tow
tow += (towT - tow) * 0.1 * f; // ease toward it (of course)
// ...and earlier, in the speed target: tgt += tow*2300;
Get close and on-line and your speed target lifts; drop back and the tow fades.
Read the condition closely, because it encodes the real-world feel. dz is the gap to the car ahead; dz > 0 && dz < SEG*18 means "behind them, but within about eighteen slices." Math.abs(battle.x - playerX) < 0.55 means "roughly on their line, not way out to the side." Only then does towT rise — and it rises more the smaller dz gets, because 1 - dz/(SEG*18) goes from 0 at the edge of range to nearly 1 right on their gearbox. Then the usual ease smooths it so the tow swells and fades instead of flicking on. Every clause there is a sentence about how a slipstream actually behaves.
The tow gets you close. To finish the job you spend ERS — the Space bar — and crucially, the ERS isn't infinite. It's a battery that drains while you hold it and refills when you don't:
var pushing = keys.boost && ersBat > 0.02 && spinT <= 0;
ersBat = clamp(ersBat + (pushing ? -dt*0.5 : dt*0.16), 0, 1);
~2 seconds of full boost, then ~6 seconds to recharge. That one limit makes it a game.
I can't overstate how much this single decision mattered. Before the battery, holding Space let you blow past the entire field on every straight, and it was boring within a minute — there was no decision, just a button. The moment the boost became a resource that says "no" sometimes, every overtake turned into a tiny plan: when do I spend it — now, to close the gap, or in three seconds, to make the pass stick before the corner? That gap, between an interaction and a decision, is the gap between a toy and a game.
Infinite anything is rarely fun. The most interesting mechanic in this whole game is a battery that occasionally refuses you. When a feature feels flat, don't ask "what can I add?" — ask "what can I take away, or ration?"
Everything so far treated the tow as a pure gift. But the game is named Dirty Air, and in real Formula 1 that churned-up air is mostly a curse: in the corners it strips your downforce and washes you wide. So here is the honest question the whole game is built on. When you tuck into a corner right on a rival's gearbox, does the code punish you the way the name promises?
11Contact, and the spin that costs you
Touch another car at the wrong moment and you spin. It needs to sting enough to matter and not so much that the game feels mean. The whole crash is six values set in one breath:
function crash(){
if (invuln > 0) return; // brief immunity so one touch isn't five crashes
spinT = 22; // frames of spinning, locked out of steering
invuln = 64; // and a moment of grace afterwards
speed *= 0.5; // scrub half your speed — the real punishment is time
shake = 9; combo = 0; tow = 0; // a screen kick, your streak gone, your tow gone
axonTxt = AXONSPIN[ Math.random()*AXONSPIN.length |0 ]; // your engineer reacts
axonT = 1.8;
}
A spin is a fistful of state changes; the one that actually hurts is the lost speed, which costs you time.
Three deliberate choices live in those lines. The invuln timer means a single clumsy nudge can't register as a rapid-fire string of crashes — without it, brushing a car for three frames would spin you three times and feel broken. The real punishment is speed *= 0.5: I scrub your pace, which costs you time, which is the currency that matters in a race. And notice what's not here — contact does not drop you a position. I learned that one painfully (it's in the field notes): when a spin both slowed you and demoted you, the game felt like it was punching down, and players quit. Time lost is fair. A place stolen by a tap is not.
While spinT counts down, two other parts of the code quietly read it and do the right thing: the steering target is forced to zero so you can't fight the spin, and the wheel on the dashboard whips back and forth on its own (steer = Math.sin(frame*0.55)*0.26) so your hands visibly lose the car. One number, spinT, read in three places, and the whole moment hangs together. That's the value of a single honest piece of state — everyone who needs to know just asks it.
Contact halves your speed and locks the wheel, and a grace timer then refuses to do it again. Both halves of that sentence are load bearing, and the second one is invisible until you remove it. Predict how much speed survives a twelve frame scrape with the grace timer off. Then race the same overtake under three different cost models and read the stopwatch.
predict
cost model · chosen before the run
drive
grace timer
the debounce you have already met
tgt = MAXS×(0.82 + 0.18×(1−edge)). Both cars are stepped through the engine's real update line at 60 fps — speed += (tgt−speed)×0.045, with tgt = MAXS×0.3 while spinT is counting — so every number in the strip is measured off the run, never asserted. Two of the three cost models are alternatives written for this figure and labelled as such; only speed × 0.5 is the game's. Race no penalty and watch which line reaches the flag first; race lose a position and watch the P8 beside the ram lane tick away for contact it did not start. Then take the 64-frame grace timer away, hold the contact for 12 frames, and count the crash() calls yourself — then press PAY once, with the timer left exactly as you set it, and count the charges. The collision test is the plain box in the inset, dz < SEG×1.2 && dz > −SEG×0.4 && |dx| < 0.22 — noticeably roomier than the car it is drawn around, which is why a near miss sometimes isn't one.12Rivals that feel alive
There are twenty-two names on the grid, but I don't run twenty-one full physics cars. That would be expensive and, worse, pointless — you can only ever fight the one car next to you. So the game keeps one rival you're chasing and, now and then, one rival attacking from behind. Everything else is bookkeeping in the standings. The two cars you actually race get just enough behaviour to feel real, and not one line more.
The rival ahead runs a pace just under yours, then leans across to defend the side you're attacking from — but only part of the way, so there's always a gap if you're brave enough to take it:
var rivTgt = speed * ((rc>3 ? 0.78 : 0.92) + frontF*0.06); // close pace; slower in corners
var defCap = 0.30 + frontF*0.18; // leaders defend more road
var defTarget = close ? clamp(playerX*0.6, -defCap, defCap) : 0;
battle.defX += (defTarget - battle.defX) * 0.012 * f; // slow INTENT: where it decides to go
battle.x += (battle.defX - battle.x) * 0.035 * f; // then the car eases onto that line
A defending driver is two eased values quietly chasing your position — never twitchy, always a touch late.
Why two eased values instead of one? Because a single value snapping to your position would feel like the rival is glued to you, reading your mind. The first value, defX, is the rival's slow intent — where it has decided to go. The second, x, is the car physically easing onto that intent. Layering a slow decision under a slower movement is what makes a defence feel like a human deciding and then moving, rather than a magnet tracking you. Two filters in series, and a number starts to feel like a person.
And look at frontF threaded through both lines — it grows as you climb the order, so the cars at the front run quicker and defend harder. The game gets tougher exactly where it should, which means you earn the podium rather than stumble onto it. The rc>3 check drops the rival's pace in corners (0.78 vs 0.92 of your speed), because a real car can't defend and carry full speed through a bend — so the corner is where you line up the move and the straight is where you complete it, exactly as it should be.
Players don't experience your architecture; they experience the moment. A rival that's two eased numbers and a coin-flip can feel every bit as alive as one with a "real" brain — if the moment lands. Spend your cleverness where the player is actually looking, and nowhere else.
We called the rival's pace 'just under yours.' Look closer and that is not a metaphor, it is the formula: their target speed is your speed times a constant, never a number they own. So what happens if you simply give up and cruise? Commit to an answer, then drag your own speedometer and watch the entire field answer to it.
13Getting overtaken
For a long while you could only ever gain places, and the game felt like a one-way escalator — pleasant, but with no jeopardy. So I added an attacker: drop your pace or wander off-line and a car comes from behind, grows in your mirrors, slides alongside, and pulls ahead. The part I'm fond of: the instant it completes the move it simply becomes your new chase target, so you can fight straight back.
if (adz > SEG*0.5){ // they've fully come through
if (youIdx < 21){ swap(order, youIdx, youIdx+1); youIdx++; } // you drop a place
axonTxt = attacker.code + ' THROUGH — GET HIM BACK';
battle = makeBattleFrom(attacker); // the car that passed is now the car ahead
}
Being passed isn't a dead end — it hands you the next fight, immediately.
Whether the attacker actually gets through depends entirely on you, and it comes down to one honest line:
var youQuick = speed > MAXS*0.82 && Math.abs(playerX) < 0.78; // defend = fast AND on-line
var atkTgt = youQuick ? speed*0.99 : speed*1.045; // they stall, or they pass
Defending is a verb: keep your speed up and your line tidy and the door stays shut.
If you're quick and tidy, the attacker's target speed drops just below yours and it never completes the move — it hangs in your mirrors and eventually drops back. Get lazy on either count and its target lifts above yours and it's through. The whole drama is visible: the dot growing in the mirror, the car drawing alongside, the slide past, the engineer in your ear. That visibility is the point. Jeopardy only feels fair when the player can see it coming and believe they had a hand in it. A place lost off-screen is just the game being cruel. A place lost while you watch yourself drop your pace is a lesson — and lessons are the fun part.
Make consequences visible. Players accept almost any setback if they can see it arrive and believe they could have stopped it. The same event — losing a place — is infuriating off-screen and thrilling on-screen.
Both rival sections leaned on one comforting promise: a gap always exists. That was never luck, and never the game being kind. The defender is wired so a gap is forced to stay open no matter how hard it tries. Swerve the car across its nose below and try to slam the door shut for it. You are going to find that you cannot.
14The running order
The standings down the side are a single array, order, holding the driver codes top to bottom, with the string 'YOU' sitting at index youIdx. An overtake isn't a physics event in the standings — it's a swap of two neighbours in that list. Here's a clean pass, the whole thing:
else if (!battle.passed && dz < 0){ // the rival is now behind you → you passed
order[youIdx] = order[youIdx-1]; // the car ahead drops into your old slot
order[youIdx-1] = 'YOU'; // and YOU move up one
youIdx--;
combo++; popT = 1.5; // start a celebratory pop, bump the streak
popTxt = 'P'+(youIdx+1) + (combo>1 ? ' x'+combo : '');
boost = Math.max(boost, 0.7); // a little reward shove out of the pass
battle = null; if (youIdx>0) spawnBattle(); // line up the next car to chase
}
An overtake is two array slots trading places, plus a flash of feedback and the next target.
The condition is the elegant part. dz is the gap to the car ahead; the moment it goes negative, the rival is behind you, which means — by definition — you passed. No collision maths, no "did I cross them?" test. The sign of one number is the entire overtake detection. When you can reduce an event to "the moment this value changes sign," you've usually found the clean way to model it.
The little combo counter is pure player psychology. String passes together without spinning and it climbs — x2, x3 — and the engineer starts calling you "on fire." It costs almost nothing and it turns a sequence of overtakes from a list of events into a run, something you don't want to break. A spin resets it to zero, which is why a clumsy moment stings beyond the time you lose: you also drop your streak. Cheap counters that reward sustained skill are some of the highest-value lines in any game.
15Faking the rest of the grid
If only two rivals are ever simulated, how does the minimap show a full field of coloured dots fanned around the circuit? Honestly: I fake it, and I'll show you exactly how, because the honesty is the point. I don't have positions for nineteen other cars, so I derive believable ones from the running order — spreading each driver around the lap based on how far ahead or behind you they sit in the standings:
for (var di=0; di<order.length; di++){
if (order[di] === 'YOU') continue;
var gp = (((pr + (youIdx-di)*0.02) % 1) + 1) % 1; // their PLACE → a spot on the lap
var qi = Math.floor(gp*pts.length) % pts.length;
var qp = mp(pts[qi][0], pts[qi][1]);
ctx.fillStyle = teamCol(order[di]); // each car in its team colour
ctx.beginPath(); ctx.arc(qp[0], qp[1], 2.1, 0, 6.28); ctx.fill();
}
Nineteen "ghost" dots: positions invented from the standings, painted in team colours.
The maths is just an offset from your own progress, pr. A car one place ahead of you (youIdx-di = 1) gets nudged 0.02 of a lap further round; a car five places back gets nudged the other way. The double-% 1 dance — ((x % 1) + 1) % 1 — is a tidy trick to wrap any number into the range 0–1, even if it went negative, so a car "behind the start line" loops cleanly round to the end of the lap. Those dots are cosmetic: the gaps aren't a real simulation, they're a plausible picture. And that's a completely legitimate thing to do — as long as you're clear-eyed about where the line is.
The two things that have to be exact are exact: your own arrow on the minimap, and the red start/finish marker you're racing toward. The decorative field can be impressionist; the things you make decisions on cannot. Knowing where to spend reality is a craft in itself. A full 22-car physics sim would have cost days and bought the player almost nothing, because they never see those cars up close. The one ahead and the one behind get real, careful behaviour. The rest get a paint job. Budget your effort the way the player budgets their attention.
I'd rather tell you the far field is partly faked than have you discover it and feel tricked. The team colours and the spread are believable; the exact gaps between distant cars are not "real." Your position and the finish line are. Always know which of your numbers are load-bearing.
Nineteen dots are derived from your own lap fraction with a fixed offset per place. Derived means they cannot drift, and it also means they cannot race. Predict what the field does while you drive a whole lap. Then drag your own progress, swap two places in the order, and watch a dot teleport.
mini point array, the order array beside it as twenty‑two rows with YOU highlighted, and the arithmetic for whichever dot you click. Watch the Δpl and off columns as you drag — those two columns are the only thing on the panel that settles the question you committed to. Then press overtake the car ahead ▸ and watch it as three beats: the loop halts, two rows in order trade places, and only then do the dots move — a car changing position in this game is an array index changing, not a car driving anywhere, which is why the stamp reads no car drove there. Flip to load‑bearing only and everything greys out except the two marks the game is honest about: your arrow, and the start/finish line. (Count the rows: order holds 22 entries and one of them is 'YOU', so the loop draws 21 dots, not nineteen.) Two honest labels on the picture: the dots are drawn between outline vertices here so a 0.02 shift is visible at all, while the engine snaps each one to vertex floor(gp×20); everything else is the engine's own line, run unchanged. Finally the clock. Press clock: 2 o'clock − 5 hours ▸ and the hand counts back 2, 1, 12, 11, 10 and stops on 9 — then press naive wrap (single %) and run it again, and watch where it stops instead. You have been computing ((x % 1) + 1) % 1 in base twelve since you learned to read a clock face; the engine just needed it written down.16Drawing in code
The car you chase, the cockpit around you, the steering wheel, the grandstands, the trees, the fireworks — every pixel is a shape drawn in code: rectangles, circles, lines, curves. Describing art this way takes a little getting used to, and then it becomes its own quiet pleasure, closer to sketching than to programming. My discipline is design the asset once, then port it. I draw the rival's rear end as a clean vector first — wing, diffuser, cambered tyres, the glowing red light — then translate those exact coordinates into drawing calls, wrapped in a helper so the same artwork scales to any size and tints to any team:
function drawCarRear(g, cx, cy, w, lean, ghost, accent){
g.save(); g.translate(cx, cy); g.rotate(lean*0.12);
var s = w/480; // ONE scale factor for the whole car
function PX(X){ return (X-300)*s; } // map the design's coordinates → the screen
function PY(Y){ return (Y-352)*s; }
// ... rear wing, painted in the rival's TEAM COLOUR ...
g.fillStyle = accent;
rrect(PX(128), PY(52), 344*s, 32*s, 9*s); g.fill();
// ... tyres, diffuser, the red rear light, the cyan roll-hoop camera ...
g.restore();
}
One function draws the whole car at any size, tinted to whichever team you're racing.
save() and restore() are the unsung heroes here, and worth learning to love. save() remembers the current state of the canvas. translate() then moves the origin — the point (0,0) — to where the car sits, so everything drawn afterwards is relative to the car, not the screen. The artwork never has to know where it lives. restore() peels all of that back off cleanly. It's like drawing on a sticky note, placing the note wherever you like, then lifting it away without a mark. Master that one pattern and complex scenes stop being intimidating, because every object gets to live in its own little coordinate world.
That tiny PX/PY pair inside is a coordinate transform done by hand: I designed the car in a 600×400 space, and those map any design point into screen pixels at the current scale, so I never redo the maths per shape. Change w and the whole car grows or shrinks; change accent and it wears a different team's colour. One function, eleven liveries, any size, razor-sharp on a high-resolution screen with nothing extra to ship.
The car is eight shapes authored once in a 600 by 400 space and never edited again. One transform carries them to any size, any angle, any team colour. Predict how many of those design numbers change when the car doubles in width. Then drag the size, and switch the transform off to see what the alternative really costs.
accent argument is holdingthe design numbers, in the left panelrotate(lean × 0.12), three team accents — and watch which panel moves while the numbers edited readout keeps score. Then flip the switch to OFF, where each shape is placed by its own hand-written expression instead of one shared translate and one shared s = w/480, and one of those expressions reaches for the X origin 300 where it needs the Y origin 352. Drag w while it is off and the damage grows with the scale, exactly as that bug behaves in real code. Now pinch a map on your phone: the type stays razor-sharp at every zoom. Ask what the phone actually changed to manage that, then check your answer against the counter here — the w slider is the pinch.17The steering wheel, up close
A modern F1 wheel is a cockpit in itself, and recreating that density is most of what makes the view feel real. The wheel in the game has a live display in the middle — speed, gear, position, the gap, tyre temperatures, the ERS bar — plus shift-lights across the top and a scatter of buttons and rotaries. None of it is hard; it's just a lot of small, honest read-outs. Take the tyre temperatures, which colour themselves by how hot the rubber is:
function tcol(t){
return t < 95 ? '#7fdbff' // cold — cyan
: t < 109 ? '#6be58b' // good — green
: t < 119 ? '#ffd24a' // warm — amber
: '#ff5050'; // hot — red
}
A value mapped to a colour by a few thresholds — the simplest, most useful UI pattern there is.
That little ladder of ?: checks is one of the most reusable things in all of UI work. A number comes in; a colour comes out, by band. Cold tyres glow cyan, working tyres green, overworked tyres amber, then red. You'll use this exact shape for a health bar, a signal meter, a temperature gauge, a score that turns gold — anywhere a number should look like its meaning at a glance. The player never reads "108 degrees"; they see green easing toward amber and feel the tyres coming good.
Building the wheel taught me something about UI that the road never did: it's a system of springs, not a set of independent parts. When I lifted the wheel to make its display readable, it slid up over the engineer's message board and hid it. Move one piece in a tight cockpit and you push on everything around it. So the wheel, the dashboard, the mirrors and the message board all share a few anchor values, and nudging one means re-checking its neighbours. That's not a flaw to fix; it's just what a dense interface is, and accepting it makes you lay things out more carefully from the start.
18The dashboard, and an engineer called AXON
A racer is, more than anything, a conversation. You're catching Pérez… mind the kerbs… last lap… box, box. A surprising amount of what makes the game feel premium happens in that chatter, and it's all just drawing plus a little timing. Above the wheel sits a small board where your race engineer, AXON, talks to you — and AXON has no AI in it whatsoever, which surprises people. It's a set of phrase banks and a clock. Overtake, and it pulls a line from the "nice move" bank; idle, and it rotates calm strategy talk; close on a car, and it calls the name.
The trick to making canned lines not feel canned is to never repeat one twice in a row, and to time each to what just happened. The idle chatter shows the technique most clearly:
var bucket = Math.floor(clock / 3.6); // a new "slot" every 3.6s
axonTxt = AXONCALM[ (bucket * 2654435761 >>> 0) % AXONCALM.length ];
A cheap, stable shuffle: scramble a slow counter into an index so the line changes every few seconds, not every frame.
That odd number, 2654435761, is a well-known constant for scrambling integers (it comes from the golden ratio). Multiplying the slow counter by it and keeping the low bits gives a sequence that looks random but is perfectly stable and completely free — no random calls, no state to store, and it never flickers because it only changes when the bucket does. The engineer feels alive because the timing is right, not because anything clever is happening underneath. And that's the whole illusion of character in games: it's almost never intelligence, it's almost always timing.
The same board changes colour with the mood — green for good news, red when you've spun, purple for strategy — and the line that decides it is, again, just a few conditions reading the game's state (bcol = spinT>0 ? COL.rd : COL.gr). A board that turns the right colour at the right moment does more for "this feels alive" than any amount of clever text.
The difference between a robotic HUD and one with a personality is rarely the words — it's when they appear, how long they linger, and that they don't repeat. Get the rhythm right and a dozen canned phrases feel like a person in your ear.
A slow counter times the chatter, and a famous constant is credited with shuffling it. The constant is real and the shuffle is worth checking, because array length gets a vote. Predict whether sixteen calm lines come out shuffled or in order. Then scrub the clock and watch two phrase banks, one constant, and two completely different answers.
predict
bank · same constant, two lengths
picker
drive
the calculator in your pocket — check this yourself
AXONCALM[(bucket * 2654435761 >>> 0) % AXONCALM.length], one step at a time; the bank below draws the order of visits as a trail, so you see the shape before any sentence names it. Then flip to STRATEGY — twelve lines, same constant, same code — and flip the picker to Math.random() to meet the two disasters this design avoids. In fairness to the constant: 2,654,435,761 is a genuinely good scrambler, and Knuth's multiplicative hashing takes the top bits, >>> (32−k). This code takes the low ones with % length, and a multiply mixes information upward — so the low bits are the half it barely touches. Whether that matters is decided by one thing the code never mentions: the length of the array. Where it bites, the fix is one character of arithmetic: >>> 28 instead of % 16, or a bank length that is not a power of two. The multiply lab is that same fact in base ten — your number ends in 3, so the product ends in 3, because the constant ends in 1.19The minimap, and a function worth befriending
The minimap traces the same mini points we built the track from, drops a coloured dot for each driver, a pulsing red marker on the start/finish line, and — the part I like best — an arrow for you that points the way you're travelling and turns as you go round. Here's how the arrow knows which way to point:
// YOU — a little arrow aimed down the road, rotating as you go
var nip = (pi+1) % pts.length, np = mp(pts[nip][0], pts[nip][1]);
var ang = Math.atan2(np[1]-cpp[1], np[0]-cpp[0]); // direction to the NEXT point
g.save(); g.translate(cpp[0], cpp[1]); g.rotate(ang);
g.beginPath(); g.moveTo(6,0); g.lineTo(-4,3.4); g.lineTo(-1.6,0); g.lineTo(-4,-3.4);
g.closePath(); g.fill();
g.restore();
atan2 turns "the next point is up-and-to-the-left" into an angle, and the arrow just rotates to match.
Math.atan2(dy, dx) is one of those functions worth making a friend of early. Hand it a direction — how far across, how far down — and it hands back the angle of that direction. The arrow is drawn once, pointing flatly to the right, and then rotate(ang) swings it to face the next point on the track. Point an arrow at a target, aim a turret, swing a compass needle, make an enemy face the player: it's the same function every time. The day atan2 clicks is the day a lot of "how do they make it point at things?" questions quietly answer themselves.
Then there are the small touches that, together, lift the map from a debug overlay into something you actually use: the lap number in red under the logo, the countdown ticking LAP IN 3s as you approach the line, the place name — MONZA, SPA, INTERLAGOS — under the outline, the red marker pulsing wider as you near it. None of these is hard. Each is twenty minutes. But they're the twenty-minute jobs that decide whether something feels finished — and "finished" is a feeling you build out of a hundred of them, not one big stroke.
20Lights out
Every race opens the same way: five red lights blink on one by one, hold, and go out — and on "lights out" you're racing. It's pure theatre, and theatre is worth building. The whole sequence is a state and a timer. When a race begins, the game enters 'count', and a clock ticks until it's time to go:
if (state === 'count'){ countT += dt; if (countT >= 3.2) state = 'race'; return; }
A tiny state machine: stay in 'count' for 3.2 seconds, then flip to 'race'.
That early return is doing real work — while we're counting down, the rest of update never runs, so the car can't move, the clock can't start, nothing can jump the start. The whole game politely holds its breath. And the lights themselves are drawn straight from that same countT:
var go = countT >= 2.6; // the lights have gone out
var lit = Math.min(5, Math.floor(countT/0.45) + 1); // how many are on so far
for (i=0;i<5;i++){
ctx.fillStyle = (!go && i<lit) ? COL.rd : '#2a2a32'; // red while arming, dark on lights-out
ctx.beginPath(); ctx.arc(W/2-44 + i*22, H*0.4, 7, 0, 6.28); ctx.fill();
}
Five lights derived entirely from one timer — no separate light state to keep in sync.
There's no array of "is light 3 on?" booleans to manage. The number of lit lights is just computed from the clock — one new one every 0.45 seconds — and the instant go becomes true, every light flicks off and you're released. This is the same lesson as the odometer: keep one source of truth (the timer) and derive the rest. Fewer pieces of state means fewer ways for two things that should agree to disagree. A start-light sequence is the kind of thing you'd expect to be fiddly, and it's four lines because the clock does all the remembering.
21Sectors, laps and the timing tower
Real F1 splits every lap into three timed sectors and lights them purple when you set the fastest. That feedback — purple, purple, just missed — is half of why hot-lapping is addictive, so the game does it too. Because we already have pos growing forever, your progress through the current lap is one division, and the sector boundaries are just thirds of it:
if (sIdx===0 && prog>=1/3){ sect[0]=sectStart; mark(0); sIdx=1; } // crossed into sector 2
else if (sIdx===1 && prog>=2/3){ sect[1]=sectStart; mark(1); sIdx=2; } // into sector 3
if (prog>=1){ // crossed the line
sect[2]=sectStart; mark(2);
lastLap = curLap;
if (bestLap==null || curLap<bestLap) bestLap = curLap; // new personal best?
lap++; lapBase += roadLen; curLap=0; sectStart=0; sIdx=0; sect=[null,null,null];
if (lap > LAPS){ state='finish'; onFinish(); } // race over
}
Sector times fall out of one progress number; crossing the line rolls the lap over and books a new best.
Watch how a lap "rolls over." We don't reset pos — that single ever-growing number stays sacred. Instead we push lapBase forward by one lap's length, so "progress this lap" (prog) is always (pos - lapBase) / roadLen and naturally starts again from zero. The sector timers reset, the per-sector "is this my best?" check (mark) lights each split purple or green, and if that was the final lap we hand off to the finish. One growing odometer, a moving base line, and the entire timing tower assembles itself.
The mark() helper is where the colour comes from: it compares each sector to your best for that sector, paints it purple if it's a new best and green otherwise, and stores the new best. Three sectors, one tiny function, and the screen suddenly speaks the language F1 fans already know. You don't have to explain a purple sector to anyone who's watched a race — and even to someone who hasn't, "purple means I just did something good" lands in about two laps.
22The finish — reward like you mean it
Cross the line in the top ten and your points come flying out of your name in the timing tower. Land on the podium and the whole screen erupts in fireworks. This is "juice" — the disproportionate, generous feedback that makes a win feel like a win. It's cheap to build and it's the last ten percent everybody remembers. A firework is nothing but particles: a ring of dots, each given a velocity, a little gravity, and a fade:
function burst(){
var cx = Math.random()*innerWidth, cy = Math.random()*innerHeight*0.66;
var col = pick(colours), n = 30 + (Math.random()*26 | 0);
for (var k=0;k<n;k++){
var a = (k/n)*6.2832, sp = 90 + Math.random()*230; // spread evenly around a circle
parts.push({ x:cx, y:cy, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp,
life:1 + Math.random()*1.3, col:col });
}
}
// each frame: p.x += p.vx*dt; p.y += p.vy*dt; p.vy += 120*dt; p.life -= dt;
A firework is a ring of dots that fly out, slow, fall, and fade. That's genuinely all it is.
Math.cos(a) and Math.sin(a) turn an angle into a point on a circle, so spacing n particles evenly around a full turn (6.2832 is 2π — once around in radians) fires them out in a clean starburst. Add a touch of downward vy each frame for gravity, drop each particle's life until it vanishes, and you have a celebration. Re-skin those same dozen lines and they're confetti, sparks, an explosion, a spell, rain, a fountain. Learn particles once and you can reward players forever.
One design choice I'll defend: the celebration is tiered. Any points finish gets the sparks off your name. Only the podium gets the screen-wide fireworks. Rewards should be proportional — if everything is a spectacle, nothing is. Save the biggest bang for the thing you most want the player to chase, and they'll chase it. The fireworks aren't decoration; they're a promise about what's worth wanting.
Juice is the cheapest, highest-return work in a game. The mechanics earn the win; the juice makes the player feel it. Build the win first, then be embarrassingly generous with the feedback.
23Keyboard and touch, one clean path
The car has to drive identically whether you're tapping arrow keys on a laptop or holding a button on a phone. The way to keep that sane is to make every input write into the same little box of flags, and have the driving code read only that box — never the keyboard or the buttons directly:
var keys = { l:false, r:false, boost:false };
function key(e, d){
var k = e.key.toLowerCase();
if (k==='arrowleft' || k==='a') { keys.l = d; e.preventDefault(); }
else if (k==='arrowright'|| k==='d') { keys.r = d; e.preventDefault(); }
else if (k===' '||k==='space') { keys.boost = d; e.preventDefault(); }
else if (d && k==='n') nextCircuit();
else if (d && k==='r') initRace(circuit);
}
window.addEventListener('keydown', function(e){ key(e, true); });
window.addEventListener('keyup', function(e){ key(e, false); });
Every control writes into one tiny keys box; the driving code never asks the hardware directly.
One function, two events — true on press, false on release — and the whole keyboard collapses into three booleans. The on-screen left/right buttons do the exact same thing with touch events, flipping keys.l and keys.r on press and release. Because everything funnels into that one box, the steering line from earlier — (keys.r?1:0) - (keys.l?1:0) — neither knows nor cares whether a thumb or a key set the flag. That separation, between "what the player did" and "what the game does about it," is one of the most clarifying habits you can build. Add a gamepad next year and you're writing into the same three booleans; nothing downstream changes.
The preventDefault() calls are small manners that matter: they stop the page from scrolling when you press Space or the arrows mid-race. And steering is on the buttons, not the canvas, so a vertical swipe over the track scrolls the page like any other — the game claims only the inputs it actually needs and leaves the rest to the browser.
24Performance, and where to spend it
Smoothness is a feature, and it's mostly bought by being honest about where the work goes. The sharpest lesson I got from this build came from sharpness itself. Chasing crispness, I rendered at full retina density — three device pixels for every CSS pixel. On a fast machine it looked gorgeous. On a phone it choked, because tripling the resolution roughly triples the painting work every frame, and the page could barely come up. The fix was one line:
dpr = Math.min(window.devicePixelRatio || 1, 2); // crisp, but never crippling
cv.width = Math.round(W * dpr); // a bigger pixel buffer than the CSS box...
cv.height = Math.round(H * dpr);
cv.style.width = W + 'px'; // ...displayed at the normal size = sharp
Render at twice the screen resolution for sharpness — but no more. Past 2× the eye barely notices; the phone definitely does.
That's the engineer's whole job in one sentence: find the knob that buys ninety-five percent of the quality for a fraction of the cost, and turn it exactly that far. Sharpness you can't see isn't quality — it's a frame-rate tax you're charging your own players. The same instinct shows up all over the game: only two rivals are real, only the visible slice of road is drawn, the distant field on the map is faked, sprites are tinted from one drawing instead of stored eleven times. Every one of those is the same question — where will the player actually look? — and the answer tells you where to spend.
I shipped the 3× version, opened it on a real phone, and got a black screen that "wouldn't load." It wasn't a crash — the code was fine — it was the device drowning in pixels before it could paint. The lesson stuck harder than any tutorial: test on the worst device you care about, not the best one on your desk.
✎Field notes — the bugs that taught me most
The tidy version of building a game is the architecture diagram. The real version is a stack of small, stupid, instructive bugs. A few from this build, kept rough on purpose:
- The shivering car. The car ahead juddered as it moved, and I blamed the AI for ages. It wasn't the AI — it was the renderer snapping the car from one road slice to the next. The fix was to interpolate its position smoothly between slices. The bug was in the eyes, not the brain. When something looks wrong, check the drawing before the logic.
- Climbing forever. "I overtook three cars and I'm still last." The rival was defending right onto my exact line, so I kept colliding instead of passing — and a collision dropped me a place. Two fixes: rivals now leave a gap, and contact costs time, not position. Progress you can't make isn't difficulty, it's a wall.
- The orange lines. The kerb was one full-width red strip drawn under the tarmac, and it peeked through at every slice seam. I redrew the kerbs as two thin edge strips. With nothing red left under the middle of the road, there was nothing to leak.
- The wheel that ate its own screen. I lifted the steering wheel to show its display and it covered the engineer's board. Lifting one thing in a cockpit pushes on everything around it. A dense interface is a system of springs; you can't move one piece in isolation.
Keep a scrappy log like this for your own projects. The bug you write down is the bug you don't repeat — and re-reading your old ones is the closest thing to having a mentor look over your shoulder.
25Exercises — make it yours
Reading code teaches you a little. Changing code teaches you a lot. Six, easy to hard — every one reachable with what's on this page.
- Turn the feel knob. Find the steering line and change
0.13to0.05, then to0.3. Drive each. Now you feel what the number does — worth more than any paragraph I could write. - Re-colour a team. Find
teamCol()and change one team's colour. Watch it appear on the rival's wing and its dot on the map from one edit. That's what "one source of truth" buys you. - Add a circuit. Copy a
minipoint array, nudge the numbers, give it a name. You've drawn a racetrack with twenty numbers. Drive your own. - Change the start. Find the
'count'timer and make it five lights over five seconds instead of three-ish. Notice how much tension a slightly longer wait adds. - Weaken the boost. Halve the ERS drain or the recharge and play a few laps. Feel how one tweak to a single constraint reshapes every overtake. That's game design, live, in your hands.
- Invent a particle. Steal
burst()and make a different effect — sparks off the kerb when you clip it, smoke when you spin. You already know everything you need.
26What I want you to take away
If you've read this far, here's the thing I most want you to believe: this is all within reach. A working racing game is not a wall of impenetrable cleverness. It's project() — five lines of division. It's a loop that thinks and draws. It's the same "ease toward a target" move used a dozen times in a dozen costumes. It's a list of points pretending to be Monza. Not one of these ideas is beyond a determined beginner, and stacked together they become something that genuinely feels good in your hands.
So if you're learning JavaScript, or just circling it, here's the path that actually works — the one nobody tells you because it sounds too simple to be true:
- Make something move on a screen this week. A square that follows your arrow keys is enough. The jolt of "I made that move" is the fuel for everything after it.
- Steal shamelessly, then change one thing. Take a snippet from this page, paste it, break it on purpose, fix it. Understanding lives in the breaking, never in the reading.
- Chase feel, not features. One control that feels wonderful beats twenty that feel like nothing. You'll learn more polishing one good second than building ten mediocre minutes.
- Finish small things. A tiny game you actually completed teaches you more than a huge one you abandoned. "Done" is a skill, and you only build it by reaching it, often.
The feeling never really changes: you type some words, and a small world appears that obeys you. That's the whole magic trick, and it's available to anyone willing to be a beginner for a while. The grid's waiting. Go build something that moves.
— Ajai
▶ Play IO-GRID — Dirty Air27Glossary
- Pseudo-3D
- Faking depth with flat 2D drawing by shrinking things in proportion to how far away they are. The technique behind OutRun, Pole Position, and this game.
- Projection
- Working out where a point in the world should be drawn on the flat screen. Here it's one division:
scale = depth / distance. - The game loop
- The forever-cycle of "update the world a little, draw it, repeat", run by
requestAnimationFrameabout sixty times a second. - Delta time (dt)
- The seconds elapsed since the last frame. Multiply movement by it so the game runs at the same real speed on every device.
- Easing / interpolation
- Sliding a value smoothly toward a target instead of snapping to it. The single most-used idea here:
v += (target - v) * k. - Accumulation
- Keeping a running total and adding a little each step. Turns small curves into corners, velocity into position, and a timer into a countdown.
- Segment
- One thin slice of road, with a bend and the world positions of its near and far edges. A track is a stack of these.
- Slipstream / dirty air / tow
- The turbulent air behind a car that drags a follower along faster on the straights. The game's central overtaking mechanic.
- ERS
- The energy boost on the Space bar — a finite battery that drains as you use it and recharges when you don't. A constraint that turns a button into a decision.
- Canvas
- A blank drawing surface in the browser you paint on with code — rectangles, circles, lines. Everything you see is drawn this way.
- save / restore / translate
- Drawing commands that let you build an object in its own little coordinate world, place it anywhere, then cleanly forget the changes.
- State machine
- A program that's always in exactly one named mode — here
count,race, orfinish— and moves between them on clear triggers. - Juice
- Generous, disproportionate feedback — fireworks, sparks, screen shake — that makes an action feel good beyond what it strictly needs.
- Device pixel ratio (dpr)
- How many real device pixels sit behind one CSS pixel. Render above 1× for sharpness; cap it so phones don't drown in pixels.
Credits, trademarks & what this is
This game and every line of its code were written from scratch by Ajai Raj as a personal engineering project, to learn in public and to teach how real-time software actually works.
- Inspired by — the sport of Formula 1 and the pseudo-3D arcade racers of the 1980s. This is an original engine: no code, art, audio, or assets from any commercial racing game are used here. Drivers, teams and liveries in this game are fictional. F1, FORMULA 1 and related marks are trademarks of Formula One Licensing BV; the circuits named are real places, referred to descriptively. Nothing here is licensed by, or connected to, any of them.
- 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.