Ten cabs, one road graph, and the bugs only a fleet could find
Over four days in the middle of August I built a taxi firm into the town: ten cars that drive themselves around a real road network, a fare you are quoted before you commit, an app on the player's phone to book from, and a back seat you actually sit in while the car drives you there. No fade-to-black. You watch the journey out of the window.
This entry is about the parts that went wrong, because they were not the parts I expected, and one of them is a lesson I would hand to anyone building AI vehicles in any engine.
What we were trying to do, and why it mattered
The town is walkable, but it had grown large enough that crossing it on foot was becoming a tax on the player rather than a texture. A bus already ran a fixed route. What was missing was point-to-point: pick a destination, pay for it, get taken there.
The design was deliberately unglamorous. Ride in the back, not a loading screen. Distance-based fare, quoted before you book, so you can decline it. A real wait, with the car visibly coming to you on a live map. That last requirement shaped everything else: if the player can watch the car approach, the car has to be genuinely driving, on genuine roads, from wherever it genuinely was.
The road network already existed as scenery
The most useful discovery came before any code. Every house door in the residential district already carried a text tag with its street address — added long ago for a different reason. Nobody had built an address database, and nobody needed to: 546 terrace doors, 63 semi-detached pairs, plus shops, plots and landmarks came to 697 addresses, already positioned, already in the world.
A door is also a much better arrival point than a house. It is where a real cab drops you.
So the pipeline became five scripts and no hand-authoring: survey the level for address tags, dump the road splines, bake a route graph offline, seed a data asset, place the fleet. Add a street, rerun the five. The bake produced 127 junction nodes, 298 directed edges and 11,618 lane waypoints, and precomputed all-pairs routing — 16,129 rows, zero unroutable pairs, longest journey 17 hops.
Directed, because one-way streets and roundabouts only mean anything if the graph knows which way round they go. A separate satnav feature uses an undirected version of the same roads, with a routing cost that is deliberately slightly wrong as a joke at the player's expense. That joke must not reach a driver, so this bake costs edges by honest travel time.
Two small rules do most of the work in making a stop feel real. A stop is only accepted if the building is on the car's left, so the passenger always steps out onto the pavement rather than into traffic. The car is then nudged the remaining distance to the kerb — about 40 cm on a residential street, which is roughly how close a cab actually parks.
The fare was solved, not guessed
The brief was one sentence: an average fare should feel like an average UK ride-hail trip without surge, about £11. The shape is the familiar one — base, booking fee, per-kilometre, per-minute, floored by a minimum.
The interesting bit is that the per-kilometre rate cannot be a real-world number, and that is the point. The town is small: landmark to landmark is 63 m to 1,642 m, median about 400 m, well under a minute of driving. At an honest rate per kilometre, every journey lands on the minimum — a flat fare wearing a distance fare's costume. The rate that put the mean on £11 while keeping the most variation between journeys was several times a real one. The spread runs £7.50 to £23.43, median £9.23, mean £10.98, 24 of 84 landmark pairs on the floor. The bake reprints that spread every run and complains if the mean drifts or if more than half the journeys hit the minimum, because a tuning constant nobody re-checks is a constant that quietly stops being right.
One booby trap worth naming: the fare formula exists twice, once in the offline baker and once as literals inside the in-game quote. A quote that disagrees with what you are charged reads as theft. They change together or not at all.
The trap: one car is not a test of a fleet
Here is the symptom, in the words you would type into a search box:
all my AI vehicles drive to the same place
my AI cars are climbing on top of each other
the car left the road the instant I gave it a destination
The movement code these cabs use was not new. A single police car had been driving the same town with the same waypoint follower for months without showing any of these. The first time ten vehicles ran at once, all three appeared in one session.
One. The "which edge am I on" index was never initialised. It defaulted to zero, and the routine that answers which junction am I standing at derives the answer from that index — so all ten cars sincerely believed they were parked at the same junction, and all ten set off for the same corner. An uninitialised integer is invisible when there is only one instance, because with one instance the wrong answer is still a consistent answer.
Two. Each car keeps its wheels on the road by tracing downward onto the ground. Those traces hit other cars, because the car body blocked the visibility channel. A cab would trace onto its neighbour's roof, decide that was the ground, and climb it: I measured a 54.7-degree pitch and cars spread across seventeen metres of vertical. The fix is one collision setting — ignore the visibility channel on the body, while staying solid to the player — but you never see the need for it until two of the things exist. Any multi-vehicle system that conforms to the ground needs this.
Three. Assigning a fare made the car re-route immediately, from a junction it had not reached yet. It cut the corner literally: 165 metres off its lane, 328 metres still to run, straight across people's gardens. The fix was to stop re-routing on assignment and let the car finish the street it is in — the route only changes when it genuinely arrives.
The measurement that found the third one is the reusable part. For each car I recorded the distance from where it was to the nearest waypoint of the road it believed it was on. Is it moving and is it moving along the road are different questions, and only the second catches a car driving beautifully through a garden.
A fourth, same family: half the fleet simply sat still, having "detected an obstacle". The obstacle was the road. The forward probe was a 70 cm sphere centred 70 cm off the ground, so its underside grazed the tarmac continuously. I found it by re-running the identical probe from a script and printing what it hit — the road, seven times out of ten. Raising the centre to 120 cm, high enough to clear kerbs and low enough to still see a wheelie bin or a pedestrian, fixed it; flattening the probe direction to yaw only stopped it aiming into the hill on any downslope.
What the back seat showed that no metric did
Once it was rideable I took a trip, and three things came out of that one journey that no metric had raised: the cab drove through the pillars of an elevated road on the way to the betting shop, spun on the spot for no reason, and appeared to skip a whole stretch of country lane. All three were real and all three had mechanisms.
Through the flyover. One route runs along an embankment with up to nine metres of air under the deck. The baked waypoints were fine — traced from their own height, 191 of 198 hit the road surface within a few centimetres. The runtime trace was the problem: it searched downward from the car's own position, in a window shorter than the drop beside the embankment, so as soon as the car drifted it found the ground far below and conformed to it. Anchor a ground-conforming vehicle's trace on the route it is following, never on the vehicle itself. Do that and it becomes geometrically incapable of conforming to ground nine metres below its own lane.
Spinning on the spot. The car advanced to the next waypoint only when within 2.6 m of it. A car that cannot turn tightly enough orbits the waypoint forever at a wider radius and never advances. The test is the part worth writing down: driving every one of the 298 edges from a clean start produced zero orbits, which is exactly why it survived the earlier fleet run. Replaying with a realistic junction entry instead — already moving, a few metres off the lane, well over 100 degrees off heading — reproduced it on 123 of 894 runs. Test a waypoint follower from a bad entry state; the clean case proves nothing. The fix adds a second way to advance: you may also move on once you have driven past the waypoint. That second test has to be distance-gated, or the car sheds a whole road in a second, which is the symptom you started with.
Skipping the lane. Switching onto a new road, the car snapped to the nearest waypoint on it — globally nearest, no limit. On a one-kilometre loop "nearest" can be hundreds of metres along, so the car aimed cross-country at a point most of a road away. The fix is one line: seed the search's best-distance with a sensible radius instead of infinity, so a far waypoint can never win. **The initial value is the gate; no extra branch needed.**
One more from the same pass, and the more general trap. The request was "about 12 mph on the winding lanes, faster on the straights", so I modelled it as cornering force. It produced 12 mph everywhere, then 25 mph everywhere, and an assertion caught the second one. Measuring the geometry explained why: the sharpest corner on that lane has a 221-metre radius, which is a 52 mph bend. The lane reads as "winding" because of its hedgerows, not its curvature. The look was not in the geometry, so no physical model was ever going to find it. Absolute radius thresholds gave the intended 12–25 mph band at a 19 mph median, and the journey it had been ruining went from 72 seconds to 146 rather than 211.
The app on the phone
The booking screen went in as page four of the in-game phone: a destination list with indicative fares, a live map showing you and the car, and one contextual button that reads REQUEST, NOT ENOUGH CASH, ON THE WAY, GET IN, ENJOY THE RIDE, ARRIVED.
Deliberately, the app added no game rules at all. Every rule already lived on the game state: nearest stop, quote, book, cancel, with a single state value from 0 to 4. Booking is one call. A UI layer that starts making decisions the simulation should be making gives you two sources of truth and a permanent support burden. Two things in it are worth stealing.
Quoting is a write, so it has to be gated. Producing a quote stores the fare that will later be charged. Quoting while a ride is already running would silently over- or under-charge the player. Every path that quotes is therefore gated on "no ride in progress", and the destination list greys out while you are in a car.
Two lists that must agree are built by one script. The app needs the internal stop indices and the human names to show. Maintained separately they drift the moment the addresses are re-baked, and the failure is silent and awful: the player taps the nightclub and gets driven to the job centre, with no error anywhere. So one pass writes both, matching on a normalised key because the survey and the game spell names differently, and it raises rather than skipping if a landmark cannot be matched.
Two silent failures that cost the most time
Neither of these errored. Both compiled clean.
A getter bound to the wrong thing entirely. The car had a variable called StopLoc; the route data asset had an array called StopLoc. The call to read the array's entry resolved to the car's own variable, and the array index was simply dropped. It compiles. Every cab would have parked at the world origin. The rule I now follow: a Blueprint must not share a variable name-stem with any data asset it reads. Renaming the car's copy fixed it, and the only reason it was caught at all was a three-line throwaway probe written specifically to check that one call did what it claimed.
The wheels came off, sideways. The report was "the wheels aren't attached to the car and they're driving sideways". An earlier repair had left the car body as the actor's root component, with all seventeen other parts hanging off it. Two rules fall straight out of that, and they apply to any multi-part actor:
- **A root component's relative rotation is the actor's rotation.** The body carried a
90-degree correction for the imported mesh's facing; the first time the driving code set the actor's rotation, that correction was wiped and the car pointed sideways.
- Sibling components hold actor-space offsets. Rotating whatever they hang off flings all
of them out about the origin — which is exactly what "the wheels aren't attached" looked like.
The repair was to add a fresh empty root and reparent all eighteen components to it. The reparent operation preserves relative values verbatim; all eighteen read back byte-identical and nothing had to be re-entered. The usual safe play — rehearse the surgery on a duplicate — was not available, because duplicating this particular asset hard-crashes the editor. So it was done in place and verified by reading every value back.
What is actually proven, and what is not
Being straight about this is the point of the format.
- The fleet has been seen driving, in an editor Simulate run: ten cars, all on different
roads, median 2 m off their lane, worst 5.5 m, none more than 20 m off, and each at the speed posted on the road it was on.
- The ride has been ridden — the three defects above came from the back seat, so booking,
waiting, boarding and being driven all ran end to end at least once.
- The fixes made after that ride have not been seen running. Their evidence is live-level
traces, data read back out of the asset, and an offline replay of the Blueprint's own maths using its real turn rate and acceleration constants. That is verification on paper. I would not call it tested.
- The phone app has never been run at all. It compiles, every argument was confirmed against
a pin dump, and nothing has been seen on screen. Whether the column even fits the phone's height is an open question. A debug key still books a cab, and that is what the one later session I have a log for used — in which a cab did arrive and the player could not board it, because an unrelated screen had put the game into a UI-only input mode and swallowed the key. Systems fail in each other's company, which is another argument for testing in company.
- Known outstanding: no steering angle on the front wheels (they roll but do not turn), no
driver in the car, no door animation, and the swerve-around-obstacles offset is still ±1.75 m, which is a lot of room on a 4.5-metre single track beside a nine-metre drop. That is the next suspect if a cab ever clips the embankment again.
What to take from it
- One instance is not a test of a system. Three bugs sat undiscovered in shared movement
code for months because only one vehicle had ever used it. If your design supports many, test many, early.
- Measure the right question. "Is it moving" and "is it moving along the road" are
different, and only the second finds a vehicle driving perfectly through somebody's garden.
- Test a follower from a bad entry state. Clean-start runs gave a flawless result on every
road. A realistic messy entry broke one run in seven.
- Anchor a ground-conforming vehicle on its route, not on itself. Any window that follows
the vehicle's own drift will eventually follow it somewhere wrong.
- Watch for silent name collisions. A getter that binds to the wrong same-named thing
compiles, reads back plausibly, and does nothing. Verify by type, not by what the tooling prints back at you.
- When the requested behaviour isn't in the geometry, no physical model will find it. The
lane reads as winding because of what is beside it. Sometimes the honest answer is a hand-authored number with a comment explaining why the clever version failed.