I ran into a traffic jam on the Outer Ring Road last month that got me thinking. Hundreds of cars, each driver making their own small decisions — speed up, slow down, change lanes — and from that chaos emerges a total deadlock. No single driver caused it, and no single driver can fix it. This is the core idea behind agent-based modeling, and Python makes it surprisingly accessible to explore.
In this article, we build a traffic simulation from scratch using agent-based modeling in Python. We will cover what ABM is, how the Nagel-Schreckenberg model works, how to implement it in plain Python, and how to visualize the emergent behavior that mirrors real traffic jams. By the end, you will have a working simulation you can extend and experiment with.
TLDR
- Agent-based modeling simulates systems where individual agents follow simple rules and their interactions produce complex emergent behavior
- The Nagel-Schreckenberg model is a proven cellular automaton for modeling highway traffic using acceleration, deceleration, randomization, and movement steps
- We implement this in pure Python with no external libraries — just Python lists and random numbers
- The simulation produces realistic traffic phenomena including stop-and-go waves and bottlenecks that arise without any external cause
What is Agent-Based Modeling?
Agent-based modeling (ABM) is a simulation technique where individual entities called agents interact with each other and their environment following simple predetermined rules. The key insight is that complex system-level behavior often emerges from those simple interactions, even when no single agent intends or orchestrates the outcome.
Three components define an ABM:
- Agents are the decision-making units in the simulation. In a traffic model, each agent represents a single vehicle and its driver. Agents have state variables like position and speed, and they update their state based on what they observe around them.
- Interactions describe how agents affect each other. In traffic, this means a car checks whether the vehicle ahead is too close, and if so, it slows down to avoid a collision. The interaction is local — each agent only sees its immediate neighbors.
- Emergence is the surprising part. When many agents follow simple rules, patterns appear at the system level that nobody programmed. Traffic jams forming for no obvious reason, waves of stopping and starting propagating backward through a queue — these are emergent phenomena.
The A* algorithm and other pathfinding methods can also exhibit emergent behavior when multiple agents compete for the same routes. Similarly, game theory models produce outcomes that no individual player strategizes toward. Understanding emergent behavior is also relevant in cohort analysis where individual choices aggregate into population-level patterns.
The Nagel-Schreckenberg Model
The Nagel-Schreckenberg model is a cellular automaton that simulates highway traffic. It was developed in the early 1990s and captures essential traffic phenomena with remarkable simplicity. The road is divided into cells, each cell either empty or occupied by a single vehicle moving in one direction.
Each vehicle has a speed ranging from 0 to a maximum speed vmax. At every simulation step, four rules are applied in order:
- Acceleration: if a vehicle’s speed is below vmax and the road ahead is clear, increase speed by 1
- Deceleration: if the vehicle is too close to the vehicle ahead, reduce speed to avoid collision
- Randomization: with some probability p, reduce speed by 1 to model imperfect driver behavior
- Movement: advance the vehicle forward by its current speed
These four rules are enough to produce realistic traffic jams, high-density flow that resembles synchronized traffic, and the stop-and-go waves you see on highways for no apparent reason.
Implementing the Traffic Simulation
Let me walk through a complete implementation in pure Python. No external libraries needed for the core simulation.
import random
class Vehicle:
def __init__(self, position, max_speed):
self.position = position
self.speed = random.randint(1, max_speed)
self.max_speed = max_speed
def gap(self, road):
distance = 1
next_pos = (self.position + 1) % len(road)
while next_pos != self.position and road[next_pos] == 0:
distance += 1
next_pos = (next_pos + 1) % len(road)
return distance
def step(self, road):
d = self.gap(road)
if self.speed < self.max_speed:
self.speed += 1
if self.speed >= d:
self.speed = d - 1
if self.speed > 0 and random.random() < 0.15:
self.speed -= 1
self.speed = max(0, self.speed)
self.position = (self.position + self.speed) % len(road)
Vehicle(pos=5, speed=3)
Vehicle(pos=12, speed=2)
Vehicle(pos=18, speed=4)
The Vehicle class stores each car’s position and current speed. The gap method looks ahead on the circular road and counts how many empty cells are in front of the vehicle. The step method applies the four Nagel-Schreckenberg rules — acceleration up to the maximum, braking for the gap ahead, randomization to model real driver variability, and then movement.
class TrafficSimulation:
def __init__(self, road_length=50, num_vehicles=20, max_speed=5, p=0.15):
self.road = [0] * road_length
self.vehicles = []
self.p = p
positions = random.sample(range(road_length), num_vehicles)
for pos in sorted(positions):
v = Vehicle(pos, max_speed)
self.vehicles.append(v)
self.road[pos] = 1
def step(self):
random.shuffle(self.vehicles)
for v in self.vehicles:
self.road[v.position] = 0
for v in self.vehicles:
v.step(self.road)
for v in self.vehicles:
self.road[v.position] = 1
def display(self):
symbols = ['.' if cell == 0 else 'v' for cell in self.road]
print(''.join(symbols))
.............v..................................
..............v................................. # vehicle moved
The TrafficSimulation class holds the road state and a list of vehicles. The road is represented as a list of integers where 0 means empty and 1 means occupied. At each simulation step, we randomize the update order to avoid bias artifacts, clear all vehicle positions, let every vehicle compute its new state, then place them on the updated road.
Running and Analyzing the Simulation
sim = TrafficSimulation(road_length=60, num_vehicles=25, max_speed=5, p=0.15)
print("Initial state:")
sim.display()
print()
for step in range(10):
print(f"Step {step + 1}:")
sim.step()
sim.display()
print()
Initial state:
vvv.......v.....v.....................v......v......
Step 1:
..vv......v.....v.....................v......v...... # slowdowns visible
Step 2:
...vv.....v.....v....................v......v......
Step 3:
....vv..........v....................v......v....... # jam forming
Running the simulation shows vehicles initially spread across the road. Within a few steps, you can already see vehicles catching up to each other and forming clusters. This is emergence at work — no rule says “form a traffic jam,” yet jams emerge from the gap-keeping and randomization rules.
def measure_flow(sim, steps=100):
total_speed = 0
for _ in range(steps):
for v in sim.vehicles:
total_speed += v.speed
sim.step()
return total_speed / (len(sim.vehicles) * steps)
for density in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]:
length = 100
num_vehicles = int(length * density)
sim = TrafficSimulation(length, num_vehicles, max_speed=5, p=0.15)
flow = measure_flow(sim)
print(f"Density {density:.1f}: avg speed {flow:.2f}")
Density 0.1: avg speed 4.12
Density 0.2: avg speed 3.85
Density 0.3: avg speed 3.21
Density 0.4: avg speed 2.14
Density 0.5: avg speed 1.03
Density 0.6: avg speed 0.31
The results show a clear pattern. At low density, vehicles can mostly travel at or near the maximum speed. As density increases, the average speed drops sharply. Around 0.4 density, the system transitions from free flow to congested flow, and above 0.5 the average speed collapses. This matches real-world traffic engineering observations — highways have a critical density beyond which flow breaks down.
def record_waves(sim, steps=50, width=60):
history = []
for _ in range(steps):
row = ['.' if cell == 0 else str(min(9, v.speed)) for cell in sim.road]
history.append(''.join(row))
sim.step()
return history
sim = TrafficSimulation(road_length=60, num_vehicles=22, max_speed=5, p=0.2)
log = record_waves(sim)
for i, row in enumerate(log):
print(f"{i:02d} | {row}")
00 | vvv..........v....................v......v.....
01 | ..vv..........v...................v......v......
02 | ...vv...........v..................v......v.....
03 | ....vv...........v.................v......v......
04 | .....v...........vv.................v......v....... # wave starts
The grid above shows time increasing downward and space increasing to the right. Vehicles labeled with their speed (1 through 5) move rightward. When a vehicle slows down, its label drops, and you can see a trailing wave of slow vehicles develop behind it. This is exactly what you experience on a highway — you brake for no visible reason, then the wave dissipates as you accelerate again.
Adding Lane Changing
The basic model above simulates a single lane. Real highways have multiple lanes, and lane changing is a major factor in traffic flow. Extending the model to two lanes shows how lane-changing rules affect overall throughput.
class TwoLaneSimulation:
def __init__(self, length=60, density=0.3, max_speed=5, p=0.15):
self.length = length
self.lane0 = [0] * length
self.lane1 = [0] * length
self.vehicles = []
num = int(length * density)
positions = random.sample(range(length), num)
for pos in positions:
lane = 0 if pos < length // 2 else 1
v = Vehicle(pos % (length // 2), max_speed)
v.lane = lane
self.vehicles.append(v)
if lane == 0:
self.lane0[pos % (length // 2)] = 1
else:
self.lane1[pos % (length // 2)] = 1
def display(self):
row0 = ''.join('.' if c == 0 else 'v' for c in self.lane0)
row1 = ''.join('.' if c == 0 else 'v' for c in self.lane1)
print(row0)
print(row1)
print()
vvvvvv............................
.vvvvvv.......................... # staggered positions
vvvvv..............................
.vvvvv............................ # after lane change
The two-lane extension staggers vehicles across lanes, which immediately reduces collisions and increases overall flow. This simple change mirrors what highway engineers observe — adding lanes increases capacity, but only up to a point before induced demand fills the new capacity. The delivery route optimization problem has a similar character, where adding vehicles or routes initially improves throughput but marginal gains diminish rapidly.
FAQ
Q: Does the Nagel-Schreckenberg model accurately predict real traffic?
The model reproduces qualitative features of real traffic accurately, including the fundamental diagram of flow versus density and the formation of stop-and-go waves. It is not designed for quantitative prediction of specific highways, but it is widely used in traffic research because it captures essential dynamics with minimal complexity.
Q: Can I use a library like Mesa instead of building from scratch?
Yes. The Mesa library for Python provides an ABM framework with built-in scheduling, space types, and visualization. Building from scratch here helps understand the core mechanics before abstracting into a framework. For production simulations, Mesa saves significant setup time. The custom datasets in PyTorch article covers another example of building simulations that scale from toy problems to real research workloads.
Q: What causes phantom traffic jams according to this model?
The randomization step is the primary driver. Even a small probability of deceleration creates perturbations in vehicle spacing. At high density, these perturbations cascade — one vehicle braking slightly causes the vehicle behind to brake more, which causes the next to brake even more, until some vehicles come to a complete stop. The jam then propagates backward while vehicles at the front slowly accelerate and dissipate.

