Anthony Ellis
← All projects

Agent simulation · Graph algorithms

Arterial — City Traffic Simulator

Draw roads, zone some land, and a city grows along what you built. Then it jams, and the jam is your fault, because nothing in the code decides how congested a road should be.

Top-down view of a simulated city at dusk: a grid of streets and avenues with lane markings and roundabouts, colored residential, commercial and industrial zones, buildings with lit windows, and cars queued at signals.

About a minute of drawing: 24 junctions, 35 roads, 115 buildings, 585 residents, 7:11 in the evening.

Role
Sole developer
Stack
Vanilla JS · Canvas 2D
Scale
~2,900 lines, 12 sections
Simulation
Fixed 60 Hz, interpolated render
Dependencies
None

Where it came from

Cities: Skylines and Mini Metro are my two favorite games to unwind with, and this started as an attempt to fuse them. It's one of three browser demos I built over about a month of evenings in mid-2026 to have something interactive on this site.

The thing I wanted to get right is the part city builders usually fake. Normally cars are decoration and congestion is a number the game assigns to a road tile based on how many houses are nearby. Here every vehicle has somewhere to be, plans its own way there, follows whoever is in front of it using a real car-following model, decides for itself whether changing lanes is worth the trouble, and has to be granted permission before it can cross a junction. When an avenue backs up, it's because a few hundred of those decisions ran into each other in the same place.

The design problem, which wasn't technical

The first version wasn't fun. Everything worked and it still felt bad to play, because the constraints I'd put on the player made every decision feel like a test I was failing. Taking the limits off and letting people build badly on purpose is what made it enjoyable. That was a useful thing to learn early: a simulation being correct and a simulation being worth playing are two separate problems.

An early prototype: two blue squares, an orange square, three straight road segments and a single car.
First versionBlue houses spawn cars, cars pathfind to orange stores. Straight segments, no lanes, no junction logic.
The current version: a full city grid at dusk with lane markings, roundabouts, zoning and traffic.
NowBézier lanes, IDM car-following, MOBIL lane changes, reservation-based junctions, and a city that grows along what you draw.

How a car gets from A to B

Roads are curves with a lookup table

Every road is a quadratic Bézier down its center with an arc-length table alongside it, so a car's position is one number that maps to a point in the world at a steady rate. Lanes are offsets of that same curve: shift the endpoints along their normals, re-derive the control point, and the lane geometry, the markings, the medians and the junction pads all come out of one definition. Curvature caps speed too, at v ≤ √(a_lat · R), so cars slow into tight bends without a line of code that says "slow into tight bends".

Routing runs over lanes, not roads

A* searches lanes and the connectors between them. This is the detail that makes turning behave. A driver who needs to be in the left lane three blocks from now has already paid for those lane changes in the cost of its route, so it starts moving over early instead of panicking at the intersection. The cost function charges per radian of turn, per lane of sideways movement, and in proportion to how jammed a lane currently is, which is why traffic spreads onto side streets when the avenue fills up.

Following the car ahead

Acceleration comes from the Intelligent Driver Model, using the gap and the closing speed to the leader. It's the standard model from traffic flow research, and it's the reason stop-and-go waves show up on a busy avenue when there's nothing in the code that draws a wave.

Deciding to change lanes

MOBIL handles this. A change gets accepted if the advantage to me, plus a politeness factor times the loss to whoever ends up behind me, clears a threshold, and only when the move wouldn't force that new follower to brake harder than a safety limit. There's a small pull toward the curb lane on top, so the network sorts itself out instead of everyone sitting in the median.

Getting through a junction

Each junction works out in advance which of its connectors physically cross each other. A car approaching the stop line asks to reserve the connector it wants, and the arbiter says yes only when nothing conflicting is booked. Traffic lights sit on top as an extra gate. Approaches get clustered into two opposing phases with a doubled-angle trick, which means a signal plan comes out of the junction's shape rather than out of me hand-authoring every intersection.

Three things that were harder than they looked

Roundabouts

A roundabout turned out to be a change to the network, not a change to how cars behave. Building one deletes the junction, drops in a ring of nodes, subdivides any span long enough to stop being a decent circular arc, rewires each arm onto its own ring node, and makes the ring edges one-way. After that the yielding and reservation code handles it, because as far as the simulation is concerned it's just more road. Keeping the vehicle logic simple and putting the cleverness in the graph is what stopped this project turning into a heap of special cases.

Editing a network with cars on it

You can upgrade a street to an avenue, split a road with a new junction, or bulldoze the road a car is driving along right now. Rebuilding an edge replaces its Lane objects, so every vehicle holding a reference has to be moved onto the new geometry, every affected node's connectors have to be rebuilt, and every building's curb attachment has to be resolved again. Getting that right, rather than leaking ghost cars and buildings attached to roads that no longer exist, was most of the work on this project.

Deadlock

Two failure modes turned up as soon as the network got dense. A car stuck at a stop line in the wrong lane would wait forever for a gap that could never appear, so it now re-plans from the lane it's actually sitting in. And a stalled reservation could hold a junction hostage indefinitely, so reservations expire. Both are places where the physically correct rule needed an escape hatch to stay playable.

The city on top

Zoning paints a coarse grid, and a cell only develops if it can reach a road within a certain distance band. That's why buildings line the streets you drew instead of sprouting in the middle of a block. A building's address is a point on a lane, stored as edge, direction, lane and position along it, so its residents leave from and return to a specific piece of curb. Houses generate trips to jobs and shops on a timer, industry sends out freight, buildings level up or get abandoned depending on how well they're actually being served, and the day/night cycle drives both the lighting and the rhythm of demand.

Map generation runs on a seeded mulberry32 generator, so a map I can reproduce is a bug I can reproduce.

An early bug I liked

Before junction handling existed, cars approaching an intersection would glide straight through it and off the end of the road, carrying on into open space. It looks absurd and it's a good illustration of what all the machinery above is actually for. A car following a curve is easy. A car deciding whether it's allowed to be in a particular piece of road at a particular moment is the entire problem.

It's easy to make a perfect simulation. It's hard to make a realistic one. Something the day job has made obvious. Real traffic is far more chaotic than anything a model produces, and reproducing that chaos honestly is harder than making it run cleanly.

Try this

Build a city using nothing but roundabouts. Throughput goes up and the network stops needing signals at all, right up until one arm gets busy enough that nobody can enter the ring. Then try the opposite: put signals on everything and watch the queues form in completely different places.

Performance

What I'd do next