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.
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.
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
- The simulation steps at a fixed 60 Hz and the renderer interpolates between steps, so physics never depends on frame rate. The 2× and 4× buttons run more steps rather than making cars teleport further.
- Lane offsets, road markings, median lines and junction pads are built once per edit and reused, instead of being recomputed sixty times a second.
- Each lane keeps its own index of the vehicles on it, so finding a leader or a follower is local and car-following never scans the world.
- The city in the screenshot up there, 35 roads and 115 buildings, sits at 58 to 60 fps with everything switched on.
What I'd do next
- Buses on fixed routes. They'd be a good stress test for the reservation system, since they stop in the road and block things.
- Signal timing that adapts to measured queue length, with a view that compares it against the fixed plan.
- Move routing into a Web Worker. A* is the only thing here that can spike a frame.
- Save and load, so a city worth showing survives a refresh.