22 cars on a circle, no bottleneck, no accident: watch a traffic jam appear by itself
In 2008, researchers in Japan asked 22 drivers to drive around a 230 m circle at the same steady speed (Sugiyama et al., New Journal of Physics, 2008). There was no bottleneck, no accident and no traffic light. A jam formed anyway. Here is the experiment, recreated with a simple model of how drivers follow each other.
NoteBefore you watch: predict
Will a jam form? If it does, which way will it move: with the traffic, or against it?
Each dot is a car, coloured by its speed. The car with the black ring is you.
// Ring road with the Intelligent Driver Model (IDM) and small random fluctuations in each driver's acceleration.// The adjustment and playback sliders are read live, so they can be changed without restarting.// ringState is shared with the time-space plot, which redraws on its own timer.ringState = { ringRestart; ringN;return {hist: [],L:230}; }
ring = {const N = ringN, L = ringState.L, v0 =12, T =1.2, s0 =2, b =1.5, lc =4, dt =0.1, noise =0.3;const x =Float64Array.from({length: N}, (_, i) => i * L / N);const v =newFloat64Array(N).fill(6);const acc =newFloat64Array(N);const hist = ringState.hist;const randn = () =>Math.sqrt(-2*Math.log(1-Math.random())) *Math.cos(2*Math.PI*Math.random());let t =0, frame =0, budget =0, nextRecord =0;while (true) { budget += viewof ringSpeed.value/60;// about 60 frames per secondconst a = viewof ringA.value;while (budget >= dt) {for (let i =0; i < N; i++) {const j = (i +1) % N;const gap =Math.max(((x[j] - x[i]) % L + L) % L - lc,0.1);const sStar = s0 +Math.max(0, v[i] * T + v[i] * (v[i] - v[j]) / (2*Math.sqrt(a * b))); acc[i] = a * (1- (v[i] / v0) **4- (sStar / gap) **2) + noise *randn(); }for (let i =0; i < N; i++) { v[i] =Math.max(0, v[i] + acc[i] * dt); x[i] = (x[i] + v[i] * dt) % L; } t += dt; budget -= dt;if (t >= nextRecord) {for (let i =0; i < N; i++) hist.push({t, i,x: x[i],v: v[i]}); nextRecord +=0.5;while (hist.length&& hist[0].t< t -150) hist.shift(); } }yield {t,x:Array.from(x),v:Array.from(v), hist,frame: frame++, L}; }}
{const W =300, R =115;const color = d3.scaleSequential(d3.interpolateRdYlGn).domain([0,8]);const cars = ring.x.map((xi, i) => {const th =2*Math.PI* xi / ring.L;return {cx: W /2+ R *Math.cos(th),cy: W /2+ R *Math.sin(th),fill:color(ring.v[i]),you: i ===0}; });return htl.svg`<svg width="100%" viewBox="0 0 ${W}${W}" style="max-width:${W}px;height:auto;display:block;margin:0 auto"> <circle cx=${W /2} cy=${W /2} r=${R} fill="none" stroke="#ddd" stroke-width="20"/>${cars.map(c => htl.svg`<circle cx=${c.cx} cy=${c.cy} r=${c.you?9:7} fill=${c.fill} stroke=${c.you? ink :"#333"} stroke-width=${c.you?3:0.6} />`)} <text x=${W /2} y=${W /2-6} text-anchor="middle" font-size="15" fill="currentColor">t = ${ring.t.toFixed(0)} s</text> <text x=${W /2} y=${W /2+14} text-anchor="middle" font-size="11" fill="gray">red = slow · green = fast</text> </svg>`;}
{const S = ringState;while (true) { // redraw twice per secondyield Plot.plot({width: plotW,height:!!document.querySelector(".reveal") ?260:300,x: {label:"Time (s)"},y: {label:"Position around the ring (m)",domain: [0, S.L]},color: {type:"sequential",scheme:"RdYlGn",domain: [0,8],legend:true,label:"Speed (m/s)"},marks: [ Plot.dot(S.hist.slice(), {x:"t",y:"x",fill:"v",r:1.3}), Plot.dot(S.hist.filter(d => d.i===0), {x:"t",y:"x",fill: ink,r:1.8}) ] });await Promises.delay(500); }}
Make it go away. Raise how briskly drivers adjust to 1.5 m/s². Each driver now corrects small wobbles before the car behind over-reacts to them, and the jam dissolves.
Fewest cars. At 0.5 m/s², what is the smallest number of cars that still jams? Why does a jam need enough cars?
Look at the diagram on the right. The red band slopes down: the jam moves backward while every car moves forward.
What the slider is: every driver follows the Intelligent Driver Model, and how briskly drivers adjust is its maximum acceleration. A small random wobble in every driver’s acceleration seeds the jam. Lecture 1 tells the whole story; Lectures 8 and 9 explain it.
theme = Generators.observe(notify => {const read = () =>notify(document.body.classList.contains("quarto-dark") ?"dark":"light");read();const watcher =newMutationObserver(read); watcher.observe(document.body, {attributes:true,attributeFilter: ["class"]});return () => watcher.disconnect();})accent = theme ==="dark"?"#c9a0ff":"#57068c"accentRange = theme ==="dark"? ["#4b3a5c","#ecdcff"] : ["#dccbee","#3a0463"]ink = theme ==="dark"?"#f2f2f2":"black"// Plot width: Plot's default 640 px, except on a phone, where a plot is drawn to fit its card// instead of being shrunk (and its text with it). Changes only when the width actually changes.plotW = Generators.observe(notify => {let last;const read = () => {const main =document.querySelector("main"), fit = main ? main.clientWidth-50:640;const w =!document.querySelector(".reveal") && fit <640?Math.max(240,Math.floor(fit)) :640;if (w !== last) notify(last = w); };read();window.addEventListener("resize", read);return () =>window.removeEventListener("resize", read);})