Skip to content
Go back

AI Traffic System — Lane Level Maps, IDM, MOBIL, and Why Traffic Simulation Breaks

Edit Views

Table of contents

Open Table of contents

Introduction

The first version always works. You spline a few roads, spawn cars, move them along the centerline at a fixed speed, and the city looks alive from a distance. Then a player parks across a junction, the cars behind pile into a solid stripe of metal, and the grid locks up for good. Nothing crashed and nothing threw an error. The simulation is just stuck, and tuning speed values will not unstick it.

That’s the point where traffic stops being decoration. Cars don’t simply move along roads, they negotiate for space: the gap in front of them, a slot in the next lane, the right to cross a junction before the car opposite does. Every one of those negotiations is a separate algorithm, and they all run at once, sixty times a second, for a few thousand agents.

Below are the layers an AI traffic system needs: lane level maps, the IDM and MOBIL models every classic simulator leans on, and the seams where traffic simulation quietly breaks.

Two references worth having open: SUMO, the de facto standard microscopic simulator, and CityFlow, a much smaller engine built to train reinforcement learning agents fast.

The layers nobody sees

A traffic system looks like one feature and behaves like five. Each layer runs on its own clock and answers a different question.

Lane level map Routing Lane changing Car following Intersection and signals
Five layers behind one moving car

Skip a layer and you don’t get a simpler system. You get one that fails in a way you can’t debug, because the missing layer’s job lands on some other layer that was never designed for it.

The lane level map

A road graph where nodes are junctions and edges are roads is enough to compute a route. It is nowhere near enough to drive one. A car needs to know which lane it’s in, where that lane’s centerline runs, and which lanes it may legally enter next. So the real map is two graphs stacked on each other: the road graph that routing runs on, and the lane graph that treats every lane as its own node, with connectors (SUMO calls them internal lanes) describing the legal moves through a junction.

Edge W lane 1 Connector: left turn Edge W lane 2 Connector: straight Connector: right turn Edge N lane 1 Edge E lane 2 Edge S lane 1
One junction expanded into lane connectors

Each lane carries a centerline polyline or spline, a width, a speed limit, a direction, and a list of outgoing connectors. Each connector carries the part nobody enjoys writing: its conflict set, the list of other connectors it crosses, and the priority relation between them.

Keep positions in Frenet coordinates: (lane_id, s, d) where s is the distance travelled along the centerline and d is the lateral offset. Cartesian position gets derived at render time. That single choice removes a whole class of bugs, because “who is in front of me” becomes a comparison of two s values instead of projecting world positions onto a curve every frame.

What the tools give you

ToolScaleMap formatWhat it’s actually for
SUMOMicro, city sized.net.xml, imports OSMThe reference implementation, huge feature surface, slow to learn
CityFlowMicro, thousands of vehiclesSimple JSON roadnetRL research, built for throughput over realism
OpenTrafficSimMicro to macroOTS XML, OSMJava, research oriented, strong on multi-modal
MATSimMeso, regionalXML network + plansWhole day travel demand, not per-tick dynamics
CARLAMicro, sensor levelOpenDRIVEAutonomous driving stacks, cameras and lidar

None of these drop into a game. What they give you is a well-tested definition of what lane level maps have to contain, and OpenDRIVE, the format CARLA uses, is the closest thing to an industry standard for describing one.

Where the game engines land

Unity ships no traffic system. NavMesh gives you agents that avoid each other, which is a crowd model rather than a traffic model: no lanes, no right of way, no notion of the car in front. It’s still right for pedestrians, and I covered how it reasons about space in the Unity NavMesh post. Most Unity traffic assets are waypoint systems instead, with points every 10 to 20 meters along each lane, each carrying a speed limit and sometimes a yield flag.

Unreal Engine 5 does ship one. Mass AI, the system behind traffic and crowds in City Sample, runs vehicles as ECS entities so thousands of NPC cars update as flat data, and only the ones near the camera get promoted to full physics vehicles. Its road data is lane based with explicit intersection periods, much closer to the model above than a waypoint chain is.

Which brings up the argument in every gamedev thread on this: waypoints or splines. Waypoints are cheap and easy to author. Splines are smooth but you pay to project a car onto the curve every frame. Frenet coordinates settle it, since s along the spline is the cheap scalar you compare and the curve is only evaluated when you need a world position to draw. Waypoint traffic is fine for a racing game where cars are scenery. The moment your open world lets the player stop in a junction, you need lanes, connectors, and conflict sets.

Routing is the easy part

Routing runs on the road graph, and it’s plain shortest path work: Dijkstra or A* with a Euclidean heuristic. The interesting question is what you use as edge cost.

Free flow travel time (length / speed_limit) gives routes that look correct and behave terribly, because every car picks the same “fastest” road and jams it. The standard fix is a cost that grows with load, the BPR function from traffic engineering:

t = t_free * ( 1 + alpha * (q / c)^beta )

t_free  free flow travel time on the edge
q       current flow, vehicles per hour
c       capacity of the edge, vehicles per hour
alpha   0.15 by convention
beta    4 by convention

At q = c travel time is 1.15x free flow. At double capacity it’s 3.4x. That fourth power makes cars abandon a road only once it’s badly congested, instead of oscillating on every small change.

Recompute routes periodically and stagger the recomputes across cars. Rerouting a thousand agents in one frame is the classic traffic system frame spike, and rerouting all of them against the same fresh cost snapshot produces herd behavior, where everyone flees the jam onto the same alternative and creates a new one.

Car following with IDM

Now the per-tick layer. Given the car ahead, how hard do I accelerate? The Intelligent Driver Model by Treiber, Hennecke and Helbing is the standard answer, and it’s one continuous equation with no branches:

a = a_max * [ 1 - (v / v0)^delta - (s_star / s)^2 ]

s_star = s0 + max(0, v * T + (v * dv) / (2 * sqrt(a_max * b)))
SymbolMeaningTypical value
vcurrent speedmeasured each tick
v0desired speedspeed limit, plus driver bias
sbumper to bumper gap to the leadermeasured each tick
dvapproach rate, v - v_leadermeasured each tick
s0minimum standstill gap2 m
Tdesired time headway1.5 s
a_maxcomfortable acceleration1.5 m/s²
bcomfortable deceleration2.0 m/s²
deltaacceleration exponent4

Read it as three competing terms. 1 is “go”. (v / v0)^delta is the free road term that fades you into your desired speed. (s_star / s)^2 is the interaction term, and it explodes as the gap shrinks below what you want. That squaring produces hard braking exactly when it’s needed and almost no influence when the road ahead is clear. s_star itself is the desired gap: a standstill buffer, plus the distance you cover in T seconds, plus a term that grows when you’re closing on the leader fast.

type Driver = {
  v0: number; // desired speed, m/s
  T: number; // time headway, s
  s0: number; // standstill gap, m
  a: number; // max acceleration, m/s^2
  b: number; // comfortable deceleration, m/s^2
};

const DELTA = 4;

export function idmAcceleration(
  d: Driver,
  v: number,
  gap: number,
  leaderSpeed: number
): number {
  const dv = v - leaderSpeed;
  const sStar =
    d.s0 + Math.max(0, v * d.T + (v * dv) / (2 * Math.sqrt(d.a * d.b)));

  // Clamp the gap so a leader at zero distance yields finite braking.
  const s = Math.max(gap, 0.1);

  return d.a * (1 - Math.pow(v / d.v0, DELTA) - Math.pow(sStar / s, 2));
}idm.ts

Two implementation details decide whether this is stable or garbage. Plain Euler integration (v += a * dt) lets a decelerating car pass through zero into negative speed and reverse into the car behind it, so clamp speed at zero and advance distance by the average of old and new speed. And IDM is a continuous model: at dt = 0.1 s it behaves, at dt = 1 s it oscillates and collides. Run the traffic step on a fixed accumulator and interpolate for display. SUMO’s default step is 0.1 s.

Phantom jams are a feature

Feed IDM a ring road at medium density, perturb one car slightly, and a stop-and-go wave forms and travels backwards through the platoon. Nobody braked hard and there is no obstacle in the road. This is string instability, and real traffic does exactly the same thing. The model being right also means “cars randomly stop for no reason” can be a legitimate output, and you now have to tell that apart from a bug in a system where the two look identical.

Lane changing with MOBIL

Car following handles one dimension. The lateral decision needs a different model, and MOBIL (Minimizing Overall Braking Induced by Lane change) is the companion piece to IDM. Its trick is to ask IDM to score hypothetical situations, so it introduces no new parameters. For a candidate change, evaluate three cars before and after the move: you, your new follower, and your old follower.

Target lane Current lane New leader Me after New follower Old leader Me now Old follower
Who MOBIL considers before a lane change

Safety criterion. The car that would end up behind you must not be forced into harder braking than it can accept:

a_new_follower >= -b_safe          b_safe ~ 4 m/s^2

Incentive criterion. The total gain, weighted by how much you care about the others:

(a_me_after - a_me_before)
  + p * [ (a_new_follower_after - a_new_follower_before)
        + (a_old_follower_after - a_old_follower_before) ]
  > delta_a_threshold + a_bias

p is the politeness factor, the most expressive single number in the whole system. At p = 1 drivers weigh everyone’s comfort equally and traffic flows smoothly. At p = 0 they’re egoists who change lanes whenever it helps them. At p < 0 they actively enjoy cutting you off. Set it per driver and the personality of your traffic changes without touching any other code.

delta_a_threshold (around 0.1 m/s²) is a hysteresis band, and without it cars flip between lanes on numerical noise. a_bias encodes asymmetric rules: a keep-right bias in Europe, or a large negative bias that forces a change when the car has to be in a specific lane for its next turn.

// Every field is an IDM acceleration, before and after the hypothetical change.
type LaneChangeContext = {
  meBefore: number;
  meAfter: number;
  newFollowerBefore: number;
  newFollowerAfter: number;
  oldFollowerBefore: number;
  oldFollowerAfter: number;
  bias: number; // keep-right rule, or a mandatory change pushing the car over
};

const B_SAFE = 4.0;
const THRESHOLD = 0.1;

export function shouldChangeLane(ctx: LaneChangeContext, politeness: number) {
  if (ctx.newFollowerAfter < -B_SAFE) return false;

  const selfGain = ctx.meAfter - ctx.meBefore;
  const othersGain =
    ctx.newFollowerAfter -
    ctx.newFollowerBefore +
    (ctx.oldFollowerAfter - ctx.oldFollowerBefore);

  return selfGain + politeness * othersGain > THRESHOLD + ctx.bias;
}mobil.ts

MOBIL is discretionary. It describes a driver who would like a better lane, and says nothing about a driver who must reach the right lane within 80 meters or miss the exit. For those, drive the decision from the route: as the remaining distance shrinks, raise a_bias and lower b_safe until the car accepts a gap it would normally refuse, and finally until it slows down and waits for one.

Add merging and it gets worse. Two cars each politely waiting for the other produce a permanent standoff, and in dense traffic a mandatory changer can find that no acceptable gap ever appears. Production systems bolt on cooperation, where a car that spots a blocked changer ahead deliberately opens a gap. That’s a message between agents, which means your “purely local” model now has communication in it.

Intersections: where it actually breaks

Everything above is a solved problem with a paper attached. Junctions are where implementations diverge and where your simulation locks up. A connector’s conflict set can’t be occupied by two cars at once, and a car that has entered a junction cannot back out. That’s resource allocation with non-preemptable resources, the textbook setup for deadlock.

waits for waits for waits for waits for North car East car South car West car
Circular wait at an unsignalized four way

Four cars, each yielding to the one on its right, each correct by the rules, all stopped forever. No individual car is buggy; the arrangement is.

Gap acceptance

For unsignalized junctions the classic model is gap acceptance. A car on the minor road watches the major stream and enters when the gap exceeds its critical gap t_c:

accept if  t_gap >= t_c

t_c ~ 4-7 s for a left turn across traffic
t_f ~ 2-3 s follow-up time for the next car in the queue

Make t_c a per-driver random draw and you get impatient drivers and cautious ones for free. Then decay t_c with waiting time, or a car at a busy left turn in rush hour waits until the heat death of the universe.

Claims beat rules

The robust approach, and the one most autonomous driving research uses, is to stop encoding right of way as behavior and make it an explicit allocation. Before entering, a car claims a time window on each conflict area along its connector. The junction grants or denies.

alt [free] [occupied] request connector W to N, arrive t0, clear t1 check conflict set for overlap granted entered, then cleared denied, retry Car Junction
Reservation flow at a junction

That buys you three things rule-based yielding never does. An explicit place to break ties (priority, then arrival order, then a stable id, never randomly, or the oscillation comes back). A natural spot to enforce “don’t block the box” by requiring free space on the exit lane before granting. And a deadlock detector: build the wait-for graph, look for a cycle, and if one exists force the lowest-id car through. It’s crude, but a forced move beats a frozen city.

Signals, and where the AI shows up

A signalized junction replaces negotiation with a schedule. The simple version is a fixed cycle of phases, sized with Webster’s formula:

C_opt = (1.5 * L + 5) / (1 - Y)

L   total lost time per cycle, seconds
Y   sum over phases of the critical flow ratio q/s

Green time splits between phases in proportion to their flow ratios. It’s from 1958, it’s a decent baseline, and it degrades badly once demand stops matching the assumptions you sized it with. Above it sit actuated controllers, which extend green while a detector keeps seeing cars. Above those sits adaptive control, and this is where “AI traffic system” usually means reinforcement learning: state is queue lengths and current phase, action is which phase to run next, reward is negative waiting time.

State: queues, phase, elapsed Policy Action: keep or switch phase Simulator step Reward: negative waiting time
RL control loop for one junction

This is why CityFlow exists. Training needs millions of steps and SUMO’s per-step cost makes that painful, so CityFlow trades feature depth for raw speed. The pitfalls are the usual reinforcement learning ones in traffic form. Optimize throughput and the policy learns to starve a minor approach forever, so throughput looks great and one street never gets a green. Independent per-junction agents undo each other, because a neighbor’s optimal policy sends you more cars than you can clear. And a policy trained against IDM drivers has learned to exploit IDM drivers.

Making it run at scale

Microscopic traffic simulation is O(vehicles) per tick with a small constant, and the constant is where you win or lose.

Why this is hard

The difficulty isn’t in any one algorithm. IDM is ten lines and MOBIL is an inequality. It’s in what happens when they meet.

FAQ

Can I build a traffic system on Unity NavMesh? Not on its own. NavMesh solves pathfinding and local avoidance, so cars reach their destination without hitting each other, but there are no lanes, no car-following distance, and no right of way at junctions. Use it for pedestrians and drive vehicles off a lane graph with IDM for the longitudinal part. Unreal's Mass AI in City Sample is the closest thing to a shipped lane-based traffic system in a mainstream engine.
Waypoints or splines for the road network? Neither is the important question. What matters is whether the data is lane level: separate lanes with explicit connectors between them. Waypoints are a lane graph sampled every 10 to 20 meters, splines are the same graph with continuous geometry. Store positions as distance along the lane and the choice becomes an authoring preference rather than an architectural one.
IDM or a simpler follow-the-leader rule? IDM, unless you're rendering fewer than a dozen cars. It's one branchless formula with physically meaningful parameters, and it's the model MOBIL, most papers, and most calibration data assume. A hand-rolled "brake if close" rule costs the same to run and gives you oscillation you'll spend weeks tuning out.
How do I stop four-way deadlocks for good? Don't rely on yield rules alone, because they can produce a legal circular wait. Add explicit reservations at the junction, require free space on the exit lane before granting entry, and run a cycle check on the wait-for graph. When a cycle appears, force the lowest-id car through.
SUMO or CityFlow? SUMO for anything where realism, map fidelity, or feature coverage matters, since it imports OpenStreetMap, models public transport and pedestrians, and has a large ecosystem. CityFlow when you need millions of simulation steps for RL training and can accept a simplified road model.
Where does "AI" actually live in a traffic system? Three places, and only one is machine learning. Search for routing, hand-built behavior models for driving (IDM, MOBIL, gap acceptance), and learned controllers, mostly reinforcement learning for signal timing. The behavior models do the heavy lifting and none of them are neural.

Conclusion

An AI traffic system is five systems in a trench coat: a lane level map, a router, a car-following model, a lane-changing model, and a junction arbiter. Each one is small. IDM fits on a napkin, MOBIL is one inequality, routing is Dijkstra with a load-dependent cost. The hard part is that they’re coupled, they run every tick, and their failure mode isn’t a crash but a city that quietly stops moving.

Start with the map. Get lanes and connectors right, put positions in Frenet coordinates, and add layers on top one at a time so you can tell which one broke. Then accept that some of what looks broken, like the wave of brake lights rolling backwards through traffic with no obstacle causing it, is the simulation getting it exactly right.


Share this post on:

Previous Post
Level Streaming Like GTA — The Keyhole, The LOD Ladder, and Why Open Worlds Hitch
Next Post
Unity NavMesh — How AI Finds Its Way Around