3
Manhattan Gridlock Cascade
tap an intersection to close it
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.