Twelve bus stops, and seven of them landed on the empty ring road
The town had roads, houses and people walking about, and no way to get anywhere. This week's job was the number 4: a real circular route through the estate, twelve stops sited where people actually live, a driver in the cab, a fare to pay, and a saloon you can stand in while it moves.
It mostly works. Several parts of it went wrong in ways that reported success, which is the only reason this is worth writing up.
What we were trying to do
The estate is walking-scale but tediously so, and a bus does two jobs at once. It makes the place read as served rather than decorated — a timetable implies an operator, an operator implies a town. And it is the hardest possible test rig for everything else: a bus is a moving floor with a character standing on it, an NPC seated in it, physics props in its path, and a dialogue system running inside it at 30 mph.
The method: bake it offline, verify it against the live world
The route is not hand-placed. It comes off a four-stage chain, the same contract the police patrol drive uses:
- Dump the road network, the amenities and all 660 terraced houses out of the level.
- Plan the circuit and site the stops offline, in plain Python — no editor involved,
so it runs in a second and can be re-run a hundred times.
- Verify in the editor: trace every waypoint down onto real geometry and conform it.
- Place the stop actors and seed the bus.
Add a street, re-run all four, done. Keeping the planning offline is the whole point — anything that has to round-trip through the editor gets tuned twice and then not at all.
The circuit shape was decided for us. One boulevard is the estate's only access off the ring road, so the route has to go in, wander the estate, come back out the way it came, and then take a full lap of the ring to get home: 5.89 km, 499 waypoints, two streets driven in both directions.
One geometry note that cost an afternoon: these residential roads T-junction into each other's middles, and every junction mouth overruns the through road by about nine metres. Matching road endpoints to road endpoints therefore fails — my first circuit asserted with a 152 m gap on a junction that is really nine metres wide. A junction has to be measured from both roads and averaged.
The trap: every stop scored the same, so the siting quietly gave up
Symptom to search for: my greedy stop-placement puts everything in one corner and dumps the rest on the empty road.
Demand is the 660 terraces, weight one each, plus the shops and civic buildings. The algorithm is textbook greedy maximum-coverage — repeatedly pick the site that covers the most uncovered demand, with a minimum spacing between picks — and it produced seven of twelve stops on the empty ring road, where nobody lives.
Two constants did it, and neither was obviously wrong.
The catchment radius was set to 400 m because 400 m is the standard UK planning maximum walk to a bus stop. But that is a maximum, not a falloff curve. On an estate 900 m across, a 400 m circle covers most of it from anywhere, so every candidate site scored roughly the same. A greedy algorithm with no gradient saturates: after five picks there was no uncovered demand left to discriminate on, every remaining site tied, and the leftovers fell through to the spacing-only filler — which dutifully spread them along the empty ring. Dropping the radius to 250 m gave the scoring function something to actually rank.
The minimum air-gap between stops was set to 180 m, which sounds like ordinary spacing hygiene. It is not what that constant is for. It exists for exactly one situation: the outbound and return passes over the same street are metres apart in world space but a kilometre apart along the route, and without a gap you get two poles back to back. At 180 m it was also blocking every parallel street in a grid of boulevards — and again pushing stops onto the ring. It belongs at 80 m.
With both fixed: seven estate stops, five ring-road stops, 70% of weighted demand covered, and all 660 houses within a 250 m walk.
The generalisable bit is not the numbers. It is that a scoring function which returns nearly the same value for every candidate has stopped being a scoring function, and greedy selection degrades into arbitrary selection without saying a word. If a heuristic's output looks like it ignored the heuristic, check whether the scores are all equal before you go looking at the algorithm.
The stops did not fit, and no amount of nudging would help
The kit has a nice JCDecaux-style shelter. Measuring the footway at all twelve sites — a lateral scan at each one, not an assumption — gave 147 to 152 cm. The shelter is 133 cm deep and wants a 5 cm rear gap, which leaves 14 to 19 cm of setback, and its canopy cantilevers 30 cm forward. The canopy would hang over the carriageway.
So every stop on this route is pole only. That is not a compromise the placement code makes at runtime, it is a fact about the footways: the shelter cannot be made to fit by moving it, only by widening the pavement to at least 183 cm. One stop elsewhere in town does take a shelter, because its footway happens to be 197.5 cm. Measure the site, then choose the kit.
While conforming waypoints, one of them seated itself 85.7 cm in the air. A first-hit downward trace had landed on a wheelie bin. The verifier now ignores bins, lamp posts, houses, give-way markings, stop poles and shop fronts by name prefix — 97.2% of waypoints now land on tarmac, and the remaining handful are on a stretch of ring road where the terrain buries the carriageway, which is a pre-existing world defect rather than a routing one.
Two halves of the speed system that cannot check each other
The user's note was simple: the bus does seem to travel too fast. The request was ten mph under the posted limit of whatever road it is on.
Nothing needed building — the machinery was already there, carrying invented numbers. But it is split across two places that have no way to validate one another at runtime:
- What a road class means lives in four literal pins inside a Blueprint function that
rebuilds the speed table at BeginPlay.
- Which class each waypoint is lives in a 499-entry array on the class defaults.
Re-bake one without the other and the bus silently drives the wrong speed on the wrong roads. No error, no warning. The array also reads empty if you inspect it, which is correct rather than broken: it is rebuilt on the first frame, so anything baked into it as data is discarded before anyone sees it.
The guard is that the baker now prints the exact literals the four pins must be set to, and raises rather than continuing if the route ever needs more than four distinct speeds. A separate verifier re-derives the posted limit under every one of the 499 waypoints and checks the resulting speed end to end. Run it after touching either half.
Snapping the route to the live road network showed the real spread: 237 waypoints on 20 mph roads, 261 on 40 mph roads, and precisely one clipping a dual carriageway. That last one gives a single-waypoint lurch to 60 mph, and it is a genuine nearest-road hit rather than a mis-snap — left in, but flagged, because it is the obvious thing to cap if it reads badly. Obeying the limits roughly doubled the lap, from about seven and a half minutes to just under fifteen.
The driver: "he's currently crumpled"
Symptom to search for: my MetaHuman collapsed into a heap and the bounding box still looks fine.
Phil is a MetaHuman seated in the cab, attached to the bus so he rides along, playing a driving idle. Setting that animation on the placed actor's body component collapsed him into roughly a 30 cm pile on the cab floor, face and all six grooms included.
The mechanism: writing animation data to a placed MetaHuman body triggers a component reconstruction that drops the write. Six consecutive reads afterwards all reported no animation, including the read taken immediately after the write. Worse, the losing fight between that write and the mesh assignment left the component with no valid pose at all — every bone's socket location returned the actor origin, so every bone was in the same place.
The reason it is hard to catch: the actor's bounds still read a plausible 170.7 cm and every scale still read 1.0. A bounding box does not reveal a collapsed skeleton. The cheap test is bone separation — pelvis to neck (zero means collapsed), pelvis to foot (about 55 cm seated against 80 standing, so a low number is correct here), and knee forward of pelvis. Three distances tell you "the clip is playing" from "he is stood in the reference pose" without taking a screenshot.
The fix is to put the clip on the Blueprint template's body component, compile, save, then delete and re-place the actor — a fresh spawn arrives already playing it, correctly seated. The repair script's shape is the real lesson: delete, re-place, and check the skeleton after every step, so the step that breaks it names itself.
Two adjacent traps from the same work, both of which produce confident nonsense:
- **Sampling an animation by stepping a placed component's playback head returns identical
samples.** Twenty-one byte-identical poses across five seconds of a 150-frame clip, which reads exactly like "this is a static idle" and is completely false. Editor-world skeletal meshes do not re-pose when you move the playhead. Read the asset directly, and make any sampling script assert that its samples differ.
- **Fitting a steering wheel to two hands needs three constraints, and I found them one
visible bug at a time.** Optimising mean distance alone put the wheel between his hands, right hand through the rim — a healthy-looking 6 cm average hid it. Adding the plane constraint but no clock angle parked the wheel 16 cm too high, and the user reported the hands at "seven and five o'clock". And the solve target is not zero, because a gripping wrist bone sits about 6 cm off the rim centre-line. The check that it was finally right: the solved wheel radius independently reproduced the authored mesh radius, giving a component scale of 0.999.
Worth knowing before fitting anything to a hand: the driving clip is a near-static mid-turn pose. His shoulders sit 17.6 cm apart fore-aft, so his torso is turned about 28°, and any circle fitted through both hands comes out canted by that much. Symmetrise about the torso plane first.
Ticketing, and a deliberate refusal to reuse the dialogue system
Fares are £2 for a short hop of five stops and £5 all day, bought by pressing E on the driver. He explains the prices, knows whether your ticket is dead, and is written as a man at work rather than a man in retail: short sentences, "ta" rather than "thank you very much", and audibly thinner patience the longer you hold his bus up.
The obvious implementation was the project's existing banter dialogue system. That was rejected on purpose: the banter system has not been play-verified on the two characters that already use it, so building fares on top of it would turn an unproven dependency into a load-bearing one. The ticket panel is self-contained with its own key path. If banter turns out to be broken, ticketing still works.
Three details worth keeping:
- Expiry compares day and hour as a pair. Comparing hours alone declares a ticket bought
at 22:00 expired the instant it is sold.
- "All day" means until the next 2 a.m., not plus 24 hours. Buy at 1 a.m. and you get one
hour — which is correct, because that is what the ticket says.
- The driver is found by proximity and facing, not by a trace. MetaHuman NPCs are
invisible to the visibility trace in this project, which is why every dialogue system here ended up on a distance-and-angle scan instead.
The key routing matters more than it sounds: the ticket handler reports that it consumed the key press, so E at the driver sells a ticket and E anywhere else on the bus still works the doors. One key, two behaviours, no mode to get stuck in.
Not built, and it should not be implied otherwise: the eject flow. The lines are written for the driver asking a short-hop passenger to get off at their fifth stop and holding the bus until they do, and the variables exist, but nothing decrements the stop counter yet. The purchase side is built and passes its go/no-go check; the throwing-you-off side does not exist.
Riding it
The brief was explicit: the player is inside the bus while it moves, not perched above the roof like the earlier van ride.
Attaching a character to a moving actor fights the character movement component, so the bus does not attach anyone. It is moved kinematically, and the engine's movement base is expected to carry a player standing on its floor. On top of that sits a leash: each tick the player's position is converted into the bus's local frame, and if they have drifted outside the interior box they are put back. It is a safety net rather than a second movement system — it does nothing in the normal case and leaves you free to walk about.
The exit rule is the bus's own doors-open flag, which is only true when a door has been requested and the bus is nearly stopped. You cannot step off at speed.
Seating reuses the pub's existing sit mechanic rather than growing a second one — the bus spawns tagged marker actors and attaches them, and the sit code finds them exactly as it finds a bar stool. Precisely one thing had to change: the sit system pinned the player to a static world position, so on a moving bus they would simply have been left behind. It now recomputes the pin from the seat's current location every tick, which is a no-op for pub chairs and correct for a bus. Verified by moving the bus with a seated player aboard and confirming they went with it.
What is actually proven: a debug key that teleports the player into the cab is in daily use, so that path works, and the bus has been ridden in play — the report that started the speed work came from watching it, and a separate report of a wheelie bin passing straight through the bus and dragging the player out with it could only have come from being aboard. (That one was fixed with a sweeper on the bus; the bin was never the moving object, the bus was.)
What is not proven: the leash's pull-back branch has never been observed firing, because it only triggers if the player drifts out of the interior while the bus is moving. A full clean lap has not been signed off. The real-time information displays at the stops show a fixed frequency rather than a live countdown, and their clock still reads dashes.
What to take from it
- A heuristic whose scores are all equal has silently become a coin flip. Check the
spread of the scores before debugging the selection.
- A planning standard's maximum is not a modelling parameter. 400 m is the furthest
anyone should have to walk, not a description of how they choose.
- Measure the site before you choose the kit. Fourteen centimetres of setback is not a
tuning problem, it is a "this object does not fit here" problem.
- Bounding boxes do not reveal broken skeletons. Assert on distances between bones.
- Any script that samples an animation must assert the samples differ, or it will
confidently report a flat clip that is nothing of the sort.
- **When one system's meaning lives in two places that cannot see each other at runtime, the
guard is an external verifier that re-derives both halves** — not a comment.
- Do not build a new feature on an unverified dependency just because the dependency is
the tidy answer. Duplication is cheaper than a shared failure.