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