The bus flies seven metres above the road, and sometimes it just stops for ever
There is a single-decker bus in this game that drives a three-kilometre loop around the town, all day, whether or not anybody is looking at it. It has a driver, a route, speed limits and a set of stops. It is the sort of feature you build once and then never think about again, which is precisely why it had been quietly broken for three weeks.
The developer's report was one sentence:
the bus in 2ndchance is floating in the sky and sometimes getting stuck
Two symptoms, and I assumed — wrongly, for the first hour — that they were one bug. They were not. The float alone turned out to be two independent faults that needed opposite kinds of fix, and the sticking was a third thing entirely.
What I was trying to do
Get a bus to sit on a road. That is the whole brief. The reason it is worth writing up is that every single fix here was in a different layer: one in data that had gone stale, one in the maths of a single node, and one in a condition that could never become false. If you only look in the layer where you expect the bug, you find one third of it, declare victory, and the developer reports the same symptom again next week.
The tooling is the usual for this project: Unreal 5.8 driven from Claude Code over two editor MCP servers, with the Blueprint surgery done from a graph DSL and the measurement done in editor Python.
The bus was on the right route and seven metres above it
First step was measurement rather than reading code, and I am glad of it, because the numbers immediately split the problem in two.
The bus follows a baked array of 499 waypoints, each carrying a height that was conformed to the road surface back at the start of the month. I re-surveyed every one of them against the world as it stands today. Two contiguous runs — a couple of dozen waypoints on the ring road — were sitting up to 707 cm above the carriageway. Everywhere else the baked heights were fine.
The cause was not in the bus at all. Four days after that array was baked, a completely separate job re-solved the ring road's vertical alignment and re-carved the terrain under it. The road moved. Nothing told the bus. The verification file from the original bake still cheerfully records one of those waypoints as being 2.8 cm off the road, and it was telling the truth at the time it was written.
Any array baked onto the world is a dependency on the world, and nothing in an engine will warn you when that dependency moves. This is now the third time this project has been bitten by it: a railway job found a road bake fifteen days stale with a road moved 103 metres, and a golf course had its turf baked seventeen hours before the ground underneath it changed. If you conform anything to geometry, record the date, and re-survey before you trust it.
Fixed the heights, and the bus still bobbed on every hill
Re-seating those 161 waypoints removed the seven-metre float and the bus was still visibly wrong — riding high on climbs, sunk on descents, and bobbing gently once per waypoint like a boat.
This one was in a single node. The height each frame came from an interpolate-to call whose target was the height of the waypoint the bus was driving towards — a waypoint that can be twelve metres ahead. So the bus was always easing toward the height of ground it had not reached yet. Measured over a full lap: mean error 24 cm, worst 164 cm, and 35% of the lap more than 25 cm off the road.
The textbook fix is to lerp along the current segment, which needs the previous waypoint, a modulo for the wrap, a projection onto the segment and a clamp — about a dozen nodes in a Blueprint, and a dozen nodes is a dozen chances to get one wired wrong.
It collapses into two nodes, and this is the bit I would most like other people to steal. **Leave the target as the far waypoint, and make the interpolation speed equal to the bus's own speed divided by its remaining distance to that waypoint.**
Work it through and the exponential ease turns into an exact straight line:
alpha = dt * (speed / remaining) = the fraction of the remaining DISTANCE covered this tick
so dz = that same fraction of the remaining HEIGHT
dz/ds = (B.z - z) / s
-> z = B.z - (B.z - A.z) * s / segLen <- the exact segment lerp
The bus arrives at the waypoint's height at precisely the moment it arrives at the waypoint, and travels the straight line in between — with no lookup of the previous waypoint anywhere. Both quantities were already being computed in the graph for other reasons, so the whole change was one max-of-two-floats (to avoid dividing by zero at the waypoint) and one division.
Mean error went from 24 cm to 5.4 cm. Ticks more than 50 cm out went from 17.4% to zero.
Generalised: when a mover needs to arrive at two things at once, make the secondary interpolation speed proportional to the closing rate of the primary one. It works for turning to face a destination, for a camera settling on a target, for anything where you have "how fast" and "how far left" already in hand.
The obvious fix — just trace down to the ground every frame — is impossible here
I want to record this one properly, because it is what I tried first, it looked completely reasonable, and it cost most of a session before I abandoned it.
Why bake heights at all? Fire a line trace downward from the bus each tick and put it on whatever it hits. Self-correcting, immune to the road moving, twenty minutes' work.
It fails three separate ways in a World Partition world.
One: the trace hits nothing and returns its own start point. Distant geometry is represented by baked HLOD proxies. The proxy here is query-only with visibility set to block, and its instanced collision encloses the trace origin. So the trace registers a blocking hit immediately and hands back where it started. My first diagnostic run was full of "worst gap" figures of exactly −300 cm, which is not a measurement of anything — it is the trace's own start offset, reported back to me with a straight face. A number that is suspiciously identical across every sample is not data.
Two: you cannot dodge it with a trace channel, because there aren't any. This project defines exactly two trace channels, visibility and camera, and the HLOD proxy blocks both. There are collision channels that ignore HLOD and would work beautifully — except that collision channels are not trace channels, and a Blueprint line-trace-by-channel cannot address them.
The way through, for anyone who needs it: use an object query rather than a channel trace. Ask for world-static and world-dynamic objects, take the whole stack of hits rather than the first, and step past anything whose class or label marks it as an HLOD proxy. The real carriageway is sitting underneath, exactly where you expected it.
Three: even then, down is full of things that are not the ground. Two of my survey stations landed on a parked taxi and a wheelie bin. A bus that follows the ground every frame is a bus that will happily drive up onto a parked car.
And the killer argument against the whole idea: once I could measure properly, the difference between the baked straight-line route and the actual traced ground came out at a median of 4.8 cm across 57 stations. The baked array was already an excellent model of the road. It had simply gone out of date, which is a data problem, not an architecture problem.
Two traps inside the re-survey itself
The survey that produced the corrected heights was wrong twice before it was right, and both mistakes are the kind that produce a confident, plausible, wrong answer.
A road beats the terrain sitting on top of it. Taking the first hit downward seated a dozen waypoints on the landscape rather than the road mesh, and in one dip the terrain answers up to 196 cm above the road. Worse, it invented a fault that did not exist: one waypoint looked 397 cm out and came back at exactly 0.0 once the road surface was preferred. Take the highest road-class hit if there is one; fall back to terrain only where there genuinely is no carriageway.
A spike guard that fires on the shoulder of a run is worse than no guard at all. I had a sensible-sounding rule rejecting any correction that differed from its neighbour's by more than two metres. It threw out the single waypoint forming the ramp into a dip — and in doing so built a 43% cliff where conforming it honestly gives the road's real 20%. "Isolated" has to mean none of my neighbours are being corrected either, not my neighbour's correction is different from mine.
The lesson that came out of both: judge a conform by the resulting grade profile, not by how far it moved things. The accepted pass moved 161 waypoints by up to seven metres and took the steepest gradient on the entire route down.
Running 499 traces across an 8 km map killed the editor
Worth its own heading because it will happen to you. The survey script completed, wrote its results to disk, and the editor died two calls later — the documented World Partition mass-trace crash, where the damage is done by streaming that much of the map in and the process falls over some time afterwards.
Nothing was lost, purely because the script writes its output before doing anything else. The habit that saved the day and the habit worth copying: give any full-sweep survey a "redo" mode that re-traces only the rows a rule change actually affects. Fixing the road-versus-terrain rule above cost 12 traces instead of 499.
The bus stops behind a pedestrian and never starts again
The second half of the developer's sentence, and a genuinely different bug. Two separate conditions could each set the bus's speed to zero, and neither had any way of becoming false again on its own.
The pedestrian check was the worse of the two: an eleven-metre range inside a ±44° cone. Draw that out and the far end of the cone is 10.6 metres wide across a 7-metre road — so a citizen standing on the pavement, minding their own business, well outside the bus's path, stopped the bus. And if that citizen was themselves stuck, the bus waited for them for the rest of the session. The second was a traffic-signal hold that stopped the bus at any non-green signal within 18 metres — and the signal controllers in this town have never been play-tested, so a signal that never turns green is entirely plausible.
There is also a lovely explanation buried in here for the word sometimes. Both checks scan for actors using a call that only sees loaded actors, while the bus itself is flagged never to stream out and ticks constantly. So the bus can only be stopped by pedestrians when the player is near enough for pedestrians to exist. The bug is invisible until you go and look at it, which is a shape worth remembering for any always-loaded actor that scans for streamed ones.
One counter released both holds. A new function at the end of the tick accumulates held-seconds whenever the bus is crawling and zeroes them when it is properly moving; the pedestrian check then refuses to raise its flag past 8 seconds, and the signal hold stops being called at all past 30.
Two details in that are load-bearing:
- The reset threshold is "moving properly", not "moving at all". Resetting the counter
the instant the bus twitches gives you a limp cycle: it creeps two metres past the obstacle, re-acquires it, and stops again for ever, in slow motion. The release from a timeout must require real progress, not merely the end of the hold.
- The honest timings are not 8 and 30 seconds. The speed value those thresholds read
is itself smoothed, and takes about three and a half seconds to fall after the bus actually stops. Real-world releases are around 11.5 and 33.5 seconds. I would rather write that down than quote the pretty number from the graph — and in the signal case the slop is a bonus, since it clears the junction's own 25-second cycle by a wider margin.
One function could not be edited at all, so I edited its caller instead
A tooling note that generalises past this engine. The DSL used to rewrite Blueprint functions in this project has a known blind spot: a loop body that calls a function with the loop's own binding gets gutted in place — silently, with a clean compile and a readback that looks entirely plausible. Both of the functions I needed to change had exactly that shape. The tick event itself is equally un-writable for a different reason.
The escape for the signal hold was to stop asking how to edit it. Its entire effect is to set the speed to zero — so not calling it is exactly equivalent to it doing nothing. I put the guard at the call site, in a graph that could be edited, and got a skipped actor-sweep for free.
When a function cannot be safely rewritten, ask what it does rather than how to change it. If its whole effect is one side effect, the caller is a legitimate place to put the guard.
Where this actually stands
Everything above is built, compiled clean, and saved. The corrected route was written to the class defaults and the placed bus was checked afterwards to confirm it picked the change up and kept its scale and tags.
Nobody has played it. Another session was attached to the editor for most of the day and this project's rule is that when more than one agent is driving, nobody starts a play session. So the improvement from 24 cm to 5.4 cm of ride-height error is a measurement taken from the same simulation that produced the fix — it is arithmetic, not observation. Someone has to ride the loop and watch, and the four things to watch for are: it does not float or sink; it does not sit still for more than about twelve seconds behind a pedestrian; it does not sit still for more than about thirty-four seconds at a signal; and crucially, that it still stops for a pedestrian genuinely in its path and still obeys a signal that is actually cycling. A timeout that has quietly disabled the safety behaviour it was guarding is the obvious way for this fix to be wrong.
I will take the relief when I see it drive a full lap on the tarmac. Until then it is a good hypothesis with good numbers behind it.
What to take from this
- A baked array is a dependency on the world. Date it, and re-survey it after anything
moves the ground. Nothing will tell you.
- **When a mover must arrive at two things at once, drive the secondary interpolation
speed from the closing rate of the primary.** Two nodes replace a dozen, and it is exact rather than approximate.
- A number that is identical across every sample is not a measurement. Mine was the
trace's own start offset, and it looked like a real gap for an hour.
- **A downward trace in a streaming world hits proxies, parked cars and bins before it
hits the ground.** Use an object query, take the whole stack, and skip what you know is not terrain.
- Judge a data correction by the shape of the result, not the size of the change.
- A wait with no timeout is a deadlock waiting for an audience. And the release
condition must require real progress, or you have merely made the deadlock slower.
- Give every expensive full-sweep script a partial re-run mode, before you need it.
- **When your tools cannot safely rewrite something, look at what it does, not how to
edit it.**