3

Manhattan Gridlock Cascade

tap an intersection to close it

A toy Manhattan: every row and column is a one-way street, alternating direction so the grid looks like a real avenue map. Cars enter at the upstream edge of each street, take occasional random turns at intersections, and leave on the far side. The movement rule is intentionally minimal — a car advances into the next cell iff that cell is empty — yet it reproduces the failure mode that defines dense urban traffic: a single stopped car can pin the intersection behind it, which pins the queue behind that, until a whole neighborhood freezes. Cars are colored by their current wait time, so the warm color literally radiates outward from the deadlock point in a visible cascade. The cascade is structural: the worst case (full gridlock) is a directed cycle where every car at an intersection is waiting on a car that is waiting on the next, and no local rule can resolve it. Tap any intersection to close it for a few seconds — the moving wavefront of red that backs up upstream is the same congestion shock you see on real arterials when a single signal fails. The HUD tracks cars on the grid, cars that have completed their route, and the longest single wait any car has accumulated.

idle
390 lines · vanilla
view source
// Manhattan-style gridlock cascade.
// A grid of one-way streets, alternating direction per row/col. Cars spawn at
// edges, take random turns at intersections, exit on the far side. A car can
// advance only if the cell ahead is empty — so converging flows can deadlock,
// and a single stuck car cascades upstream into full gridlock.
//
// Click an intersection square to "close" it for a few seconds (simulated
// accident / signal failure). Watch the warm color flood backwards.

const COLS = 10;          // intersections across
const ROWS = 6;           // intersections down
const CELLS_PER_BLOCK = 6; // cells along each street segment between intersections
const SPAWN_INTERVAL = 0.18; // seconds between spawn attempts per edge
const MAX_CARS = 320;
const CLOSE_DURATION = 4.5; // seconds a clicked intersection stays closed
const STUCK_WARM_SECS = 4.0; // wait time at which car is fully "warm"

// Streets:
//   Horizontal streets are one per row of intersections. Row r street runs
//   left->right if r%2===0, else right->left.
//   Vertical streets are one per col. Col c street runs top->bottom if
//   c%2===0, else bottom->top.
// Cars travel along a sequence of cells. Between intersection (r,c) and
// the next intersection along that street, there is a block of CELLS_PER_BLOCK
// cells. The intersection itself is a separate cell.

// Cell identity scheme (kept as small ints in car.path):
//   We don't precompute the global cell graph; cars carry their next-cell
//   coords on demand. Cell occupancy is stored in maps keyed by string ids.

let W, H;
let cellPx;        // pixel size of one street cell
let originX, originY;
let interSize;     // intersection square side (a bit bigger than cellPx)
let gridW, gridH;  // pixel dims of the whole grid

let cars;
let nextCarId;
let spawnTimers;   // [edgeIndex] -> seconds until next spawn attempt
let intersections; // 2D array of intersection objects {closedUntil}
let cellOccupancy; // Map<cellKey, carId>
let completed;
let longestWait;
let simTime;

// --- geometry helpers --------------------------------------------------------

// Each row r has a horizontal street at y position. Each col c has a vertical
// street at x position. An intersection sits at (col c, row r) and is the
// shared cell where the two streets cross.

function intersectionPx(c, r) {
  return {
    x: originX + c * (CELLS_PER_BLOCK * cellPx + interSize) + interSize * 0.5,
    y: originY + r * (CELLS_PER_BLOCK * cellPx + interSize) + interSize * 0.5,
  };
}

// Horizontal street row r flows: dir = (r%2===0) ? +1 : -1 (cell index sign).
// Vertical street col c flows: dir = (c%2===0) ? +1 : -1.

function hDir(r) { return r % 2 === 0 ? +1 : -1; }
function vDir(c) { return c % 2 === 0 ? +1 : -1; }

// A "cell" address is one of:
//   { kind: 'h', r, c, i }  — horizontal block on row r, between intersection
//                              cols c and c+1, cell i in [0..CELLS_PER_BLOCK-1].
//                              i increases in flow direction.
//   { kind: 'v', c, r, i }  — vertical block on col c between rows r and r+1.
//   { kind: 'x', c, r }     — intersection at (c, r).

function cellKey(a) {
  if (a.kind === 'x') return `x:${a.c},${a.r}`;
  if (a.kind === 'h') return `h:${a.r},${a.c},${a.i}`;
  return `v:${a.c},${a.r},${a.i}`;
}

// Pixel position of a cell.
function cellPx2(a) {
  if (a.kind === 'x') {
    return intersectionPx(a.c, a.r);
  }
  if (a.kind === 'h') {
    // block between intersection col c (left) and col c+1 (right).
    const left = intersectionPx(a.c, a.r);
    const right = intersectionPx(a.c + 1, a.r);
    // i runs in flow direction. If dir=+1 (left->right), cell 0 is just right
    // of the left intersection; if dir=-1, cell 0 is just left of the right
    // intersection (so still progress along the flow).
    const d = hDir(a.r);
    let t; // fraction from left intersection toward right
    if (d === +1) {
      t = (a.i + 0.5) / CELLS_PER_BLOCK;
      return { x: left.x + (right.x - left.x) * t, y: left.y };
    } else {
      t = (a.i + 0.5) / CELLS_PER_BLOCK;
      return { x: right.x - (right.x - left.x) * t, y: left.y };
    }
  }
  // vertical
  const top = intersectionPx(a.c, a.r);
  const bot = intersectionPx(a.c, a.r + 1);
  const d = vDir(a.c);
  const t = (a.i + 0.5) / CELLS_PER_BLOCK;
  if (d === +1) return { x: top.x, y: top.y + (bot.y - top.y) * t };
  return { x: top.x, y: bot.y - (bot.y - top.y) * t };
}

// Compute the next cell after `a` given the car's current decision context.
// Cars on a horizontal block continue along the block until reaching its end,
// then enter the intersection. At an intersection, the car decides to either
// continue straight or turn onto the cross street (if that street's direction
// allows entry from this intersection — it always does, since one-way streets
// only emit and only receive in one direction; from intersection (c,r), the
// horizontal street is leaving in hDir direction, and the vertical street is
// leaving in vDir direction. The car came in along one of those; it can either
// keep its current axis or switch.
//
// Returns either {cell, exits:true} if the next move would exit the grid, or
// {cell:nextCellObj}.

function nextCellFromBlock(a) {
  // a.kind === 'h' or 'v'
  if (a.kind === 'h') {
    if (a.i + 1 < CELLS_PER_BLOCK) {
      return { cell: { kind: 'h', r: a.r, c: a.c, i: a.i + 1 } };
    }
    // entering the intersection at the downstream end.
    const d = hDir(a.r);
    const interC = d === +1 ? a.c + 1 : a.c;
    return { cell: { kind: 'x', c: interC, r: a.r } };
  }
  if (a.i + 1 < CELLS_PER_BLOCK) {
    return { cell: { kind: 'v', c: a.c, r: a.r, i: a.i + 1 } };
  }
  const d = vDir(a.c);
  const interR = d === +1 ? a.r + 1 : a.r;
  return { cell: { kind: 'x', c: a.c, r: interR } };
}

// At an intersection, choose where to go. The car's `lastAxis` is 'h' or 'v'
// (the axis it arrived on). It can either continue along the same axis on the
// outgoing street, or switch to the other axis.
//
// Returns { cell, exits } where exits=true if the chosen direction goes off
// the grid.

function nextCellFromIntersection(inter, lastAxis, turnBias) {
  // turnBias in [0,1): probability of switching axes.
  const c = inter.c, r = inter.r;
  const goStraight = Math.random() > turnBias;
  let axis = goStraight ? lastAxis : (lastAxis === 'h' ? 'v' : 'h');

  if (axis === 'h') {
    const d = hDir(r);
    // outgoing block starts at intersection (c,r) and runs to (c+d, r).
    const newC = d === +1 ? c : c - 1; // block's left-intersection index
    // For d=+1, block goes (c,r) -> (c+1,r); represent as 'h' with c=newC (= c).
    // For d=-1, block goes (c,r) -> (c-1,r); represent as 'h' with c=newC (= c-1).
    if (newC < 0 || newC >= COLS - 1) {
      return { cell: null, exits: true };
    }
    return { cell: { kind: 'h', r, c: newC, i: 0 }, axis: 'h' };
  } else {
    const d = vDir(c);
    const newR = d === +1 ? r : r - 1;
    if (newR < 0 || newR >= ROWS - 1) {
      return { cell: null, exits: true };
    }
    return { cell: { kind: 'v', c, r: newR, i: 0 }, axis: 'v' };
  }
}

// --- spawn points ------------------------------------------------------------

// Entry points are at edges, on the upstream end of every street whose flow
// enters the grid:
//   For each row r: if hDir(r)===+1, entry is at column 0; else column COLS-1.
//   For each col c: if vDir(c)===+1, entry is at row 0; else row ROWS-1.
// Cars spawn into the first cell of that street (i=0 of the block leaving the
// entry intersection).

function spawnPointForEdge(edgeIdx) {
  // First ROWS edges are horizontal (one per row), next COLS are vertical.
  if (edgeIdx < ROWS) {
    const r = edgeIdx;
    const d = hDir(r);
    const c = d === +1 ? 0 : COLS - 2; // block index
    return {
      cell: { kind: 'h', r, c, i: 0 },
      axis: 'h',
    };
  }
  const c = edgeIdx - ROWS;
  const d = vDir(c);
  const r = d === +1 ? 0 : ROWS - 2;
  return {
    cell: { kind: 'v', c, r, i: 0 },
    axis: 'v',
  };
}

// --- car logic ---------------------------------------------------------------

function makeCar(spawn) {
  return {
    id: nextCarId++,
    cell: spawn.cell,
    axis: spawn.axis,
    wait: 0,         // seconds spent waiting (current bout, decays when moving)
    maxWait: 0,      // max wait ever recorded
    moveCooldown: 0, // seconds until ready to attempt next move
    turnBias: 0.18 + Math.random() * 0.25, // per-car random turn affinity
    hue: 200 + Math.random() * 40, // base hue for "moving" color (cool)
  };
}

function tryMove(car, dt) {
  car.moveCooldown -= dt;
  if (car.moveCooldown > 0) {
    // Still in transit through current cell.
    return;
  }
  // Decide the next cell.
  let next;
  if (car.cell.kind === 'x') {
    next = nextCellFromIntersection(car.cell, car.axis, car.turnBias);
  } else {
    next = nextCellFromBlock(car.cell);
  }

  if (next.exits) {
    // Car leaves the grid. Free its cell.
    cellOccupancy.delete(cellKey(car.cell));
    car.done = true;
    completed++;
    if (car.maxWait > longestWait) longestWait = car.maxWait;
    return;
  }

  // Special: if next cell is an intersection that is currently closed, treat
  // as blocked.
  if (next.cell.kind === 'x') {
    const inter = intersections[next.cell.r][next.cell.c];
    if (inter.closedUntil > simTime) {
      car.wait += dt;
      if (car.wait > car.maxWait) car.maxWait = car.wait;
      return;
    }
  }

  const nk = cellKey(next.cell);
  if (cellOccupancy.has(nk)) {
    // Blocked: accumulate wait time.
    car.wait += dt;
    if (car.wait > car.maxWait) car.maxWait = car.wait;
    return;
  }

  // Move.
  cellOccupancy.delete(cellKey(car.cell));
  car.cell = next.cell;
  if (next.axis) car.axis = next.axis;
  cellOccupancy.set(nk, car.id);
  // Movement cooldown is one cell's worth of travel time.
  car.moveCooldown = 0.32 + Math.random() * 0.05;
  // Decay wait on a successful move.
  car.wait = Math.max(0, car.wait - 0.6);
}

// --- init / tick -------------------------------------------------------------

function computeLayout(width, height) {
  W = width;
  H = height;
  // Reserve a HUD strip at the top.
  const padTop = 44;
  const padX = 18;
  const padBottom = 18;
  const availW = W - padX * 2;
  const availH = H - padTop - padBottom;
  // Total grid spans: COLS-1 blocks + COLS intersections horizontally? No:
  // We have COLS intersection columns, and between them COLS-1 horizontal
  // blocks. Each block = CELLS_PER_BLOCK * cellPx. Each intersection = interSize.
  // gridW = COLS * interSize + (COLS - 1) * CELLS_PER_BLOCK * cellPx
  // Pick interSize ~ cellPx * 1.6.
  // Solve for cellPx given availW and availH:
  //   gridW(cellPx) = COLS * 1.6 * cellPx + (COLS-1) * CELLS_PER_BLOCK * cellPx
  //                = cellPx * (COLS * 1.6 + (COLS-1) * CELLS_PER_BLOCK)
  //   gridH(cellPx) = cellPx * (ROWS * 1.6 + (ROWS-1) * CELLS_PER_BLOCK)
  const wCoef = COLS * 1.6 + (COLS - 1) * CELLS_PER_BLOCK;
  const hCoef = ROWS * 1.6 + (ROWS - 1) * CELLS_PER_BLOCK;
  cellPx = Math.max(4, Math.min(availW / wCoef, availH / hCoef));
  interSize = cellPx * 1.6;
  gridW = cellPx * wCoef;
  gridH = cellPx * hCoef;
  originX = (W - gridW) * 0.5;
  originY = padTop + (availH - gridH) * 0.5;
}

function init({ canvas, ctx, width, height }) {
  computeLayout(width, height);

  cars = [];
  nextCarId = 1;
  spawnTimers = new Array(ROWS + COLS).fill(0).map(() => Math.random() * SPAWN_INTERVAL);
  intersections = [];
  for (let r = 0; r < ROWS; r++) {
    const row = [];
    for (let c = 0; c < COLS; c++) row.push({ closedUntil: -1 });
    intersections.push(row);
  }
  cellOccupancy = new Map();
  completed = 0;
  longestWait = 0;
  simTime = 0;

  // Paint background once so the first few frames look intentional.
  ctx.fillStyle = '#0a0d14';
  ctx.fillRect(0, 0, W, H);
}

function handleClicks(input) {
  const clicks = input.consumeClicks ? input.consumeClicks() : [];
  for (const click of clicks) {
    const cx = click.x;
    const cy = click.y;
    // Find nearest intersection within interSize/2 + a small slop.
    let best = null;
    let bestD2 = Infinity;
    for (let r = 0; r < ROWS; r++) {
      for (let c = 0; c < COLS; c++) {
        const p = intersectionPx(c, r);
        const dx = cx - p.x, dy = cy - p.y;
        const d2 = dx * dx + dy * dy;
        if (d2 < bestD2) { bestD2 = d2; best = { c, r }; }
      }
    }
    if (best && bestD2 < (interSize * 0.9) * (interSize * 0.9)) {
      intersections[best.r][best.c].closedUntil = simTime + CLOSE_DURATION;
    }
  }
}

function spawn(dt) {
  for (let e = 0; e < spawnTimers.length; e++) {
    spawnTimers[e] -= dt;
    if (spawnTimers[e] > 0) continue;
    spawnTimers[e] = SPAWN_INTERVAL * (0.7 + Math.random() * 0.9);
    if (cars.length >= MAX_CARS) continue;
    const sp = spawnPointForEdge(e);
    const k = cellKey(sp.cell);
    if (cellOccupancy.has(k)) continue; // entry blocked — try again next bucket
    const car = makeCar(sp);
    cars.push(car);
    cellOccupancy.set(k, car.id);
  }
}

function drawStreets(ctx) {
  // Streets as faint stripes.
  ctx.strokeStyle = '#1c2230';
  ctx.lineWidth = Math.max(1, cellPx * 0.95);
  ctx.lineCap = 'butt';

  // Horizontal streets, each row.
  for (let r = 0; r < ROWS; r++) {
    const left = intersectionPx(0, r);
    const right = intersectionPx(COLS - 1, r);
    ctx.beginPath();
    ctx.moveTo(left.x, left.y);
    ctx.lineTo(right.x, right.y);
    ctx.stroke();
  }
  for (let c = 0; c < COLS; c++) {
    const top = intersectionPx(c, 0);
    const bot = intersectionPx(c, ROWS - 1);
    ctx.beginPath();
    ctx.moveTo(top.x, top.y);
    ctx.lineTo(bot.x, bot.y);
    ctx.stroke();
  }

  // Direction arrows on each street (a faint triangle near the midpoint).
  ctx.fillStyle = '#2a3346';
  const arrowSize = Math.max(3, cellPx * 0.7);
  for (let r = 0; r < ROWS; r++) {
    const left = intersectionPx(0, r);
    const right = intersectionPx(COLS - 1, r);
    const mx = (left.x + right.x) * 0.5;
    const my = left.y;
    const d = hDir(r);
    ctx.beginPath();
    if (d === +1) {
      ctx.moveTo(mx + arrowSize, my);
      ctx.lineTo(mx - arrowSize * 0.5, my - arrowSize * 0.5);
      ctx.lineTo(mx - arrowSize * 0.5, my + arrowSize * 0.5);
    } else {
      ctx.moveTo(mx - arrowSize, my);
      ctx.lineTo(mx + arrowSize * 0.5, my - arrowSize * 0.5);
      ctx.lineTo(mx + arrowSize * 0.5, my + arrowSize * 0.5);
    }
    ctx.closePath();
    ctx.fill();
  }
  for (let c = 0; c < COLS; c++) {
    const top = intersectionPx(c, 0);
    const bot = intersectionPx(c, ROWS - 1);
    const mx = top.x;
    const my = (top.y + bot.y) * 0.5;
    const d = vDir(c);
    ctx.beginPath();
    if (d === +1) {
      ctx.moveTo(mx, my + arrowSize);
      ctx.lineTo(mx - arrowSize * 0.5, my - arrowSize * 0.5);
      ctx.lineTo(mx + arrowSize * 0.5, my - arrowSize * 0.5);
    } else {
      ctx.moveTo(mx, my - arrowSize);
      ctx.lineTo(mx - arrowSize * 0.5, my + arrowSize * 0.5);
      ctx.lineTo(mx + arrowSize * 0.5, my + arrowSize * 0.5);
    }
    ctx.closePath();
    ctx.fill();
  }
}

function drawIntersections(ctx) {
  const s = interSize;
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const p = intersectionPx(c, r);
      const closed = intersections[r][c].closedUntil > simTime;
      if (closed) {
        const remain = intersections[r][c].closedUntil - simTime;
        const pulse = 0.5 + 0.5 * Math.sin(simTime * 8);
        ctx.fillStyle = `hsla(0, 85%, ${(45 + pulse * 15).toFixed(1)}%, 0.95)`;
        ctx.fillRect(p.x - s * 0.55, p.y - s * 0.55, s * 1.1, s * 1.1);
        // little "X" mark.
        ctx.strokeStyle = '#100';
        ctx.lineWidth = Math.max(1, s * 0.12);
        ctx.beginPath();
        ctx.moveTo(p.x - s * 0.3, p.y - s * 0.3);
        ctx.lineTo(p.x + s * 0.3, p.y + s * 0.3);
        ctx.moveTo(p.x + s * 0.3, p.y - s * 0.3);
        ctx.lineTo(p.x - s * 0.3, p.y + s * 0.3);
        ctx.stroke();
        // remaining countdown ring
        ctx.strokeStyle = 'rgba(255,255,255,0.4)';
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(p.x, p.y, s * 0.65, -Math.PI * 0.5,
                -Math.PI * 0.5 + (Math.PI * 2) * (remain / CLOSE_DURATION));
        ctx.stroke();
      } else {
        ctx.fillStyle = '#2b3448';
        ctx.fillRect(p.x - s * 0.5, p.y - s * 0.5, s, s);
      }
    }
  }
}

function carColor(wait) {
  // Cool blue when wait=0, warm red/orange when wait >= STUCK_WARM_SECS.
  const t = Math.min(1, wait / STUCK_WARM_SECS);
  // Interpolate hue from 210 (cyan/blue) -> 18 (warm orange-red) via 60 (yellow).
  // Use simple linear from 210 -> 18 going the short way (through 60 at t~0.7).
  // We just lerp on a 1D scale and shift saturation/lightness.
  const hue = 210 - t * 192; // 210 to 18
  const sat = 75 + t * 20;
  const light = 55 - t * 5;
  return `hsl(${hue.toFixed(0)}, ${sat.toFixed(0)}%, ${light.toFixed(0)}%)`;
}

function drawCars(ctx) {
  const radius = Math.max(2, cellPx * 0.32);
  for (const car of cars) {
    if (car.done) continue;
    const p = cellPx2(car.cell);
    ctx.fillStyle = carColor(car.wait);
    ctx.beginPath();
    ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
    ctx.fill();
    if (car.wait > 1.5) {
      // Faint outer halo for very-stuck cars.
      ctx.strokeStyle = `hsla(10, 90%, 55%, ${Math.min(0.7, (car.wait - 1.5) * 0.25)})`;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.arc(p.x, p.y, radius + 1.5, 0, Math.PI * 2);
      ctx.stroke();
    }
  }
}

function drawHUD(ctx) {
  ctx.fillStyle = '#0a0d14';
  ctx.fillRect(0, 0, W, 36);
  ctx.fillStyle = '#cfd6e4';
  ctx.font = '13px ui-sans-serif, system-ui, sans-serif';
  ctx.textBaseline = 'middle';
  const onGrid = cars.length;
  const txt = `cars on grid: ${onGrid}   completed: ${completed}   longest wait: ${longestWait.toFixed(1)}s`;
  ctx.fillText(txt, 14, 18);
  ctx.fillStyle = '#7a8499';
  ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
  ctx.textAlign = 'right';
  ctx.fillText('tap an intersection to close it', W - 14, 18);
  ctx.textAlign = 'left';
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) computeLayout(width, height);

  // Clamp dt to avoid huge jumps on tab refocus.
  const stepDt = Math.min(0.1, dt);
  simTime += stepDt;

  handleClicks(input);
  spawn(stepDt);

  // Move cars in a randomized order so no row gets a perpetual priority and
  // the deadlock structure is symmetric.
  // Quick Fisher-Yates shuffle of indices.
  const order = new Array(cars.length);
  for (let i = 0; i < cars.length; i++) order[i] = i;
  for (let i = order.length - 1; i > 0; i--) {
    const j = (Math.random() * (i + 1)) | 0;
    const tmp = order[i]; order[i] = order[j]; order[j] = tmp;
  }
  for (const idx of order) {
    const car = cars[idx];
    if (!car.done) tryMove(car, stepDt);
  }
  // Compact out finished cars occasionally.
  if (cars.length > 0 && (Math.random() < 0.05 || cars.some((c) => c.done))) {
    cars = cars.filter((c) => !c.done);
  }

  // Draw.
  ctx.fillStyle = '#0a0d14';
  ctx.fillRect(0, 0, W, H);

  drawStreets(ctx);
  drawIntersections(ctx);
  drawCars(ctx);
  drawHUD(ctx);
}

Comments (0)

Log in to comment.