Follow-the-gap: how our 1/10 race car picks a line
October 17, 2026
·3 min read
Our CoVAPSy car makes every driving decision with one Python function. Fifteen times a second it takes a 360-beam LiDAR scan and returns two numbers: a steering angle and a speed. No map, no localisation, no learned model. This post walks through what happens in between, using the actual constants from the car.
1. Clean the scan
The RPLIDAR returns one range per degree, with 0° straight ahead. Missing returns, NaNs and anything under 5 cm become 5 m: on a track narrower than 5 m, "nothing there" and "far away" mean the same thing. Everything is clipped at 5 m.
2. Make obstacles as wide as the car
A raw scan treats the car as a point. Wherever two neighbouring beams differ by more than 35 cm there is an edge, and the car could clip it. So at every edge we lower the beams on the far side to the near distance, over an angle that covers half the car's width plus a margin, 25 cm in total. At 1 m that is about 14 beams.One detail took a while to find. The edges must be detected on a copy of the scan, not on the array being modified. Otherwise each widened obstacle creates a new edge next to it, the widening cascades, and every opening disappears.
3. A bubble around the nearest point
Next we zero out every beam within 18 cm of the nearest obstacle. The angle this covers grows as the obstacle gets closer, so it is capped at 12°: without the cap, a wall right next to the car could swallow the only gap in a corner.
4. Pick the gap, then aim inside it
Beams beyond ±90° are masked, since the car cannot steer there. The gap is the longest run of beams still open. Aiming at its centre keeps the car mid-corridor on straights, but turns into corners late. Aiming at its deepest point commits to the corner early, but snaps between walls on a uniform scan. The car aims halfway between the two:
The deepest point is the middle of the band of beams at the maximum range, not a single beam, so it doesn't flicker to one edge of a flat wall.
On this page
5. Speed from steering and clearance
Speed sits between 0.4 and 1.0 m/s, and the part above 0.4 is scaled down twice: by up to 45% for hard steering, and by the clearance in a 40° cone ahead, from nothing at 30 cm to all of it at 1.5 m. It is then capped so the car never closes on that clearance faster than 1 s allows:
Python
speed = min(speed, clearance_ahead / 1.0)
As long as a gap exists the car never drops below 0.2 m/s, so it cannot stall in front of a gap it could take.
6. Dead ends
If there is no gap, or anything within 30 cm in the ±15° cone ahead, the car backs out. It reverses with full lock toward whichever side has more room, comparing the median clearance from 20° to 85° on each side, but only if the rear IR sensor reports the space behind clear. If the front and rear are both blocked, it stops.
Testing without the car
gap_follow() is a pure function, which made two things easy. Ten unit tests pin down its behaviour: open field, wall on one side, boxed in with the rear clear and with it blocked, a wall ahead, the speed and steering clamps, garbage input. And the Webots simulator imports the same function unchanged, so a change can be raced against three other simulated cars before it ever touches the real one.
What it doesn't do
Speed is open-loop: the commanded m/s maps to a pulse width, and the wheel encoder only drives stuck detection, not a speed controller. The widest gap is not always the best line: on a fork, the algorithm takes the branch that looks wider from where the car is, even when the other one is the faster line. Those are the next two things to fix.