🏎️ Neural Racing Driver

A top-down racing simulator where the car is driven by a neural network trained with PPO. It sees eleven distance sensors and its own speed β€” no track map, no racing line, no waypoints β€” and learns to drive circuits it has never seen before.

A trained policy lapping a procedurally generated circuit. Nothing here is scripted β€” every steering and throttle input comes from the network.

Python TensorFlow / Keras PPO Pygame NumPy 13 β†’ 128 β†’ 128 β†’ 13

πŸ‘οΈ What the Car Sees

The network gets thirteen numbers per frame and nothing else. Eleven of them are raycast distances, fired at Β±90Β°, Β±60Β°, Β±30Β°, Β±20Β°, Β±10Β° and straight ahead, each one reporting how far it travels before leaving the tarmac. The other two are the car's own velocity and acceleration.

There is no map. The policy never knows where it is on the circuit, how long the lap is, or what the corner after this one looks like. Everything it does is a reaction to eleven numbers that describe the road immediately around it, which is why a network trained on one set of circuits can drive a completely new one.

The camera is locked to the car so the sensors sit at fixed screen angles β€” only their lengths change. Watch the left-hand beams collapse as the tyre stack comes up, and the forward beam shorten into the corner. That collapsing fan is the entire input vector.

🧠 Inside the Network

Two hidden layers of 128 units map those thirteen inputs onto thirteen discrete actions β€” every sensible combination of throttle, brake, steering and handbrake, from coasting to full-throttle-and-left to a handbrake turn. The policy outputs a probability for each, and the driver takes the most likely one.

Live activations while driving. Inputs run down the left (velocity, acceleration, then each ray from L90 round to R90), action probabilities down the right. The highlighted output is what the car is doing this frame.

πŸ›£οΈ Generating Circuits

Tracks are built from a closed Catmull-Rom spline through randomly placed control points, resampled to even spacing and rasterised into a mask. That mask does double duty: it is what the physics tests against for wall contact, and it is what the sensor rays terminate on β€” so obstacles punched out of it become visible to the network for free, without a single extra input.

Four difficulty tiers change what gets generated. Easy and medium are plain circuits; hard adds tyre stacks on the racing line; extreme adds cars coming the other way. Oncoming traffic is modelled as pairs of moving circles rather than being baked into the mask, since re-rasterising a full-resolution surface every frame would cost more than the entire physics step.

TierWhat it generatesWhy it's harder
EasyWide circuit, gentle radiiMost corners can be taken flat
MediumTighter corners, varying widthRequires braking and placement
HardTyre stacks on the tarmacThe racing line is now blocked
ExtremeOncoming traffic holding a laneMust pass through a moving gap

πŸ”§ Three Things That Went Wrong

The circuit that taught one corner. The first track had a single wide corner. The policy trained on it lapped beautifully and then fell apart on anything else β€” it hadn't learned to corner, it had learned that corner. The replacement is deliberately unpleasant: twelve corners that can't be taken flat, a long straight that ends in one of them, and three pinch points where the road narrows to roughly twice the width of the car.

The impossible pinch point. An early version put one of those narrow sections mid-corner. Threading an 80 px gap on a line the car must already be braking for isn't a hard corner, it's an unachievable one, and the trained policy simply could not complete a lap. Moving all three pinches onto near-straight sections fixed it. The lesson generalised: when a policy plateaus at zero, suspect the environment before the algorithm.

The track that was entirely wall. The mask read as solid barrier on macOS and worked fine elsewhere. The cause was reading pixels back with array2d, which returns the packed pixel value as a signed 32-bit integer. On a surface carrying an alpha channel β€” which macOS provides and Linux doesn't β€” opaque black packs to 0xFF000000, overflows to βˆ’16777216, and every "is this pixel road?" comparison fails. Reading through array3d instead returns plain RGB bytes regardless of the underlying surface format.

View the code on GitHub
β„Ή

About Me

I'm Doruk Orak, a 12th grade student at Sankt Georg Austrian High School in Istanbul.

My interests span hardware design, artificial intelligence, programming, and physics. Connecting the world of intelligent systems with physical circuits.