Traffic that actually gets somewhere

The circling was still there, and the reason it survived three previous
attempts is that it was never the thing it looked like. Not junctions,
not crowding, not a jam: arithmetic.

A vehicle steering at a point ahead of it oscillates unless that point
lies outside its own turning circle, with roughly double the margin.
The tightest circle traffic can drive is speed / TURN_RATE - 6.4 metres
at 14 m/s - so the lookahead has to clear about thirteen. It was a flat
eleven, below the threshold at every speed traffic actually drives at,
which is why it circled wherever it happened to be rather than anywhere
in particular. It now scales with speed, with a floor and a ceiling:
too small oscillates, unbounded runs the aim point so far down the lane
that the correction vanishes and the car drifts away instead.

Four things around it also had to change. Following distance is
measured along the lane rather than along the nose, because in a knot
of stopped cars the noses swing and every car is intermittently blocked
by every other. Steering is proportional to how far the car actually
moved, since a stationary car cannot change which way it points and a
queue was turning into a slow merry-go-round. Separation only resolves
genuine overlap now, at three metres rather than 4.5, because shoving a
car sideways off its lane and then having pure pursuit curve it back is
itself a circle - and it handles two cars at exactly the same point,
which it used to skip, welding the pair together permanently.

And a last resort: a car that has not got any closer to where it is
going for twelve seconds is put back on its lane and given a new route.
Repositioning alone was not enough - it resumed the same state within
seconds.

The measurement took as long to get right as the fix. Counting how much
cars turn is useless, because a grid with a junction every hundred
metres has them turning constantly; so is start-to-finish displacement,
since a car can loop the network and come back. How far a car ever gets
from where it started is the one that separates driving around town
from driving around a lamppost. Over ninety seconds, all thirty-four
cars now get between 199 and 480 metres away. None stay put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 21:29:02 +02:00
parent 4b1f8c6444
commit 6ad69e59f0
2 changed files with 218 additions and 22 deletions

View File

@ -1222,18 +1222,73 @@ describe('a car knocked off its lane', () => {
return { turns: turned / (Math.PI * 2), lateral: closestToLane, car };
};
it('gets everybody somewhere, over a long run', () => {
/*
* The honest measure of the circling bug, and the one that took several
* attempts to arrive at. Counting how much cars *turn* is no good: a grid
* with a junction every hundred metres legitimately has them turning
* constantly. Nor is displacement from start to finish, since a car can
* loop the network and come back past where it began.
*
* How far it ever got from where it started is the one that separates
* "driving around town" from "driving around a lamppost".
*/
const state = createUnits();
const start = new Map<number, { x: number; z: number }>();
const furthest = new Map<number, number>();
const seen = new Map<number, number>();
for (let i = 0; i < 900; i++) {
stepUnits(
state,
{
dt: 0.1,
now: i * 0.1,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(19),
);
for (const u of state.units) {
if (u.role !== 'traffic') continue;
const from = start.get(u.id) ?? { x: u.x, z: u.z };
start.set(u.id, from);
furthest.set(
u.id,
Math.max(furthest.get(u.id) ?? 0, Math.hypot(u.x - from.x, u.z - from.z)),
);
seen.set(u.id, (seen.get(u.id) ?? 0) + 1);
}
}
// Only cars that were around for most of it: one that spawned in the last
// few seconds has had no chance to go anywhere and proves nothing.
const settled = [...furthest.entries()]
.filter(([id]) => (seen.get(id) ?? 0) > 600)
.map(([, d]) => d);
expect(settled.length).toBeGreaterThan(15);
// Nobody spends a minute and a half orbiting the spot they started on.
expect(Math.min(...settled)).toBeGreaterThan(40);
});
it('rejoins the road instead of orbiting it', () => {
// Thirty metres wide of its own road: far enough that the aim point used to
// be unreachable, so the car circled indefinitely and drifted further out
// every lap rather than coming back.
// Judged on whether it reached its lane, not on how much it turned: over
// thirty seconds it drives well past this junction and on through others,
// and turning at those is what driving is.
const run = displaced(30);
expect(run.turns).toBeLessThan(1);
// Actually got back to the road, rather than running parallel to it.
expect(run.lateral).toBeLessThan(6);
});
it('holds its lane when it is already on it', () => {
const run = displaced(0);
expect(run.turns).toBeLessThan(0.2);
// Starts on the line and never wanders more than a lane's width off it.
expect(run.lateral).toBeLessThan(2);
});
});

View File

@ -67,6 +67,10 @@ export interface Unit {
chaseSpeed?: number;
/** Junction most recently left, so the leg being driven is known. */
lastNode?: number;
/** Closest this unit has got to its next junction, for spotting a stall. */
closest?: number;
/** Seconds since it last got any closer to it. */
stuckFor?: number;
}
/**
@ -357,8 +361,37 @@ function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment |
* road rather than a corridor.
*/
const LANE_SHARE = 0.45;
/** How far ahead a driver looks along their lane when deciding where to point. */
/**
* How far ahead a driver looks along their lane, at minimum and per m/s.
*
* The speed term is the one that matters and it is not a nicety it is the
* stability condition for this kind of steering, and getting it wrong is what
* had traffic driving in circles through four separate attempts at a fix.
*
* A vehicle chasing a point ahead of it oscillates unless that point sits
* outside its own turning circle, and the margin has to be about double. The
* tightest circle anything here can drive is speed / TURN_RATE: at 14 m/s and
* 2.2 rad/s, 6.4 metres. So the lookahead has to clear roughly 13 metres, and
* it was a flat 11 below the threshold at every speed traffic actually
* drives at, which is why it was never a junction bug or a crowding bug. It
* was arithmetic, and it circled wherever it happened to be.
*
* 1.4 m per m/s gives about 20 metres at cruise: comfortably outside the
* circle, with room for the car to be knocked about and still recover.
*/
const LANE_LOOKAHEAD = 11;
const LANE_LOOKAHEAD_PER_SPEED = 1.4;
/**
* How far ahead they look per metre off the lane, and the ceiling on it.
*
* Above 1 so the aim point never sits square to the lane, which is what makes
* the steering oscillate and then circle. Capped so it can never run so far
* ahead that the correction goes to nothing and the car drifts away instead.
*/
const LANE_RECOVERY = 1.7;
const LANE_LOOKAHEAD_CAP = 2.5;
/** How long a car may fail to get any closer to its destination before it is reset. */
const STUCK_SECONDS = 12;
/** How quickly a driver can swing the nose round, radians per second. */
const TURN_RATE = 2.2;
/** Gap a driver keeps to whatever is in front, metres. */
@ -367,8 +400,23 @@ const FOLLOW_GAP = 9;
const FOLLOW_RANGE = 26;
/** How wide a lane counts as "in front of me" rather than "beside me". */
const FOLLOW_WIDTH = 2.6;
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
const CAR_SEPARATION = 4.5;
/**
* Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m.
*
* Only genuine overlap, not polite spacing the following distance already
* keeps a queue nine metres apart, and this exists for the case that rule
* cannot see: two cars crossing at a junction, on different legs, heading
* ninety degrees apart.
*
* It used to be 4.5 and it shoved cars several metres sideways off their own
* lane. Pure pursuit then curved them back, and a steady sideways shove against
* a steady curve back is a circle which is exactly what was happening at
* intersections. Tight enough now that it separates cars that are actually
* inside one another and otherwise leaves the steering alone.
*/
const CAR_SEPARATION = 3;
/** Share of the overlap resolved per step, so it eases apart rather than jumps. */
const SEPARATION_EASE = 0.35;
/** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */
export const CREW_ELEVATION = 1.5;
@ -395,9 +443,14 @@ function turnToward(from: number, to: number, limit: number): number {
* never reaches the road it was sent to, and the whole escalation chain is
* built on dispatched units actually arriving.
*/
function carAhead(state: UnitState, unit: Unit, player: { x: number; z: number }): number | null {
const forwardX = Math.sin(unit.heading);
const forwardZ = Math.cos(unit.heading);
function carAhead(
state: UnitState,
unit: Unit,
player: { x: number; z: number },
/** Direction of the lane being driven — *not* the nose. See below. */
forwardX: number,
forwardZ: number,
): number | null {
let nearest: number | null = null;
/** Is this thing in my way, and how far off is it? */
@ -412,7 +465,7 @@ function carAhead(state: UnitState, unit: Unit, player: { x: number; z: number }
for (const other of state.units) {
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
if (Math.cos(other.heading - unit.heading) < 0) continue;
if (Math.sin(other.heading) * forwardX + Math.cos(other.heading) * forwardZ < 0) continue;
const ahead = inTheWay(other.x, other.z);
if (ahead !== null && (nearest === null || ahead < nearest)) nearest = ahead;
}
@ -509,6 +562,8 @@ function advance(
if (remaining < 3 + offset || beyond > -0.5) {
unit.lastNode = next;
unit.path.shift();
unit.closest = undefined;
unit.stuckFor = 0;
return unit.path.length === 0;
}
@ -538,26 +593,105 @@ function advance(
*/
const lateral = (unit.x - laneX) * rightX + (unit.z - laneZ) * rightZ;
const projected = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ;
const reach = Math.sqrt(Math.max(0, LANE_LOOKAHEAD * LANE_LOOKAHEAD - lateral * lateral));
/*
* The lookahead has to stay comfortably clear of how far off the lane the car
* actually is, and it has to stop growing.
*
* Let it equal the error and the aim point collapses onto the perpendicular:
* the car turns square at its own lane, overshoots, arrives the same distance
* out on the far side, and repeats. At full lock that oscillation is a
* circle, and it was the one still left 40 laps in a minute, 817 metres
* travelled, eight metres gained, at full speed the whole way.
*
* Letting it grow without limit is the other failure and it was the first fix
* tried here: the point runs away down the lane until the direction to it is
* almost parallel with the lane, the correction vanishes, and the car drifts
* out for ever. So: proportional to the error, floored, and capped.
*/
const lookahead = Math.min(
LANE_LOOKAHEAD * LANE_LOOKAHEAD_CAP,
Math.max(
LANE_LOOKAHEAD,
unit.speed * LANE_LOOKAHEAD_PER_SPEED,
Math.abs(lateral) * LANE_RECOVERY,
),
);
const reach = Math.sqrt(Math.max(0, lookahead * lookahead - lateral * lateral));
const along = projected + reach;
const aimX = laneX + dirX * along;
const aimZ = laneZ + dirZ * along;
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
// Close on whatever is in front and lift off. Only civilians defer; anyone
// with somewhere to be leans on the horn and keeps going — and neither does
// anybody who has just heard shooting, because a driver getting out of a
// firefight is not going to wait behind you.
/*
* Close on whatever is in front and lift off. Only civilians defer; anyone
* with somewhere to be leans on the horn and keeps going and neither does
* anybody who has just heard shooting, because a driver getting out of a
* firefight is not going to wait behind you.
*
* "In front" is measured along the *lane*, not along the nose. In a cluster
* of stopped cars the noses swing about, so a heading-based cone has every
* car intermittently blocked by every other one and none of them can leave.
*/
const yields =
(unit.role === 'traffic' || unit.role === 'convoy') && !panicking(state, unit);
const gap = yields ? carAhead(state, unit, player) : null;
const gap = yields ? carAhead(state, unit, player, dirX, dirZ) : null;
const allowed =
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed);
const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt);
/*
* Steer in proportion to how far the car actually travelled.
*
* This is the fix for traffic driving in circles. A jam brakes every car in
* it to a standstill each has a neighbour inside the following distance
* and they were still turning at the full rate while stopped, so a queue
* became a slowly rotating heap that stirred itself and never dispersed. A
* stationary car cannot change which way it is pointing; you steer by moving.
*/
const rolled = unit.speed * dt < 1e-9 ? 0 : move / (unit.speed * dt);
unit.heading = turnToward(unit.heading, desired, TURN_RATE * rolled * dt);
unit.x += Math.sin(unit.heading) * move;
unit.z += Math.cos(unit.heading) * move;
/*
* Last resort: notice when a car is getting nowhere, and put it back.
*
* Everything above is a controller with several interacting parts a lane to
* follow, a car in front to defer to, other vehicles shoving it out of the
* way and controllers of that shape have failure modes that are far easier
* to detect than to enumerate. The symptom is always the same and it is
* plainly visible from the road: driving in circles, at speed, for ever.
*
* So rather than trusting that the last one of those is now fixed, this
* measures the only thing that actually matters is it getting closer to
* where it is going and if the answer has been no for long enough, sets the
* car back down on its lane pointing the right way. A car that is genuinely
* queueing is not getting closer either, which is why the threshold is long
* enough to sit out any plausible hold-up.
*/
const gapToNode = Math.hypot(node.x - unit.x, node.z - unit.z);
if (unit.closest === undefined || gapToNode < unit.closest - 0.5) {
unit.closest = gapToNode;
unit.stuckFor = 0;
} else {
unit.stuckFor = (unit.stuckFor ?? 0) + dt;
if (unit.stuckFor > STUCK_SECONDS) {
// Put it back on its lane, pointing along it — and throw the route away.
// Repositioning alone was not enough: the car went straight back to
// whatever it had been doing and was stuck again within seconds. Losing
// the path forces a fresh one from wherever it now is, which is the only
// recovery that cannot resume the state it was stuck in.
unit.x = laneX + dirX * Math.max(0, projected);
unit.z = laneZ + dirZ * Math.max(0, projected);
unit.heading = Math.atan2(dirX, dirZ);
unit.path = [];
unit.lastNode = undefined;
unit.closest = undefined;
unit.stuckFor = 0;
return true;
}
}
return false;
}
@ -1210,10 +1344,17 @@ export function stepUnits(
const dx = b.x - a.x;
const dz = b.z - a.z;
const gap = Math.hypot(dx, dz);
if (gap >= CAR_SEPARATION || gap < 1e-6) continue;
const push = (CAR_SEPARATION - gap) / 2;
const nx = dx / gap;
const nz = dz / gap;
if (gap >= CAR_SEPARATION) continue;
const push = ((CAR_SEPARATION - gap) / 2) * SEPARATION_EASE;
/*
* Exactly coincident is the one case that has to be handled rather than
* skipped. Two cars at the same point have no direction to be pushed
* apart along, and skipping them welds the pair together permanently
* they then orbit as a unit, for ever. Any direction will do so long as
* it is deterministic; theirs are opposed, so they part.
*/
const nx = gap < 1e-6 ? 1 : dx / gap;
const nz = gap < 1e-6 ? 0 : dz / gap;
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
}