Cockpit-view open-wheel racer IO IO-GRID Dirty Air Twenty-one cars ahead, one line through every corner. Tuck into the dirty air, hold your nerve and tap the throttle — drag yourself from dead last to the front of the 2026 grid, one overtake at a time. ⊟ Read how it's built → Designed & developed by Ajai Raj · © 2026 — all rights reserved.
iolinked.com IO-GRID · © Ajai Raj
Home / Arcade / Building IO-GRID ← Back to home
Author · Ajai Raj iolinked.com

Build Notes · Game

Building IO-GRID — Dirty Air

IOIO-GRIDDirty Air

By 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 on

00Before 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:

The rule that shaped everything

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.

Perspective is just division Camera, a near slice and a far slice, projected onto the screen. horizon — H·HOR camera near slice — small cz → big sc → WIDE far slice — big cz → small sc → tiny
One line of maths does this: divide a slice's real distance into a constant, and you get its on-screen scale. Far slices are divided by a bigger number, so they shrink toward the horizon.

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 smallfar things shrink. Divide by a small distance and sc comes out largenear 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.

If you're new

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

If you've coded a bit

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.

If you've shipped games

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.

Take-away

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.

one slice distance · two falloff laws · one horizon THE RAY DIAGRAM · side on why the ratio can only be depth ÷ cz screen · depth 0.9 eye line · camH 640 above ground camera ground the slice · 520 tall depth 0.9 cz = 1316 THE COCKPIT · both laws the slice you drag is highlighted horizon · H × 0.36 NO VANISHING POINT the edges crossed at cz = 10000 cz · slice distance 1316 engine units sc = 0.9 / cz 0.000684 the scale factor road half-width Wd 862 canvas px · 720×450 marker on screen 80.0 px · hoarding 520 We shrink far things. Suppose that instead of DIVIDING by distance we SUBTRACT a fixed amount per unit of it. Does the road still converge to a horizon? commit a prediction — then the slice unlocks
◂ predict first
“linear falloff” is an illustrative alternative law, not code from the game — the fairest straight line through 0.9/cz, matched to it at cz = 5000.
measured ladder · canvas px
Commit a prediction — then the slice unlocks.
1/cz — the engine linear — illustrative fault
One factor sizes the road AND everything standing beside it. Next: what happens when the road bends but the data does not.
Labelled: a pinhole model, no lens distortion; the ray diagram’s distance axis is logarithmic, so the triangles are to scale but the runs are not.
One slider, two panels, and a question you answer before the instrument unlocks: if far things shrank by subtracting a fixed amount per unit of distance instead of dividing, would the road still find a horizon? Commit, then drag slice distance cz and watch the ray diagram: the camera, the screen plane at 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 sheetswhatever 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.

Note · for absolute beginners

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.

Take-away

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.

the two-clock loop — drag the frame rate and read both clocks 60 fps corner real time your car dt · world clock — seconds advanced per frame clamp 0.05 0.017 f · filter clock — min(2.2, dt×60) cap 2.2 1.00 real frame time 16.7 ms world falls behind at 0.00 s per s
The machine bogs down to 10 fps. Does the car teleport forward to catch up, or run in slow-motion?
drag me ↓
8 fps2060 fps
clamp off — dt runs unbounded
Commit a prediction — then the frame rate unlocks.
gold = the clock being driven · red = clamp / cap engaged · faint = the real-time ghost
This same smoothing clock drives one move used absolutely everywhere in the game. Meet it next.
aha: the clamp on dt is what turns a lag spike into slow-motion instead of a teleport. When the machine can't keep up, the loop advances the world by at most 0.050 s per frame — so the world falls behind the wall clock (slow-mo) rather than flinging the car a whole frame's distance across the track. Slow-motion on a tired phone isn't a bug: it is the designed failure mode, and it is the safe one. Flip the clamp off and the same low frame rate teleports the car clean past the apex — the lurch the clamp exists to trade away.
The two-clock loop. Commit a prediction, then drag the frame rate from 60 fps down toward 8. Two clocks run the loop: dt, the world clock, is the seconds the world advances each frame — the loop clamps it at 0.050; and f = min(2.2, dt×60), the filter clock that scales every smoothing channel, which caps at 2.20. Below ~27 fps the f cap engages; below 20 fps dt hits its ceiling too. Watch the gold driven car against the faint real-time ghost, read the live real-time gap, then press remove the clamp and drop the frame rate again to see what the clamp was holding back. The lesson underneath: the clamp trades wall-clock accuracy for safety on purpose — when the machine can't keep up, the loop would rather slow the whole world down than fling the car a full frame across the track, which is why a struggling game stutters into slow-motion instead of teleporting your car through the corner.

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

Take-away

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.

closing a gap · one dot = one frame ease · k fixed 0.1 snap the fixed 0.1 step is illustrative — it is not in the game · the pale band is the last 10% of the gap target · the racing line ▲▼ off scale 0.00 −0.85 never settles frame 0 20 40 60 a list you flick — let go and it is slowed by the same k ↔ drag · or ← → release friction · velocity eased with the same k = 0.13 v = 0.0 px/frame a flick loses 90% of its speed in 16.5 frames — 0.276 s
Not a fraction — a fixed 0.1 units every frame. What happens when it arrives?
 
gap remaining
k × f
frames to 90%
seconds to 90%
Commit a prediction — then k unlocks.
Roads are built with a different kind of easing. Next: a corner is a ramp of numbers you can type.
aha: the step is a fraction of what is left, so it shrinks exactly as the gap shrinks. That is why one constant handles a huge correction and a tiny one, why it can never overshoot, and why it never has to be told it has arrived — and it is the same glide your thumb feels when a list comes to rest.
Commit a prediction first — a fixed 0.1-unit step, arriving — and the k slider unlocks. Then drag k and read the four numbers: at k = 0.13, the engine’s steering constant, the gold trace closes 90% of the gap in 16.5 frames, 0.276 s; at k = 0.045, the speed filter, 50.0 frames, 0.833 s. Every step is a fraction of what is left, so it shrinks as the gap shrinks: one constant covers a huge correction and a tiny one, it can never overshoot, and it never needs an arrival test. Now flick the strip of cards — it is let go with whatever k you are holding, so at k = 1.00 it stops dead like a brick and at k = 0.05 the same flick drifts on for over a second: the difference between every list you have ever scrolled and one that feels broken. Switch to 30 fps and f doubles the step: the frame count nearly halves while the seconds barely move — which is what 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.

Take-away

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.

one call · addRoad(enter, hold, leave, curve, h) THE CURVE PROFILE one bar per segment · height = s.curve ease ramp = p*p*(3-2p) 2.50 · min() ceiling THE ARRAY ITSELF what addRoad() actually pushes i curve p1.x p1.z p2.z press write the array segments written 0 rows in the array road length 0 segments × SEG 200 peak curve 0.00 max |s.curve| biggest jump 0.00 between neighbours a corner ramps its curve up over 20 slices, holds it, then ramps back down. set the ramp to zero. sharper corner, or broken one? commit a prediction to unlock the sliders
◂ predict first
This corner ramps in over 20 segments. Set the ramp to zero so the curve jumps straight to full lock. Do you get a sharper corner, or a broken one?
addRoad(enter, hold, leave, curve, h) — every slider below writes the array live.
enter and leave are a transition curve — the same ramp a motorway slip road is built with, so the wheel turns at a steady rate instead of being yanked.
Commit a prediction — then the sliders unlock.
held at full curve ramp written by ease() the straight either side the biggest jump between neighbours
Those numbers came from a map. Next: watch a real circuit get read, one vertex at a time.
Commit a prediction, then build a corner by typing its numbers. enter, hold, leave and curve are the whole argument list of 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."

If you're new

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

If you've shipped games

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.

one polygon · turn = angNorm(head(i) − head(i−1)) THE MAP · circuit.mini Monza · 15 points, nothing else   THE ROAD IT WRITES the same array, built into track     sharp 2.50 = hard stop hold floor 16 60 = hard stop turn 0.00 rad · angNorm(Δhead) sharp 0.00 min(2.5, |t|·2.4+0.35) hold 0 clamp(|t|·40, 16, 60) segments written 0 round(hold·.55)·2 + hold one point of this map is yours to drag. drag it until the track doubles back on itself. what does the road builder make of a needle? commit a prediction to unlock the map ▸
◂ predict first
You are about to fold one map point back on itself — a needle. What does the builder write?
Commit a prediction — then the map unlocks.
this vertex & what it writes road already built not built yet a clamp state
The gold point takes the keyboard too: ← → ↑ ↓ nudge it, Home puts it back.
turn ≥ 0 → dir = +1 → a right-hander.
The map and the road share one array, so one point moves both. Next: the twenty-one rival dots drawn on that same map, all sharing one number.
Commit a prediction, then take hold of the gold point on the map — pointer or arrow keys — and drag it down through the line its neighbours make. A bare polygon carries no radius, no arc, no corner length; it is fifteen pairs of numbers. So sharpness has to be derived, and it is derived from a difference of headings: 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.

Note · the same trick twice

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?

one curve slider · two views of the same road THE DATA · top-down every slice stores x = 0 the whole array is one straight line THE PAINT · cockpit same slices, drawn to screen p1.x 0.00 frozen · inert p2.x 0.00 frozen · inert x 0.0 px · accumulated dx 0.0 px / slice curve 0.00 slider value the road below bends into a corner. where does that corner live? commit a prediction to unlock the panels ↙ you picked DATA — this panel never moves
◂ predict first
commit a prediction, then drag the curve.
driven (curve, dx) accumulator x inert data (p1.x, p2.x)
A straight road you can bend on screen also means the camera never really turns — it slides. That is the next trick.
Two views of one road, wired to a single curve slider. First commit a prediction — is the corner in the data, or is it painted? — then drag curve and watch which panel obeys. The left panel is the actual segment array drawn top-down: a dead-straight ladder, because 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.

War story · the strobing road

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.

one road · three ways to paint it SIDE VIEW · the road from the fence camera at the left · slices numbered by distance line of sight camera slice number · distance from the camera THE SCREEN each band is one row of pixels maxy = 242 14 slices shown here · the engine’s loop runs DRAW = 185 slices painted screen rows lit total row-paints worst row painted maxy · the ceiling 0 / 14 0 / 36 0 242 PREDICT TO UNLOCK the loop is halted
① predict first
The road has a crest with a dip hidden behind it. Which draw order paints every screen row exactly once and still hides the dip?
 
the ceiling is one number: maxy, started at H.
Pick an order — then the loop unlocks.
 
two orderings run inside the same frame — and one of them is about to look wrong.
key:the slice being painted nowa screen row already painted (denser = painted again)a slice that painted over something nearer than itselfa slice the loop threw awaymaxy, the ceiling — it starts at H and moves one way only
Commit an order first — which one paints every screen row exactly once and still hides the dip? — then press paint one slice and watch the loop halt on each slice before the rows land. The right-hand panel is the screen itself, one band per row of pixels, and every band gets denser each time something is painted into it, so the cost stops being a number and becomes a colour. The engine’s loop starts at the camera’s own segment and walks outward — near to far — and the whole occlusion test is one variable: 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.

The most important number in the game

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.

two knobs, plotted against speed — the game (gold) vs a real car (faint) STEERING AUTHORITY CORNER PUSH 0 speed → 358 0 speed → 358 km/h RISES falls linear 0.52× 0.49× game grip real grip 0.01 0.00 game push real push commit a prediction to reveal the gauges ↑ no peeking — the answer is behind this panel HOLDING THE LINE — GAME MODEL OUTSIDE — off the track OFF — understeer ✕ 42 km/h
locked
Straight, then a fast bend. Does the wheel get harder or easier as you speed up? Commit above.
The faint line is an illustrative real-car sketch — understeer plus a v² push — not in this engine's code; the gold lines are the engine's real formulas, 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.
Before you touch anything, predict: as the car speeds toward a fast bend, does the wheel get harder or easier? Commit, and the gauges appear. Drag the speed and watch the gold lines — the engine's own 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.

Take-away

For arcade feel, don't simulate forcesease 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.

one slice of road, seen head-on · the gold car is you TWO WHEELS OFF — CAPPED AT 0.62 × MAXS −0.99 0 · the racing line +0.99 locked — commit first the price curve is drawn once you commit the price curve · target speed vs how far off the line 399.0 358.8 222.4 0.00 0.50 0.99 SPEED 358.8 km/h the ledger: an illustrative 90.0 s lap, held exactly on this line all the way round, at a steady speed
start hereYou run half the road’s width off the line. How much of your top speed does that cost?
 
Being fast is only half of it. Next: what it costs when somebody else is in the way.
playerX
edge
tgt km/h
90.0 s lap
the ledger wakes up when you commit.
Commit a prediction — then the car unlocks.
Commit a call first — run half the road’s width off the line and what does it cost you? — then drag the gold car across the slice of road and watch the price curve draw itself under your hand. There is no throttle and no brake here, so the only currency the game can charge you in is the speed target: 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.

Unlock · fun lives next to a constraint

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?

CLOSING THE GAP — you tuck in behind (|Δx| < 0.55) dz — Commit a prediction below — then both lanes reveal here. WHAT THE NAME PROMISES “Dirty Air” — if the title told the truth +0 km/h vs base 300 base 300 🌡 105°C …feeds grip GRIP WHAT THE CODE SHIPS the engine, exactly as written +0 km/h vs base 300 base 300 🌡 105°C decoration — never read GRIP the tyres glow — the physics never looks tb = 90 + 18·speed/MAXS — SHOWN, NEVER FED BACK
start here Tucked in close through a corner — what does the engine do to you?
Pick a prediction above — then close the gap and flip to the corner.
So the tow is all upside — the deliberate lie in the title. Everything else on the wheel is honest read-outs, built next.
Predict what the engine does when you tuck in through a corner, then close the gap and flip between STRAIGHT and CORNER. On the straight the two lanes agree — the slipstream tow is real, +2300 units ≈ +40 km/h, and it grows as dz falls below 3600. In the corner they split: the name promises dirty air bleeding your grip, but the code’s Δ is 0 — there is no dirty-air penalty anywhere in the engine, and the tyre temp — climbing past 105°C and glowing amber — feeds your speed exactly zero times, its wire drawn cut. Hit switch the missing penalty ON and feel it: following closely suddenly hurts. That is the whole reason it was left out — the missing penalty is the design, a feature wearing the costume of a bug.

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.

THE RACE · ONE STRAIGHT, THREE RIVALS 60 fps · the engine’s own update line FINISH RUS DEL OKO RAM P8 CLEAN P8 0.00 s 0.00 s SPEED · km/h, FRAME BY FRAME THE HITBOX · AABB 358 180 0 spinT window · 22 frames invuln window · 64 frames 0 100 200 300 400 frame · 60 to the second 1.2·SEG 0.4·SEG |dx| < 0.22 the box is not the car
speed after contact
the lowest the ram line reaches
crash() calls this contact one touch, counted
time lost vs the clean line live gap ÷ top speed
spinT · invuln, counting down left: locked steering. right: grace.
① commit · ② pick a cost · ③ RACE · ④ take the grace away

predict

One touch halves your speed. Switch the 64-frame grace timer off and hold a 12-frame scrape. How much of 358 km/h survives?

cost model · chosen before the run

* an alternative written for this figure. Only the third is code from the game.

drive

grace timer

1.6·SEG deep ÷ a 0.92× rival’s closing speed ≈ 12 frames.
Commit a prediction — then RACE unlocks.
 

the debounce you have already met

A button is a spring: one press bounces for several frames. Press it once, with the timer set as it is now.
key:the ram line, and its faultsthe clean line, and the shipped modelshaded: a timer still countingrivals, and the hitboxthe shipped model ahead
A spin costs you time. Next: what it costs when the car ahead is defending, and why the door can never quite shut.
Commit a call first, then choose a cost model and press RACE: the same straight, run twice side by side — a ram line that drives through three rivals and a clean line that goes around them and pays the engine's own off-line tax, 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.

Note · cheat tastefully

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.

the same road — one speedometer, two cars YOU 280 km/h RIVAL km/h rival target = your speed × 0.92 GAP TO RIVAL closing Δv = you − rival = 0 km/h the rival's speed is a fixed slice of yours — never its own. so the fight scales with you: it can't run away, and it can't drop you.
start here the rival is ahead. Floor it — what happens?
Pick a prediction above — then drag your speed.
If its speed is borrowed from yours, can it ever truly shut the door? Try to make it, next.
Predict what the car ahead does when you accelerate, then drag YOUR speed. The rival's blue bar is always the same fraction of your gold one — rivTgt = your speed × 0.92 on a straight. Flip to CORNER and the multiplier drops to 0.78: the rival eases in the bend. Slide your grid slot toward the front and frontF lifts the multiplier a touch. Then drag your speed to zero — the rival's target collapses to 0 and it stops dead. The rival is not slower than you; it is a fraction of you, which is why the fight stays on a knife-edge no matter how fast you go — the same rubber-band pulling on you in half the games you have ever played.

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.

Take-away

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.

road width · the full strip you can use reach reach daylight ↔ drag me −1.0 −0.5 0 +0.5 +1.0 lateral position on the road
predict: can a determined defender eventually cover you completely?
 
your x
intent defX
defender x
cap
daylight left
commit a call — then drive the gold car and watch the door.
✓ You slipped through the gap the numbers promised you.
a field that can’t quite catch you and can’t quite block you — so what is the fight really named after? Back to the air.
key:you, the moverdefender intent (defX), 1.39 s latedefender’s carthe daylight it can’t covercap — the defender’s furthest reach
Commit a call first — can a determined defender eventually cover you completely? — then drag the gold car to the wall and hold. The blue car is the defence; the faint dashed outline is its intent, running one slow filter (k = 0.012, about 1.39 s behind your move) that the car then eases onto. Because the intent tracks only 60% of your lateral move and is hard-capped at 0.30, the blue car stalls at the faint reach line while a strip of daylight stays open beside you — the readout measures it and it never falls to zero. Crank grid position to a front-runner and the cap climbs to 0.48: the door gets genuinely harder, yet 60% tracking still leaves the daylight the verdict prints. The fairness isn’t the AI choosing to let you by — sixty-percent tracking, a hard cap and a 1.39-second lag make a closable gap arithmetically impossible. The overtake was always available; the design just guarantees it in numbers instead of hoping for it.

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.

Honesty note

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.

the minimap · the order array · the arithmetic THE MINIMAP one polygon · 21 dots · your arrow drawn between vertices so 0.02 of a lap shows; the engine snaps each dot to vertex floor(gp×20). THE ORDER ARRAY one array, 22 rows i car Δpl off THE ARITHMETIC for the highlighted dot pr 0.160 your own lap fraction, 0…1 youIdx − di −11 places between you and this car pr + offset −0.060 raw, before any wrapping gp 0.940 after ((x % 1) + 1) % 1 five hours before two o'clock 12 3 6 9 2 o'clock the start point You drive one flawless lap. The field drives normally. Where are the other twenty-one dots when you cross the line again? commit a prediction to unlock your lap progress
◂ predict first
You drive one flawless lap while the field drives normally. At the end of it, where are the other twenty-one dots?
Every dot on that map is placed by one line of the engine, and the right-hand panel runs that line's arithmetic for whichever dot you highlight.
Highlight a car to read its arithmetic — click its dot on the map, or step through the field:
Commit a prediction — then your lap progress unlocks.
you, and your lap fraction pr the start/finish line the other twenty-one dots greyed in the load-bearing view a dot the naive wrap put off the map
This panel runs the engine's own line for every dot, so the spread you see here is the spread the game draws. Your arrow and the start/finish marker are exact here because they are exact in the game.
Fake it where nobody is looking. Next: the engineer in your ear, who is faking harder than you think.
Commit a prediction first, then take hold of pr — your own lap fraction — and drive a whole lap. The panel is one instrument in three parts: a circuit outline traced straight from its 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.

design space · 600 × 400 never edited shadow300,352 r246×17 left tyre112,250 100×208 right tyre488,250 100×208 suspension262,152→150,176 bodywork250,118→376,332 rear wing128,52 344×32 r9 rear light293,192 14×36 r5 roll-hoop cam300,30 r5.5 ONE SHAPE, ONE TYPO — PY(52) used 300 instead of 352 canvas dashed = where the wing belongs translate(cx,cy) rotate(lean×0.12) scale s = w/480
Those eight rows are the car’s shapes as raw design numbers. Double the width — how many of them change?
 
w
s = w/480
lean × 0.12
numbers edited
Commit a prediction — then the sliders unlock.
Every pixel here is a primitive, and there is not one image file in the whole game — and the densest pile of them is the wheel in your hands.
key:the transform, livetransform off · the shape with the wrong originwhatever colour the accent argument is holdingthe design numbers, in the left panel
Honesty: this panel redraws the car in SVG from the engine’s own design coordinates — it does not run the game’s canvas code. transform OFF is an illustrative alternative written for this figure; the shipped engine always uses the transform. The tyres’ camber and the rear wing’s two endplates are drawn from the same blocks as their listed rows and are not given rows of their own.
Commit a prediction first — the eight rows on the left are this car’s shapes in design coordinates, and the question is how many of them have to be rewritten when the car’s size does. Then drive it: w from 60 to 240, lean feeding rotate(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.

Note · personality is timing

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.

THE ARITHMETIC bucket × 2,654,435,761 >>> 0 · wrap to 2^32 % 16 → index low 8 bits of >>> 0 AXON · THE MESSAGE BOARD ◇ STANDBY clock locked ▮▮▮▮▮▮▮▮ THE BANK · 16 LINES lines reached: —
① commit · ② scrub the clock · ③ break it on purpose

predict

The engineer has sixteen calm lines and picks one with a famous scrambling constant.

bank · same constant, two lengths

picker

drive

Same clock — same line?
Commit a prediction — then the clock unlocks.

the calculator in your pocket — check this yourself

key:the four bits that decide the indexthe line on the board nowthe multiply and the 32-bit wrapvisited, tagged with its bucketa picker faulting on purpose
Timing made a character out of a list — no intelligence anywhere, just a counter and a remainder. The artwork this all sits inside is built the same stubborn way: from code, without a single image file.
Commit a call first — sixteen calm lines, one famous scrambling constant: shuffled, or in order? — then scrub the race clock and watch the bucket tick over every 3.6 s. The ladder runs the engine's own line, 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 posthat 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 proportionalif 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.

Take-away

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

War story · the 3× that went black

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

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.

  1. Turn the feel knob. Find the steering line and change 0.13 to 0.05, then to 0.3. Drive each. Now you feel what the number does — worth more than any paragraph I could write.
  2. 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.
  3. Add a circuit. Copy a mini point array, nudge the numbers, give it a name. You've drawn a racetrack with twenty numbers. Drive your own.
  4. 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.
  5. 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.
  6. 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 Air

27Glossary

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 requestAnimationFrame about 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, or finish — 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.

Written & built by · Ajai Raj · © 2026 iolinked.com