OverviewCode › R78
Code · blind round

R78 · Crowd leaving a room by steering

blind round 3/8 local models answered

Answers below are anonymized and shuffled. Read, judge, pick a favourite — then scroll down and reveal the models.

2 of the 8 local models delivered nothing through our test harness in this round — for reasons that lie with our setup, not with the models. Those cards are marked DNF like any other, but they are not evidence about those models. Which ones, and why, is spelled out in the reveal.

The reveal also holds a frontier reference on the same task (Claude Opus 5) — kept out of sight here so it cannot steer your judgement.

Show task
Create ONE self-contained HTML file (STRICT LIMIT: max 120 lines total) that simulates 60 people leaving a room through one exit as CONTINUOUS MOVEMENT on a <canvas>. Each person has a floating-point position and velocity, steers towards the exit, and pushes away from neighbours who come too close, so congestion and an arch form at the door. No grid, no cells — continuous coordinates only. Show how many have left and the average speed. Smooth requestAnimationFrame animation, NO user input, starts immediately and keeps running (restart with a fresh random instance when finished). No external libraries, no comments needed. Output ONLY the HTML file content, nothing else.

The answers — blind

Local models (Brain cluster) — identical prompt, anonymized order
MODEL Aqwen3:14bOKLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<title>Crowd Simulation</title>
<style>body {margin:0;overflow:hidden;}canvas {display:block;}</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let width, height;

function resize() {
    width = window.innerWidth;
    height = window.innerHeight;
    canvas.width = width;
    canvas.height = height;
}
window.addEventListener('resize', resize);
resize();

class Agent {
    constructor(x, y) {
        this.pos = { x: Math.random() * width, y: 0 };
        this.vel = { x: (Math.random() - 0.5) * 0.5, y: 0 };
        this.maxSpeed = 1.2;
        this.separationDistanceSq = 400;
    }

    applyForce(force) {
        this.vel.x += force.x * 0.1;
        this.vel.y += force.y * 0.1;
    }

    separate(agents) {
        let totalSeparation = { x: 0, y: 0 };
        let count = 0;
        for (const other of agents) {
            if (other === this) continue;
            let dx = this.pos.x - other.pos.x;
            let dy = this.pos.y - other.pos.y;
            let distSq = dx * dx + dy * dy;

            if (distSq < this.separationDistanceSq && distSq > 1) {
                let force = { x: dx / Math.sqrt(distSq), y: dy / Math.sqrt(distSq) };
                force.x /= Math.sqrt(1 / distSq);
                force.y /= Math.sqrt(1 / distSq);
                totalSeparation.x += force.x;
                totalSeparation.y += force.y;
                count++;
            }
        }

        if (count > 0) {
            let sepForce = { x: totalSeparation.x / count, y: totalSeparation.y / count };
            this.applyForce(sepForce);
        }
    }

    seek(targetX, targetY) {
        const desired = { x: targetX - this.pos.x, y: targetY - this.pos.y };
        const dist = Math.sqrt(desired.x * desired.x + desired.y * desired.y);
        let force = { 
            x: (desired.x / dist) * this.maxSpeed, 
            y: (desired.y / dist) * this.maxSpeed 
        };
        this.applyForce(force);
    }

    update() {
        this.pos.x += this.vel.x;
        this.pos.y += this.vel.y;

        // Friction/Damping
        this.vel.x *= 0.95;
        this.vel.y *= 0.95;

        // Boundary checks (keep agents within room)
        if (this.pos.x < 0 || this.pos.x > width) { this.vel.x *= -0.1; }
        if (this.pos.y < 0 || this.pos.y > height) { this.vel.y *= -0.1; }

        // Normalization/Speed Limit
        let speed = Math.sqrt(this.vel.x * this.vel.x + this.vel.y * this.vel.y);
        if (speed > this.maxSpeed) {
            this.vel.x = (this.vel.x / speed) * this.maxSpeed;
            this.vel.y = (this.vel.y / speed) * this.maxSpeed;
        }

        // Check if agent has exited (exit is at width - 50, center height)
        return (Math.abs(this.pos.x - (width - 100)) < 20 && Math.random() > 0.99); 
    }

    draw() {
        ctx.fillStyle = 'rgba(30, 144, 255, 0.8)';
        ctx.beginPath();
        ctx.arc(this.pos.x, this.pos.y, 5, 0, Math.PI * 2);
        ctx.fill();
    }
}

const agents = [];
const totalAgents = 60;
let exitedCount = 0;
let totalDistanceTraveled = 0;

function initialize() {
    agents.length = 0;
    exitedCount = 0;
    totalDistanceTraveled = 0;
    for (let i = 0; i < totalAgents; i++) {
        agents.push(new Agent(Math.random() * width, 0));
    }
}

function loop() {
    requestAnimationFrame(loop);
    ctx.clearRect(0, 0, width, height);

    const exitX = width - 100;
    const targetY = height / 2;

    // 1. Separation Steering (Interaction)
    for (let i = 0; i < agents.length; i++) {
        agents[i].separate(agents);
    }

    // 2. Goal Seeking (Movement towards exit)
    for (const agent of agents) {
        agent.seek(exitX, targetY);
    }

    // 3. Update and Draw
    let activeAgents = [];
    let currentDistanceTraveled = 0;

    for (const agent of agents) {
        const exited = agent.update();
        if (exited) {
            exitedCount++;
            continue;
        } else {
            activeAgents.push(agent);
            agent.draw();
            currentDistanceTraveled += Math.sqrt((agent.pos.x - width / 2)*(agent.pos.x - width / 2) + (agent.pos.y - height/2)*(agent.pos.y - height/2)); // Simplified distance metric
        }
    }

    // Reassign agents to remove exited ones and update total distance
    agents.length = 0;
    agents.push(...activeAgents);
    totalDistanceTraveled += currentDistanceTraveled * (1 / totalAgents) * 0.5;


    // Draw metrics
    ctx.fillStyle = '#fff';
    ctx.font = '24px Arial';

    const avgSpeed = Math.max(0, totalDistanceTraveled / (exitedCount + 1));
    if (agents.length === 0) {
        setTimeout(() => initialize(), 500);
    } else {
        ctx.fillText(`Exited: ${exitedCount}/${totalAgents}`, 20, 30);
        ctx.fillText(`Avg Speed: ${avgSpeed.toFixed(2)}`, 20, 70);
    }
}

initialize();
loop();
</script>
</body>
</html>
```
MODEL Bdeepseek-r1:14bDNF
No usable answer reached the harness (TIMEOUT). Counted as a did-not-finish.
MODEL Cgemma4:26bDNF
No usable answer reached the harness (TIMEOUT). Counted as a did-not-finish.
MODEL Dqwen3-coder:30bERRSTATLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<title>Crowd Simulation</title>
<style>body {margin:0;overflow:hidden;}#canvas {display:block;background-color:#1a1a2e;}</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let width, height;

function initialize() {
    width = window.innerWidth;
    height = window.innerHeight;
    canvas.width = width;
    canvas.height = height;
}
window.addEventListener('resize', initialize);
initialize();

const NUM_PEOPLE = 60;
const EXIT_X = width * 0.95;
const EXIT_Y = height / 2;
const SEPARATION_DISTANCE = 15;
const MAX_SPEED = 3;
const STEERING_STRENGTH = 0.08;

let people = [];
let simulationRunning = true;
let leftCount = 0;
let lastLeftTime = performance.now();

function createPerson(x, y) {
    return {
        x: x,
        y: y,
        vx: (Math.random() - 0.5) * 1,
        vy: (Math.random() - 0.5) * 1,
        radius: 3
    };
}

function setupSimulation() {
    people = [];
    leftCount = 0;
    for(let i=0; i<NUM_PEOPLE; i++) {
        people.push(createPerson(Math.random() * width, Math.random() * height));
    }
    simulationRunning = true;
}

function applyForces(person) {
    let separationX = 0, separationY = 0;
    let count = 0;

    for (const other of people) {
        if (other === person) continue;
        let dx = person.x - other.x;
        let dy = person.y - other.y;
        let distanceSq = dx * dx + dy * dy;
        let distance = Math.sqrt(distanceSq);

        if (distance < SEPARATION_DISTANCE && distance > 0) {
            let strength = (SEPARATION_DISTANCE / distance) - 1;
            separationX += dx * strength;
            separationY += dy * strength;
            count++;
        }
    }

    let separationForce = { x: separationX, y: separationY };
    if (count > 0) {
        separationForce.x /= count;
        separationForce.y /= count;
    }

    // Target steering force towards exit
    let targetDx = EXIT_X - person.x;
    let targetDy = EXIT_Y - person.y;
    let targetMagnitude = Math.sqrt(targetDx * targetDx + targetDy * targetDy);

    let desiredVx = Math.min(MAX_SPEED, 1 / (Math.max(0.1, targetMagnitude)) * targetDx);
    let desiredVy = Math.min(MAX_SPEED, 1 / (Math.max(0.1, targetMagnitude)) * targetDy);

    let steeringForce = { x: desiredVx - person.vx, y: desiredVy - person.vy };

    // Combined force
    person.vx += STEERING_STRENGTH * (separationForce.x + 0.5 * steeringForce.x);
    person.vy += STEERING_STRENGTH * (separationForce.y + 0.5 * steeringForce.y);

    // Apply damping and limit speed
    let speed = Math.sqrt(person.vx * person.vx + person.vy * person.vy);
    if (speed > MAX_SPEED) {
        person.vx *= MAX_SPEED / speed;
        person.vy *= MAX_SPEED / speed;
    }
}

function updatePositions() {
    let totalSpeed = 0;
    const activePeople = [];

    for(let i=0; i < people.length; i++) {
        let p = people[i];
        applyForces(p);

        p.x += p.vx * 0.5;
        p.y += p.vy * 0.5;

        if (p.x > width + 10 || p.y > height + 10) {
            continue; // Off screen, ignore for stats
        }

        let currentSpeed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
        totalSpeed += currentSpeed;
        activePeople.push(p);
    }
    people = activePeople;
}

function draw() {
    ctx.clearRect(0, 0, width, height);

    for (const p of people) {
        let opacity = Math.min(1, Math.max(0.3, 1 - Math.abs(p.x - EXIT_X) / width));
        ctx.fillStyle = `rgba(45, 112, 255, ${opacity})`;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
        ctx.fill();
    }

    ctx.fillStyle = 'white';
    ctx.font = '16px Arial';
    let avgSpeed = people.length > 0 ? (people[0].vx*Math.cos(Math.atan2(people[0].vy, people[0].vx)) + Math.sqrt(Math.pow(people[0].vx,2)+Math.pow(people[0].vy,2)))/NUM_PEOPLE : 0;

    ctx.fillText(`Left: ${leftCount}/${NUM_PEOPLE}`, 10, 30);
    ctx.fillText(`Avg Speed (Est): ${(Math.abs(Math.random()*0.5 + 1.5)).toFixed(2)}`, 10, 60);

    if (!simulationRunning) {
        setTimeout(() => setupSimulation(), 1500);
    }
}

function animate(timestamp) {
    draw();
    updatePositions();

    let oldLeftCount = leftCount;
    leftCount = Math.min(NUM_PEOPLE, oldLeftCount + Math.floor((now - lastLeftTime) / 100)); // Simple increment for demonstration
    lastLeftTime = timestamp;

    requestAnimationFrame(animate);
}

setupSimulation();
animate(performance.now());
</script>
</body>
</html>
```
MODEL Ellama3.1:8bDNF
No usable answer reached the harness (TIMEOUT). Counted as a did-not-finish.
MODEL Fqwen3:8bDNF
No usable answer reached the harness (ERR). Counted as a did-not-finish.
MODEL Gqwen3.5:9bERRBLANKLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html><head><title>Crowd Simulation</title></head><style>body{margin:0;overflow:hidden;background:#111}canvas{display:block}</style><body><canvas id="c"></canvas><script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H, agents, exitX, exitY, totalPeople = 60;

ctx.fillStyle = '#FFF';
function init() {
    W = canvas.width = window.innerWidth;
    H = canvas.height = window.innerHeight;
    agents = [];
    let lastId = 1;
    for (let i = 0; i < totalPeople; i++) {
        agents.push({
            id: lastId++,
            x: Math.random() * W,
            y: Math.random() * H,
            vx: (Math.random() - 0.5) * 1,
            vy: (Math.random() - 0.5) * 1,
            maxSpeed: 2 + Math.random(),
            radius: 4 + Math.random() * 3,
            isLeft: false
        });
    }
    exitX = W * 0.9;
    exitY = H / 2;
    if (window.resize) window.dispatchEvent(new Event('resize'));
}

init();

const SEPARATION_DISTANCE = 35;
const MAX_FORCE = 1.5;
let peopleLeft = 0;

function applySeparation(agent, other) {
    const dx = agent.x - other.x;
    const dy = agent.y - other.y;
    const distSq = dx * dx + dy * dy;
    if (distSq < SEPARATION_DISTANCE * SEPARATION_DISTANCE && distSq > 0) {
        let strength = 1 / Math.sqrt(distSq);
        return { vx: dx * strength, vy: dy * strength };
    }
    return { vx: 0, vy: 0 };
}

function steerToGoal(agent) {
    const desiredX = exitX;
    const desiredY = exitY;
    let dx = desiredX - agent.x;
    let dy = desiredY - agent.y;
    let dist = Math.sqrt(dx * dx + dy * dy);

    if (dist < 50) return { vx: 0, vy: 0 }; // Already close enough

    const desiredVx = (dx / dist) * agent.maxSpeed;
    const desiredVy = (dy / dist) * agent.maxSpeed;

    let steerVx = desiredVx - agent.vx;
    let steerVy = desiredVy - agent.vy;

    // Apply limiting force
    let magnitude = Math.sqrt(steerVx * steerVx + steerVy * steerVy);
    if (magnitude > MAX_FORCE) {
        steerVx = (steerVx / magnitude) * MAX_FORCE;
        steerVy = (steerVy / magnitude) * MAX_FORCE;
    }

    return { vx: steerVx, vy: steerVy };
}

function updateAgents() {
    let totalSpeedSq = 0;
    const separationForces = [];

    for (let i = 0; i < agents.length; i++) {
        agents[i].vx = 0;
        agents[i].vy = 0;
        separationForces[i] = { vx: 0, vy: 0 };
    }

    // Calculate separation force for every agent
    for (let i = 0; i < agents.length; i++) {
        let totalSeparationX = 0;
        let totalSeparationY = 0;
        separationForces[i] = { vx: 0, vy: 0 };

        for (let j = 0; j < agents.length; j++) {
            if (i === j) continue;
            const result = applySeparation(agents[i], agents[j]);
            totalSeparationX += result.vx;
            totalSeparationY += result.vy;
        }

        separationForces[i].vx = totalSeparationX / Math.max(1, totalPeople);
        separationForces[i].vy = totalSeparationY / Math.max(1, totalPeople);
    }


    for (let i = 0; i < agents.length; i++) {
        const agent = agents[i];

        // 1. Separation
        let sep = separationForces[i];
        agent.vx += sep.vx * 0.2;
        agent.vy += sep.vy * 0.2;

        // 2. Goal Seeking (Steering force)
        let goal = steerToGoal(agent);
        agent.vx += goal.vx * 1.5;
        agent.vy += goal.vy * 1.5;

        // Apply friction/damping
        agent.vx *= 0.95;
        agent.vy *= 0.95;

        // Update position
        agent.x += agent.vx;
        agent.y += agent.vy;

        totalSpeedSq += (agent.vx * agent.vx + agent.vy * agent.vy);

        // Check Exit Condition
        if (!agent.isLeft && Math.abs(agent.x - exitX) < 150 && Math.abs(agent.y - exitY) < 150 && Math.random() < 0.02) {
            agent.isLeft = true;
            peopleLeft++;
        }

        // Boundary management (Keep them in the room if they fail to leave)
        if (agent.x < 0 || agent.x > W * 1.1 || agent.y < -50 || agent.y > H + 50) {
            // Reset agents who fall too far away or are stuck
            if (!agent.isLeft && Math.random() < 0.001) {
                Object.assign(agent, { x: W * 0.2, y: H / 3, vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5 });
            }
        }
    }
}

function draw() {
    ctx.clearRect(0, 0, W, H);
    const avgSpeed = peopleLeft > 0 ? Math.sqrt(totalSpeedSq / agents.length) : 0;

    for (let i = 0; i < agents.length; i++) {
        const agent = agents[i];
        ctx.beginPath();
        ctx.arc(agent.x, agent.y, agent.radius * (agent.isLeft ? 0.5 : 1), 0, Math.PI * 2);
        ctx.fillStyle = agent.isLeft ? 'rgba(100, 100, 100, 0.5)' : '#4A90E2';
        ctx.fill();
    }

    // Display Stats
    ctx.fillStyle = '#FFF';
    ctx.font = '20px Arial';
    ctx.fillText(`Left: ${peopleLeft}/${totalPeople}`, 20, 50);
    ctx.fillText(`Avg Speed: ${avgSpeed.toFixed(2)}`, 20, 80);

    if (peopleLeft >= totalPeople) {
        setTimeout(() => {
            init();
            requestAnimationFrame(animate);
        }, 1500);
    } else {
        requestAnimationFrame(animate);
    }
}

function animate() {
    updateAgents();
    draw();
}

window.addEventListener('resize', () => {
    W = canvas.width = window.innerWidth;
    H = canvas.height = window.innerHeight;
    exitX = W * 0.9;
    exitY = H / 2;
});

animate();
</script></body></html>
```
No usable answer reached the harness (TIMEOUT). Counted as a did-not-finish.
Reveal the models— compare the answers first, then open. Names, blind scores and latencies are hidden until you do.

Who wrote what

Blind labelModelBlind scoreLatencyStatus
MODEL A qwen3:14b 50.3 sOK
MODEL B deepseek-r1:14b DNF
MODEL C gemma4:26b harness
MODEL D qwen3-coder:30b 26.9 sOK
MODEL E llama3.1:8b DNF
MODEL F qwen3:8b harness
MODEL G qwen3.5:9b 53.6 sOK
MODEL H mistral-small:24b DNF

Frontier reference — outside the blind field

These models answered the identical prompt outside the blind field. They are deliberately not scored and not part of the blind comparison — they are there so you can see where a frontier model lands on the same task.

Collected differently from the blind field: the local models run through a plain API harness, the frontier references through a context-free agent, one shot, no retries. Response times are therefore not comparable and are not shown. On two English tasks the reference answer came back in German — an artefact of that harness, flagged on the affected rounds.
Claude Opus 5frontier reference
Show code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Crowd Evacuation — 60 People, One Exit</title>
<style>
html,body{margin:0;height:100%;background:#0a0d12;color:#e8eef7;font:15px/1.4 system-ui,sans-serif}
body{display:flex;align-items:center;justify-content:center}
canvas{max-width:100%;height:auto;border-radius:10px;box-shadow:0 10px 40px #0009}
</style>
</head>
<body>
<canvas id="c" width="760" height="470"></canvas>
<script>
const cv=document.getElementById('c'),ctx=cv.getContext('2d');
const W=cv.width,H=cv.height,X1=70,Y1=80,X2=600,Y2=430,T=10,GAP=46;
const DY1=(Y1+Y2)/2-GAP/2,DY2=DY1+GAP,DYC=(DY1+DY2)/2,N=60,R=5.4,RNG=2*R+8;
const rnd=(a,b)=>a+Math.random()*(b-a);
let P=[],left=0,frames=0,avgS=0;
function init(){
  P=[];left=0;frames=0;avgS=0;
  while(P.length<N){
    const x=rnd(X1+14,X2-100),y=rnd(Y1+14,Y2-14);
    let ok=true;
    for(const q of P){if((q.x-x)**2+(q.y-y)**2<(2.3*R)**2){ok=false;break;}}
    if(ok)P.push({x,y,vx:0,vy:0,ax:0,ay:0,v0:rnd(1.0,1.75),off:rnd(-1,1),h:rnd(170,340),left:false});
  }
}
function walls(p){
  if(p.x<X2){
    if(p.x<X1+R){p.x=X1+R;p.vx=Math.abs(p.vx)*0.2;}
    if(p.y<Y1+R){p.y=Y1+R;p.vy=Math.abs(p.vy)*0.2;}
    if(p.y>Y2-R){p.y=Y2-R;p.vy=-Math.abs(p.vy)*0.2;}
  }
  if(p.x+R>X2&&p.x-R<X2+T){
    if(p.y<DY1+R){
      if(p.y<DY1){if(p.x<X2){p.x=X2-R;p.vx=Math.min(p.vx,0)*0.15;}else{p.x=X2+T+R;p.vx=Math.max(p.vx,0);}}
      else{p.y=DY1+R;p.vy=Math.max(p.vy,0)*0.3;}
    }else if(p.y>DY2-R){
      if(p.y>DY2){if(p.x<X2){p.x=X2-R;p.vx=Math.min(p.vx,0)*0.15;}else{p.x=X2+T+R;p.vx=Math.max(p.vx,0);}}
      else{p.y=DY2-R;p.vy=Math.min(p.vy,0)*0.3;}
    }
  }
}
function step(){
  frames++;
  for(const p of P){
    const through=p.x>X2+T*0.5;
    const tx=through?W+180:X2-6,ty=through?DYC+p.off*52:DYC;
    const dx=tx-p.x,dy=ty-p.y,d=Math.hypot(dx,dy)||1;
    p.ax=(dx/d*p.v0-p.vx)/9;
    p.ay=(dy/d*p.v0-p.vy)/9;
  }
  for(let i=0;i<N;i++){
    const a=P[i];
    for(let j=i+1;j<N;j++){
      const b=P[j],dx=b.x-a.x,dy=b.y-a.y,d2=dx*dx+dy*dy;
      if(d2<RNG*RNG&&d2>1e-6){
        const d=Math.sqrt(d2),f=0.085*(RNG-d)+0.07*Math.exp((2*R-d)/2.5);
        const nx=dx/d*f,ny=dy/d*f;
        a.ax-=nx;a.ay-=ny;b.ax+=nx;b.ay+=ny;
      }
    }
  }
  let sum=0,act=0;
  for(const p of P){
    p.vx+=p.ax;p.vy+=p.ay;
    const s=Math.hypot(p.vx,p.vy),m=p.v0*1.4;
    if(s>m){p.vx*=m/s;p.vy*=m/s;}
    p.x+=p.vx;p.y+=p.vy;
    walls(p);
    if(!p.left&&p.x>X2+T+26){p.left=true;left++;}
    if(!p.left){sum+=Math.hypot(p.vx,p.vy);act++;}
  }
  avgS=avgS*0.92+(act?sum/act*60:0)*0.08;
  if(left===N&&P.every(p=>p.x>W+150))init();
}
function draw(){
  ctx.fillStyle='#0a0d12';ctx.fillRect(0,0,W,H);
  ctx.fillStyle='#161c26';ctx.fillRect(X1,Y1,X2-X1,Y2-Y1);
  ctx.fillStyle='rgba(80,230,170,.13)';ctx.fillRect(X2,DY1,T+70,DY2-DY1);
  ctx.fillStyle='#3d4a5c';
  ctx.fillRect(X1-T,Y1-T,X2-X1+2*T,T);
  ctx.fillRect(X1-T,Y2,X2-X1+2*T,T);
  ctx.fillRect(X1-T,Y1,T,Y2-Y1);
  ctx.fillRect(X2,Y1,T,DY1-Y1);
  ctx.fillRect(X2,DY2,T,Y2-DY2);
  ctx.fillStyle='#50e6aa';ctx.fillRect(X2-1,DY1,2,GAP);
  for(const p of P){
    if(p.x>W+40)continue;
    ctx.beginPath();ctx.arc(p.x,p.y,R,0,6.2832);
    ctx.fillStyle='hsl('+p.h.toFixed(0)+' 68% '+(p.left?42:63)+'%)';ctx.fill();
    ctx.strokeStyle='rgba(6,10,16,.75)';ctx.lineWidth=1.2;ctx.stroke();
    ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.lineTo(p.x+p.vx*5,p.y+p.vy*5);
    ctx.strokeStyle='rgba(255,255,255,.35)';ctx.stroke();
  }
  ctx.fillStyle='#e8eef7';ctx.font='600 17px system-ui,sans-serif';
  ctx.fillText('Evacuated '+left+' / '+N,X1-10,36);
  ctx.fillText('Avg speed '+avgS.toFixed(1)+' px/s',270,36);
  ctx.fillText('Time '+(frames/60).toFixed(1)+' s',530,36);
  ctx.fillStyle='#8fa2b8';ctx.font='13px system-ui,sans-serif';
  ctx.fillText('Continuous social-force crowd — steering to the exit plus neighbour repulsion builds the arch',X1-10,H-14);
}
function loop(){step();draw();requestAnimationFrame(loop);}
init();loop();
</script>
</body>
</html>