The Car Spun Before It Had Pixels

·7 min read
engineeringaiclaudegames

The old Split Race was bad. Not "rough around the edges" bad. A fixed 920px canvas, a car seen from above, an ellipse for a track, and races that were over in thirty seconds. It was a game in the sense that you could press keys and something moved.

So I rebuilt it. Long races, real physics, a chase camera behind the car, split screen for two players on one keyboard, AI drivers to race against. It is live at /apps/split-race, and the part worth writing about is where the bugs were.

V1 drew a car before it had one

The old movement was a sprite that rotated and slid forward. No tyres, no weight, no grip. So there was nothing to feel.

Feel in a driving game is what happens when the rear tyres run out of grip halfway through a corner and you have to catch it. You cannot paint that on. You either model it or you do not have it.

Build the car before you draw it

So the rewrite started with no graphics at all. The engine is plain TypeScript with no idea it will ever be rendered: the track as a closed spline sampled every two metres, the car as a bicycle model, the race as a grid, laps, walls and standings, and the AI as a driver that reads the corners ahead and plans its speed.

The car is the part that matters. Each tyre has a slip angle, and its grip rises with that angle until it saturates at the tyre's share of the car's weight. Weight shifts forward under braking and backward under power. Downforce builds with speed. Grass has about half the grip of tarmac and drags at the car. Hills change the load.

The numbers were chosen so the car behaves like a fast rear-wheel-drive car, not so it looks right: 0 to 100km/h in about four seconds, around 280km/h flat out, 100 to 0 in roughly 35 metres.

Because the engine never touches a screen, it can run thousands of times inside a test. There are 33 of them over it now.

Four bugs, zero pixels

Here is what that bought. Four bugs, each of which would have shown up in the browser as "the game feels wrong", and each of which the headless simulation named precisely.

  • The car spun on its first steering input. Full throttle from the line spent the rear tyres' whole grip budget pulling forward, leaving nothing for cornering. Real cars have the same problem, which is why they have traction control. This one does now.
  • A spinning car reached 5,500km/h. The rotation maths used the simplest integration method in the car's own frame of reference, and at high spin rates that quietly adds about 2% energy every step. Nothing in the code looked wrong. The number did. Velocity is now integrated in the world frame, and a test pins it.
  • The AI spun under braking, six times a lap. ABS let braking take 85% of the grip, which left the rear too little to hold a line into a corner. It is 75% now, with most of the braking moved to the front axle and a stability control that only damps oversteer. The handbrake bypasses it on purpose.
  • A race with no human drivers finished at the lights. "Has every human crossed the line?" is true when there are no humans. every() over an empty list always says yes.

None of those needed a renderer to find, and two of them would have been very hard to find with one. A car that spins is obviously broken. A car that is slightly too fast after a slide just feels a bit off, and you can tune "a bit off" for weeks without fixing anything.

My guess about pace was wrong

One finding I did not expect. The AI drivers come in three skill bands, and at first the only thing that varied was how hard they took corners. Lap times spread by less than a second.

On these circuits, lap time is made on traction out of the corners and on the straights after them. So the AI cars now differ in engine power too, between 78% and 100%. The player always gets the full car.

Small thing, but it is the kind you only learn by measuring. I had a theory about what makes one driver faster than another, and the first run threw it out.

The AI art went on the horizon and nowhere else

I allowed up to 20,000 ElevenLabs credits for visuals. The rewrite used 716.

Generated images went in exactly one place: the painted horizon behind each circuit. Everything near the car is procedural — terrain, road, kerbs, barriers, trees, grandstands, skid marks, smoke — because it all has to react to light and speed, and a picture cannot. The horizon is the one thing that never moves relative to the sky, so it is the one place a picture works.

The pictures earn their place. Sky, fog and light colours are sampled from each image, so the 3D world sits inside the painting rather than in front of it. The coast image had to be cropped so its sun sat on the mirror axis. Before that, the scene had two suns.

It is the same point as The Model Is the Cheap Part, from the other side. Let the model paint. Keep everything that has to behave in code you can test.

What I could not test in a browser

There was one wall. Headless Chromium on a software renderer runs this scene at about 0.3 frames a second. You can check the start lights, the throttle and the steering. You cannot drive a full lap.

So lap completion lives in the tests: the AI drives two clean laps of each circuit in Node, on the same physics the browser runs. The browser checks prove the game is wired up. The simulation proves it works. Asking either to do the other's job would have been slower and less certain.

For the person doing the work

If you are building anything where "it feels wrong" is the bug report, split the model from the view before you write either. Make the model run without a screen. Then you can ask it precise questions, and it answers in numbers instead of impressions.

Most of what I found would have been weeks of tuning in a browser. In a test, each one was a failing assertion and an afternoon.

For the person deciding

When a product is bad, the tempting fix is a reskin. Better graphics on the same core is cheaper, and it demos well.

It would have failed here, because every problem with V1 sat underneath the pixels. The decision that mattered was whether to rebuild the core, and that is usually the real call on anything people describe as bad. Judge what the thing is before you judge how it looks.

And look at where the budget went. 20,000 credits allowed, 716 spent. A big cap did not mean the work needed it.

The takeaway

The rewrite looks better, but that is the least of it. The car is a car now. It has weight, grip and limits, and its bugs were found as numbers before anyone saw a frame.

Build the thing first. Draw it second. Then the drawing has something real to show.

Coastline best laps go on the board. Have a go at /apps/split-race.

Written by

Martin Dimoski

Senior AI Engineer & Systems Builder