Overview › Physics Lab › R57
Physics Lab · blind round

R57 · Hanging rope with Verlet integration

blind round 9/11 local models answered

Every answer below carries the name of the model that wrote it. Blind refers to the scoring: the review was done without knowing which model produced which answer — the published result names them.

2 of the 11 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 — the reason is noted on the card itself.

A frontier reference answered the same task (Claude Opus 5) — outside the field and deliberately unscored, further down the page.

Show task
Create ONE self-contained HTML file (STRICT LIMIT: max 120 lines total) that shows a hanging rope simulated with Verlet integration on a <canvas>. Exactly 40 point masses connected by distance constraints, both ends anchored at the top with a gap between them so the rope hangs in a catenary. Apply gravity and run several constraint relaxation passes per frame so the rope does not stretch. Give the rope a sideways impulse every few seconds so it visibly swings and settles. Smooth requestAnimationFrame animation, NO user input, starts immediately and keeps running. No external libraries, no comments needed. Output ONLY the HTML file content, nothing else.

The answers

Local models (Brain cluster) — identical prompt, shuffled order (the sequence is not a ranking)
qwen3.5:9bBLANKLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head><title>Verlet Rope Simulation</title></head>
<body><canvas id="c"></canvas><script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
canvas.width = 800;
canvas.height = 600;

const N = 40;
const K_PASSES = 15;
const G = 0.3; // Gravity constant
const DT = 0.1; // Time step
let particles = [];
let constraints = [];
let impulseTimer = 0;

function init() {
    for (let i = 0; i < N; i++) {
        particles.push({ x: canvas.width / 2, y: 50 + (i * 12) });
    }

    // Anchor the ends (Particles 0 and N-1 are fixed or constrained to anchors)
    const anchorA = { x: 100, y: 80 };
    const anchorB = { x: canvas.width - 100, y: 80 };

    // Initialize positions based on a slightly curved path
    for (let i = 0; i < N; i++) {
        particles[i].x = anchorA.x + (anchorB.x - anchorA.x) * (i / (N - 1));
        particles[i].y = anchorA.y + Math.sin(i / 5) * 50;
    }

    // Create distance constraints between adjacent particles
    for (let i = 0; i < N - 1; i++) {
        constraints.push({ p1: i, p2: i + 1, length: Math.hypot(particles[i+1].x - particles[i].x, particles[i+1].y - particles[i].y) });
    }

    // Fix the first and last particles to their anchors
    particles[0] = { x: anchorA.x, y: anchorA.y, px: anchorA.x, py: anchorA.y };
    particles[N - 1] = { x: anchorB.x, y: anchorB.y, px: anchorB.x, py: anchorB.y };
}

function updatePhysics() {
    const p = particles;
    for (let i = 0; i < N; i++) {
        if (i === 0 || i === N - 1) continue; // Anchored points do not move via Verlet
        
        const nextX = p[i].x + (p[i].x - p[i].px) * 0.95 + G * DT * 2;
        const nextY = p[i].y + (p[i].y - p[i].py) * 0.95 + G * DT * 2;

        p[i].px = p[i].x;
        p[i].py = p[i].y;
        p[i].x = nextX;
        p[i].y = nextY;
    }

    // Apply impulse if time is right
    impulseTimer += 0.1;
    if (Math.sin(impulseTimer) > Math.cos(impulseTimer) && Math.floor(impulseTimer / 2) % 4 === 0) {
        const forceX = Math.sin(particles[N/2].y * 0.1) * 50;
        for (let i = 1; i < N - 2; i++) {
            particles[i].x += forceX / 4;
        }
    }

    // Constraint relaxation passes (PBD)
    for (let pass = 0; pass < K_PASSES; pass++) {
        constraints.forEach(c => {
            const p1 = particles[c.p1];
            const p2 = particles[c.p2];
            const dx = p2.x - p1.x;
            const dy = p2.y - p1.y;
            const dist = Math.hypot(dx, dy);
            const correction = (dist - c.length) / dist;

            p1.x -= dx * 0.5 * correction;
            p1.y -= dy * 0.5 * correction;
            p2.x += dx * 0.5 * correction;
            p2.y += dy * 0.5 * correction;
        });

        // Re-apply anchor constraints after relaxation
        particles[0].x = particles[0].px;
        particles[0].y = particles[0].py;
        particles[N - 1].x = particles[N - 1].px;
        particles[N - 1].y = particles[N - 1].py;
    }

    // Update previous positions for next frame (crucial step)
    for (let i = 0; i < N; i++) {
        if (!(i === 0 || i === N - 1)) {
            particles[i].px = p[i].x - ((p[i].x - p[i].px) * 0.95); // Approximation for Verlet update using solved positions
            particles[i].py = p[i].y - ((p[i].y - p[i].py) * 0.95);
        }
    }
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < N; i++) {
        const p = particles[i];
        ctx.beginPath();
        ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
        ctx.fillStyle = '#333';
        ctx.fill();

        if (i > 0) {
            const prevP = particles[i-1];
            ctx.beginPath();
            ctx.moveTo(prevP.x, prevP.y);
            ctx.lineTo(p.x, p.y);
            ctx.strokeStyle = '#aaa';
            ctx.lineWidth = 2;
            ctx.stroke();
        }
    }

    requestAnimationFrame(animate);
}

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

init();
animate();
</script></body></html>
```
gemma4:26bharness
No answer reached us (THIN) — and the reason lies with our setup, not with the model: reasoning consumed the entire token budget (3 min 28 s, zero characters of answer); larger budgets are refused by the gateway Counted as a did-not-finish, but it is not evidence about this model.
deepseek-r1:14bOKFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<body style="background:#000;margin:0;display:flex;justify-content:center;height:100vh">
<canvas id=c width=800 height=600></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const points = [];
const nextPoints = [];
const constraints = [];
let gravity = 0.1;
let timestep = 0;

// Initialize rope
for(let i=0;i<40;i++) {
    points.push(i===0 ? {x:50,y:20} : 
               i===39 ? {x:canvas.width-50,y:20} : {x:i*18+50,y:canvas.height/2});
}

// Create constraints between adjacent points
for(let i=0;i<points.length-1;i++) {
    constraints.push({a:i,b:i+1});
}

function update() {
    timestep++;
    
    // Apply gravity
    for(let i=0;i<points.length;i++) {
        let p = points[i];
        nextPoints[i] = {x:p.x,y:Math.min(p.y + gravity, canvas.height)};
    }

    // Relax constraints multiple times per frame
    for(let pass=0;pass<4;pass++) {
        for(const c of constraints) {
            const i1 = c.a;
            const i2 = c.b;
            const p1 = points[i1];
            const p2 = points[i2];
            const dx = p2.x - p1.x;
            const dy = p2.y - p1.y;
            const distance = Math.sqrt(dx*dx+dy*dy);
            const nx = dx/distance;
            const ny = dy/distance;
            
            // Adjust positions to maintain correct distance
            points[i1] = {x:p1.x + (nx * 0.3), y:p1.y + (ny * 0.3)};
            points[i2] = {x:p2.x - (nx * 0.3), y:p2.y - (ny * 0.3)};
        }
    }

    // Apply impulse every few seconds
    if(timestep % 60 === 0) {
        points[0].x += -15;
        points[points.length-1].x += 15;
    }

    // Update positions for next frame
    for(let i=0;i<points.length;i++) {
        points[i] = {...nextPoints[i]};
    }
}

function draw() {
    ctx.beginPath();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    
    for(let i=0;i<points.length-1;i++) {
        const p1 = points[i];
        const p2 = points[i+1];
        ctx.moveTo(p1.x,p1.y);
        ctx.lineTo(p2.x,p2.y);
    }
    ctx.stroke();

    // Draw small circles at each point
    for(let p of points) {
        ctx.beginPath();
        ctx.arc(p.x,p.y,3,0,Math.PI*2);
        ctx.fillStyle = '#fff';
        ctx.fill();
    }
}

function animate() {
    requestAnimationFrame(animate);
    update();
    draw();
}

window.onload = () => animate();
</script>
</body>
</html>
```
qwen3-coder:30bOKFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<title>Verlet Rope</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 W, H;

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

const P = 40;
const dt = 1/60;
const GRAVITY = 9.8 / 2000; // Scaled gravity
const K_PASSES = 15;

let p = []; // Current positions
let oldP = []; // Previous positions
let fixedPoints = [];

for (let i = 0; i < P; i++) {
    p[i] = [W * 0.4 + i * (W * 0.6 / P), 50];
    oldP[i] = [...p[i]];
}

const anchor1 = p[0];
const anchor2 = p[P - 1];
fixedPoints.push(anchor1, anchor2); // Anchor the ends at the top

function update() {
    let impulseTime = Math.sin(Date.now() / 3000) * 5;
    
    for (let i = 0; i < P; i++) {
        if (i === 0 || i === P - 1) continue;

        // Verlet integration step: p_new = 2*p_current - p_old + acc*dt^2
        let vx = (p[i][0] - oldP[i][0]) / dt;
        let vy = (p[i][1] - oldP[i][1]) / dt;

        // Apply forces (Gravity + Impulse)
        let ax = impulseTime * 0.5;
        let ay = GRAVITY + Math.sin(Date.now() / 800) * 0.3;

        oldP[i] = [...p[i]];
        p[i][0] += vx * dt + ax * dt * dt;
        p[i][1] += vy * dt + ay * dt * dt;
    }

    // Constraint Solving (Distance Constraints)
    for(let k=0; k<K_PASSES; k++) {
        for (let i = 1; i < P; i++) {
            const p1 = [...p[i - 1]];
            const p2 = [...p[i]];
            const dx = p2[0] - p1[0];
            const dy = p2[1] - p1[1];
            const dist = Math.sqrt(dx*dx + dy*dy);
            
            if (dist === 0) continue;

            const correction = 0.01 * (1 - dist / 50); // Target length ~50
            p[i][0] += dx / dist * correction;
            p[i][1] += dy / dist * correction;
        }
    }

    // Anchor constraints (Locking the ends)
    p[0] = fixedPoints[0];
    p[P - 1] = fixedPoints[1];
}

function draw() {
    ctx.clearRect(0, 0, W, H);
    ctx.beginPath();
    for (let i = 0; i < P; i++) {
        const x = p[i][0];
        const y = p[i][1];

        // Draw rod segment
        if (i > 0) {
            ctx.strokeStyle = '#444';
            ctx.lineWidth = 2;
            ctx.moveTo(p[i-1][0], p[i-1][1]);
            ctx.lineTo(x, y);
            ctx.stroke();
        }

        // Draw point mass
        ctx.fillStyle = '#333';
        ctx.beginPath();
        ctx.arc(x, y, 2, 0, Math.PI * 2);
        ctx.fill();
    }
}

function loop() {
    update();
    draw();
    requestAnimationFrame(loop);
}

loop();
</script>
</body>
</html>
```
qwen3:14bSTATLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<title>Verlet Rope Simulation</title>
<style>body { margin: 0; overflow: hidden; background-color: #111; }</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H;

function resizeCanvas() {
    W = window.innerWidth;
    H = window.innerHeight * 0.8;
    canvas.width = W;
    canvas.height = H;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();

const N_MASSES = 40;
const ITERATIONS = 10;
const GRAVITY = 9.8 / 60; // Adjusted for frame rate
const SPRING_STRENGTH = 0.8;
let px = new Array(N_MASSES);
let pv = new Array(N_MASSES);

for (let i = 0; i < N_MASSES; i++) {
    px[i] = [W * 0.2 + (i - N_MASSES / 2) * 4, H * 0.1];
    pv[i] = [...px[i]];
}

function simulate() {
    for (let i = 0; i < N_MASSES; i++) {
        const p = px[i];
        const v = pv[i];
        // Verlet integration prediction step
        p[0] += (p[0] - v[0]) * 0.99 + GRAVITY * 16; // damping and gravity
        p[1] += (p[1] - v[1]) * 0.99 + GRAVITY;

        // Boundary constraints
        if (p[0] < 0 || p[0] > W) p[0] = Math.max(0, Math.min(W, p[0]));
        if (p[1] < 0 || p[1] > H) {
            p[1] = H;
            pv[i][1] *= -0.5; // Bounce dampening
        }
    }

    // Constraint relaxation passes
    for (let k = 0; k < ITERATIONS; k++) {
        // Anchor constraints (Ends of the rope)
        px[0][1] = H * 0.1;
        pv[0][0] = 0; pv[0][1] = 0;

        px[N_MASSES - 1][1] = H * 0.1;
        pv[N_MASSES - 1][0] = 0; pv[N_MASSES - 1][1] = 0;

        // Distance constraints (Springs)
        for (let i = 0; i < N_MASSES - 1; i++) {
            const p1 = px[i];
            const p2 = px[i + 1];
            const dx = p2[0] - p1[0];
            const dy = p2[1] - p1[1];
            const dist = Math.sqrt(dx * dx + dy * dy);
            const targetDist = 4; // Fixed distance

            const correctionX = (dist - targetDist) / dist * dx;
            const correctionY = (dist - targetDist) / dist * dy;

            px[i][0] -= correctionX * 0.5; px[i][1] -= correctionY * 0.5;
            px[i + 1][0] += correctionX * 0.5; px[i + 1][1] += correctionY * 0.5;
        }
    }

    // Update previous positions and velocities for next frame
    for (let i = 0; i < N_MASSES; i++) {
        pv[i] = [...px[i]];
    }
}

function draw() {
    ctx.fillStyle = 'rgba(17, 17, 34, 0.2)';
    ctx.fillRect(0, 0, W, H);
    ctx.strokeStyle = '#aaa';
    ctx.lineWidth = 2;

    for (let i = 0; i < N_MASSES - 1; i++) {
        const p1 = px[i];
        const p2 = px[i + 1];
        ctx.beginPath();
        ctx.moveTo(p1[0], p1[1]);
        ctx.lineTo(p2[0], p2[1]);
        ctx.stroke();
    }

    // Draw masses (optional visualization)
    for (let i = 0; i < N_MASSES; i++) {
        const p = px[i];
        ctx.fillStyle = '#fff';
        ctx.beginPath();
        ctx.arc(p[0], p[1], 2, 0, Math.PI * 2);
        ctx.fill();
    }
}

let lastImpulseTime = 0;
function applyImpulse() {
    const impulseTime = Date.now();
    if (impulseTime - lastImpulseTime > 3000) {
        // Apply a horizontal kick to masses 10 through N_MASSES/2
        for (let i = Math.floor(N_MASSES * 0.2); i < Math.ceil(N_MASSES * 0.8); i++) {
            px[i][0] += 40 + Math.random() * 10; // Small kick
        }
        lastImpulseTime = impulseTime;
    }
}

function animate() {
    applyImpulse();
    simulate();
    draw();
    requestAnimationFrame(animate);
}

animate();
</script>
</body>
</html>
```
qwen3:8bharness
No answer reached us (ERR) — and the reason lies with our setup, not with the model: reasoning consumed the entire token budget and the answer stayed empty; the gateway rejects budgets above 3000 tokens Counted as a did-not-finish, but it is not evidence about this model.
llama3.1:8bSTATFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<title>Verlet Rope Simulation</title>
<style>
canvas {
  border: 1px solid black;
}
</style>
</head>
<body>
<canvas id="c" width="800" height="600"></canvas>

<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

const numPoints = 40;
const gravity = 0.01;

let points = [];
for (let i = 0; i < numPoints; i++) {
  const x = Math.sin(i * Math.PI / numPoints) * 200 + canvas.width / 2;
  const y = -Math.cos(i * Math.PI / numPoints) * 150 + canvas.height / 2;
  points.push([x, y]);
}

function constrain() {
  for (let i = 0; i < numPoints - 1; i++) {
    const p1 = points[i];
    const p2 = points[i + 1];
    const dx = p2[0] - p1[0];
    const dy = p2[1] - p1[1];
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist > 25) {
      const ratio = 25 / dist;
      points[i][0] += (p2[0] - p1[0]) * (ratio / 2);
      points[i][1] += (p2[1] - p1[1]) * (ratio / 2);
    }
  }
}

function update() {
  for (let i = 0; i < numPoints; i++) {
    const x = points[i][0];
    const y = points[i][1];
    if (i > 0) {
      const dx = x - points[i - 1][0];
      const dy = y - points[i - 1][1];
      points[i][0] += dx * gravity;
      points[i][1] += dy * gravity;
    }
  }

  constrain();

  for (let i = 0; i < numPoints; i++) {
    points[i][0] += Math.sin(Date.now() / 1000) / 10;
    points[i][1] += Math.cos(Date.now() / 500);
  }
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (let i = 0; i < numPoints - 1; i++) {
    const p1 = points[i];
    const p2 = points[i + 1];
    ctx.beginPath();
    ctx.moveTo(p1[0], p1[1]);
    ctx.lineTo(p2[0], p2[1]);
    ctx.stroke();
  }
}

let lastUpdate = Date.now();

function animate() {
  const now = Date.now();
  if (now - lastUpdate > 16) {
    update();
    draw();
    lastUpdate = now;
  }
  requestAnimationFrame(animate);
}

animate();
</script>
</body>
</html>
```
mistral-small:24bBLANKFORMAT
Show code
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hanging Rope Simulation</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="ropeCanvas"></canvas>
<script>
const canvas = document.getElementById('ropeCanvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const numPoints = 40;
const points = [];
const constraints = [];

for (let i = 0; i < numPoints; i++) {
    points.push({ x: i * 15, y: 0 });
}

for (let i = 0; i < numPoints - 1; i++) {
    constraints.push([i, i + 1]);
}
points[0].x = canvas.width / 4;
points[numPoints - 1].x = 3 * canvas.width / 4;

function VerletIntegrate(points) {
    for (let p of points) {
        p.oldX = p.x;
        p.oldY = p.y;
        p.vy += 0.2; // gravity
        p.x += p.vx;
        p.y += p.vy;
    }
}

function RelaxConstraints(points, constraints) {
    for (let c of constraints) {
        const [i, j] = c;
        const dx = points[j].x - points[i].x;
        const dy = points[j].y - points[i].y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        const diff = 15 - dist; // Desired distance is 15
        const percent = 0.3;

        if (dist > 0) {
            const offsetX = dx / dist * diff * percent;
            const offsetY = dy / dist * diff * percent;
            points[i].x -= offsetX / 2;
            points[i].y -= offsetY / 2;
            points[j].x += offsetX / 2;
            points[j].y += offsetY / 2;
        }
    }
}

function ApplyImpulse(points) {
    const impulse = { x: Math.random() * 5, y: 0 };
    for (let p of points.slice(1, numPoints - 1)) {
        if (Math.random() < 0.5) {
            p.vx += impulse.x;
        }
    }
}

function Draw(points) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (let p of points) {
        ctx.lineTo(p.x, p.y);
    }
    ctx.strokeStyle = 'black';
    ctx.stroke();
}

let frameCount = 0;
function animate() {
    VerletIntegrate(points);
    for (let i = 0; i < 10; i++) RelaxConstraints(points, constraints);
    if (frameCount % 60 === 0) ApplyImpulse(points);
    Draw(points);
    frameCount++;
    requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
</script>
</body>
</html>
```
command-r:35bOKFORMAT
Show code
```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hanging Rope Simulation</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
        }
        canvas {
            width: 100vw;
            height: 100vh;
            display: block;
        }
    </style>
</head>
<body>
    <canvas id="simulation"></canvas>

    <script>
        const canvas = document.getElementById('simulation');
        const ctx = canvas.getContext('2d');

        let width, height;
        let masses = [];
        let constraints = [];
        let dt = 0.0167; // delta time
        let impulseInterval = 5; // apply sideways impulse every N seconds
        let impulseCounter = impulseInterval;

        function init() {
            width = canvas.width = window.innerWidth;
            height = canvas.height = window.innerHeight - 50;

            for (let i = 0; i < 40; i++) {
                const x = width / 2 + ((i * 2) - 38) * 10;
                masses.push({
                    x, y: height - 20, // initial position
                    vx: 0, vy: 0, // initial velocity
                    ax: 0, ay: 0, // acceleration (will be calculated)
                    prevX: x, prevY: height - 20 // previous position for Verlet integration
                });
            }

            for (let i = 1; i < masses.length; i++) {
                constraints.push({
                    mass1: masses[i - 1],
                    mass2: masses[i],
                    distance: Math.sqrt((masses[i].x - masses[i - 1].x) ** 2 + (masses[i].y - masses[i - 1].y) ** 2), // initial distance between masses
                });
            }
        }

        function applyImpulse() {
            for (let i = 0; i < masses.length; i++) {
                if (Math.random() > 0.5) {
                    masses[i].vx -= 10 + Math.random() * 20; // apply sideways impulse with some randomness
                } else {
                    masses[i].vx += 10 + Math.random() * 20;
                }
            }
        }

        function update(timestamp) {
            requestAnimationFrame(update);

            ctx.clearRect(0, 0, width, height);
            ctx.beginPath();
            ctx.strokeStyle = 'white';

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

                // Verlet integration with constraints and gravity
                mass.ax = -mass.y / 500 + Math.random() * 0.2 - 0.1; // randomize a bit to simulate wind
                mass.ay = 0.049;

                const distX = mass.x - mass.prevX;
                const distY = mass.y - mass.prevY;
                mass.vx += dt * (mass.ax + distX / dt);
                mass.vy += dt * (mass.ay + distY / dt);
                mass.x += dt * mass.vx;
                mass.y += dt * mass.vy;

                ctx.lineTo(mass.prevX, mass.prevY); // draw the rope segment
                mass.prevX = mass.x;
                mass.prevY = mass.y;
            }

            ctx.stroke();

            if (impulseCounter++ > impulseInterval) {
                applyImpulse();
                impulseCounter = 0;
            }
        }

        init();
        update(0);
    </script>
</body>
</html>
```
qwq:32bSTAT
Show code
<think>

Alright, so I need to create an HTML file that simulates a hanging rope using Verlet integration. Let me start by recalling what Verlet integration is. From what I remember, Verlet integration is a method used in physics simulations to calculate the trajectory of objects over time. It's different from Euler's method because it uses the previous and current positions to compute the next position, which helps with stability, especially in constraints like the rope's segments here.

The user specified exactly 40 point masses connected by distance constraints. Both ends are anchored at the top with a gap so it forms a catenary. I need to make sure the rope doesn't stretch, so applying multiple constraint relaxation passes per frame is essential. Also, adding a sideways impulse every few seconds to make it swing and then settle. The animation should use requestAnimationFrame for smoothness and start immediately without any user input.

First, I'll set up the basic HTML structure with a canvas element. The canvas needs to be sized appropriately. Let me think about the dimensions. Maybe 800x600? That's a common size and should be big enough to see the rope. The canvas tag will have an id so I can reference it in JavaScript.

Next, the JavaScript part. I'll need to set up the canvas context. Full-screen might not be necessary, but perhaps set a fixed size. Wait, the user didn't specify, but since it's a self-contained file, I'll just use the canvas size as attributes.

Now, the rope simulation. Each point mass needs to be represented as an object with position and previous position. Since it's Verlet, the previous position is important for calculating the next position. The constraints are the distance between each consecutive point. The two ends are anchored, so their positions don't change. The gap between them at the top means their initial positions are offset horizontally.

Let me structure the variables. There's an array for the points. Each point has an x and y, and a previous x and y. The anchors are the first and last points. The initial positions should be set so that the rope forms a catenary. Wait, actually, the initial positions might just be a straight line between the two anchors, but then gravity will pull them down into a catenary. Hmm, that's probably better because the simulation can start with the rope hanging naturally.

So, the initial positions: the first and last points are fixed. The middle points start horizontally aligned between them? Wait, maybe start all points in a straight line between the two top anchors and then let gravity do its thing. That way, the simulation will naturally form the catenary.

Gravity is a force applied to each point (except the anchors). In Verlet integration, forces are handled by adjusting the position relative to the previous position. The formula is something like new position = 2*current - previous + acceleration * dt². Since it's Verlet, maybe time step can be handled by delta time, but perhaps the example can simplify by using a fixed step for simplicity, but requestAnimationFrame uses the time delta.

Wait, actually, in many examples, they use the delta time (dt) to adjust the acceleration term. Let me check the standard approach. The Verlet formula is:

position += (acceleration * dt²) + (position - previousPosition)

Then previousPosition is set to the old position before the update. So, the acceleration here would be due to gravity. So, the gravity would be a downward vector, let's say (0, 9.8) but scaled appropriately.

But in the simulation, the scaling might need to be adjusted for pixel units. Maybe the gravity is set as a vector like (0, 0.1) per frame? Not sure yet, but I can adjust that later.

Each frame, after updating the positions with gravity, we need to apply the constraints multiple times. The constraints here are that each adjacent pair of points must be a certain distance apart. Since it's a rope with equal segments, the distance between each point should be fixed. The user mentioned distance constraints between points, so the rest length is the distance between the initial points, or maybe a fixed value.

Wait, the initial setup might need to have each segment's length, and then each constraint enforces that distance. Let me think of the constraints as follows: between each pair of consecutive points (i and i+1), the distance should be maintained. So for each pair, we calculate the vector between them, compute the current distance, and then adjust their positions to enforce the desired distance.

The process for constraints would be for each pair:

- Compute the vector between the two points (pos2 - pos1)
- Compute the current distance (length of the vector)
- The desired distance is the initial length (or a fixed value)
- The difference is (desired - current)/current
- Then each point is moved along the vector by half of this difference to maintain symmetry.

Wait, but in Verlet integration, the approach might be slightly different. Because in Verlet, the position is updated in a certain way, but when applying constraints, you have to adjust the current position to satisfy the constraint, then the previous position is updated accordingly. Hmm, maybe I need to look up how constraints are handled with Verlet.

Alternatively, perhaps each constraint is handled by moving the points towards satisfying the distance. Let me think of a typical implementation. Suppose we have two points A and B, with a desired distance d. The vector between them is AB = B - A. The length of AB is l. If l != d, then we can compute the difference vector as (d/l - 1) * AB, and then move each point by half of this difference. So:

delta = (d - l)/l * 0.5

Then:

A += delta * AB

B -= delta * AB

Wait, actually, the scaling might be (d/l), and then the adjustment is ( (d/l) - 1 ) * (B - A) which is the difference needed. Then split equally between both points. So:

vec = B - A;

len = length(vec);

if len != 0:

    delta = (d / len - 1) / 2;

    A += vec * delta;

    B -= vec * delta;

Wait, perhaps that's the right approach. The idea is to scale the vector between them to the desired length by moving both points towards each other or away, depending on whether the current length is too long or too short.

So, for each constraint (each pair of consecutive points), apply this adjustment. Since we have to do this for all constraints, and do multiple passes for better convergence. The user mentioned several relaxation passes per frame, so maybe 3-5 passes each frame to get the rope to not stretch.

Now, the anchors: the first and last points are fixed. So in the constraints, we don't move them. Wait, actually, if the anchors are fixed, then in their constraints, the other point (next or previous) can be adjusted but the anchor remains fixed. So for the first point (anchor), when applying the constraint between it and the second point, the first point's position doesn't change, only the second point is adjusted. Similarly for the last anchor and the penultimate point.

So in the code, for each constraint pair (i and i+1), if either i or i+1 is an anchor (0 or 39), then only adjust the non-anchor point. Wait, but in the case of the first point (i=0) and i=1, since the anchor can't move, only the second point is moved.

So in code, for each pair:

pointA = points[i], pointB =
deepseek-r1:32bERRBLANKFORMAT
Show code
```html
<!DOCTYPE html>
<html><body style="background:#000;margin:0;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
const canvas=document.getElementById("c"),ctx=canvas.getContext("2d");
canvas.width=800;canvas.height=400;
let points=[],constraints=[],anchorX1=350,anchorY1=100,anchorX2=450,anchorY2=100,gap=100,gravity=0.1,mass=1;
for(let i=0;i<40;i++){
    let x=i==0?anchorX1:i==39?anchorX2:anchorX1+(i*(gap)/38);
    let y=i==0||i==39?anchorY1+i*(anchorY2-anchorY1)/39:200;
    points.push({x,y,px:x,py:y});
}
function connect(a,b,distance){
    constraints.push({a,b,distance});
}
for(let i=0;i<40-1;i++)connect(points[i],points[i+1],i==0||i==39?gap/2:gap/38);
let lastTime=0,impulseInterval=5000,lastImpulse=0;
function animate(timestamp){
    if(!lastTime)lastTime=timestamp;
    let dt=(timestamp-lastTime)/1000;
    if((timestamp-lastImpulse)>impulseInterval){
        points.forEach(p=>{if(Math.random()<0.2)p.px-=Math.random()*5-2.5});
        lastImpulse=timestamp;
    }
    for(let i=1;i<points.length-1;i++){
        let p=points[i];
        let dx=p.x-p.px,dy=p.y-p.py;
        p.px=p.x;
        p.py=p.y;
        p.x+=dx+gravity*dt*dt;
        p.y+=dy+gravity*dt*dt;
    }
    for(let i=0;i<5;i++){
        constraints.forEach(c=>{
            let a=c.a,b=c.b,dx=b.x-a.x,dy=b.y-a.y,len=Math.sqrt(dx*dx+dy*dy);
            if(len!=c.distance){
                let delta=c.distance-len;
                let factor=delta/len*0.5;
                if(a==points[0]||a==points[39])b.x+=factor*dx;b.y+=factor*dy;
                else if(b==points[0]||b==points[39])a.x-=factor*dx;a.y-=factor*dy;
                else{a.x-=factor*dx/2;a.y-=factor*dy/2;b.x+=factor*dx/2;b.y+=factor*dy/2;}
            }
        });
    }
    ctx.clearRect(0,0,canvas.width,canvas.height);
    ctx.beginPath();
    points.forEach((p,i)=>{
        if(i>0){
            ctx.strokeStyle='#fff';
            ctx.lineWidth=2;
            ctx.moveTo(points[i-1].x,points[i-1].y);
            ctx.lineTo(p.x,p.y);
        }
    });
    ctx.stroke();
    lastTime=timestamp;
    requestAnimationFrame(animate);
}
animate(0);
</script></body></html>
```
Outside the Brain cluster — same task, same character budget, collected through their own tooling (response times not comparable)
GPT-5.5 (Codex)frontier referenceOK
Show code
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verlet Rope</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#10141b}
canvas{display:block;width:100%;height:100%}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const canvas=document.getElementById("c"),ctx=canvas.getContext("2d");
let w,h,pts=[],segs=[],last=0,nextKick=0;
const N=40,g=.55,passes=12;
function resize(){
  w=canvas.width=innerWidth*devicePixelRatio;
  h=canvas.height=innerHeight*devicePixelRatio;
  canvas.style.width=innerWidth+"px";canvas.style.height=innerHeight+"px";
  init();
}
function init(){
  pts=[];segs=[];
  const ax=w*.33,ay=h*.12,bx=w*.67,by=h*.12,extra=h*.55,len=(bx-ax+extra)/(N-1);
  for(let i=0;i<N;i++){
    const t=i/(N-1),x=ax+(bx-ax)*t,y=ay+Math.sin(Math.PI*t)*extra*.55;
    pts.push({x,y,px:x,py:y,lock:i==0||i==N-1});
    if(i)segs.push({a:i-1,b:i,l:len});
  }
}
function impulse(t){
  const dir=Math.sin(t*.001)>0?1:-1;
  for(let i=8;i<N-8;i++){
    const p=pts[i],amp=Math.sin(Math.PI*i/(N-1))*22*devicePixelRatio*dir;
    p.px-=amp;
  }
}
function step(now){
  requestAnimationFrame(step);
  if(!last)last=now;
  const dt=Math.min(2,(now-last)/16.67);last=now;
  if(now>nextKick){impulse(now);nextKick=now+3300;}
  pts[0].x=w*.33;pts[0].y=h*.12;pts[N-1].x=w*.67;pts[N-1].y=h*.12;
  for(const p of pts)if(!p.lock){
    const vx=(p.x-p.px)*.992,vy=(p.y-p.py)*.992;
    p.px=p.x;p.py=p.y;p.x+=vx;p.y+=vy+g*dt*dt*devicePixelRatio;
  }
  for(let k=0;k<passes;k++)for(const s of segs){
    const a=pts[s.a],b=pts[s.b],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;
    const diff=(d-s.l)/d*.5,ox=dx*diff,oy=dy*diff;
    if(!a.lock){a.x+=ox;a.y+=oy}
    if(!b.lock){b.x-=ox;b.y-=oy}
  }
  draw();
}
function draw(){
  ctx.clearRect(0,0,w,h);
  ctx.lineCap="round";ctx.lineJoin="round";
  ctx.strokeStyle="#9be7ff";ctx.lineWidth=8*devicePixelRatio;
  ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);
  for(let i=1;i<N;i++){
    const p=pts[i],q=pts[i-1],mx=(p.x+q.x)/2,my=(p.y+q.y)/2;
    ctx.quadraticCurveTo(q.x,q.y,mx,my);
  }
  ctx.lineTo(pts[N-1].x,pts[N-1].y);ctx.stroke();
  ctx.strokeStyle="#314256";ctx.lineWidth=3*devicePixelRatio;
  for(const i of [0,N-1]){const p=pts[i];ctx.beginPath();ctx.arc(p.x,p.y,15*devicePixelRatio,0,7);ctx.stroke()}
}
addEventListener("resize",resize);
resize();requestAnimationFrame(step);
</script>
</body>
</html>
Claude Sonnet 5frontier referenceOK
Show code
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Rope</title>
<style>html,body{margin:0;padding:0;background:#0b1220;overflow:hidden;height:100%}
canvas{display:block}</style></head>
<body>
<canvas id="c"></canvas>
<script>
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
function resize(){cv.width=innerWidth;cv.height=innerHeight;}
resize();
addEventListener('resize', resize);

const N = 40;
const GRAVITY = 900;
const ITER = 12;
const DAMP = 0.995;

let points = [];
let segLen = 0;
let anchorAX, anchorAY, anchorBX, anchorBY;

function initRope(){
  const w = cv.width, h = cv.height;
  anchorAX = w * 0.22; anchorAY = h * 0.14;
  anchorBX = w * 0.78; anchorBY = h * 0.14;
  const straight = Math.hypot(anchorBX - anchorAX, anchorBY - anchorAY);
  const total = straight * 1.55;
  segLen = total / (N - 1);
  points = [];
  for (let i = 0; i < N; i++){
    const t = i / (N - 1);
    const x = anchorAX + (anchorBX - anchorAX) * t;
    const y = anchorAY + Math.sin(Math.PI * t) * (total - straight) * 0.5;
    points.push({x, y, px: x, py: y, pinned: (i === 0 || i === N - 1)});
  }
}
initRope();

function verlet(dt){
  for (const p of points){
    if (p.pinned) continue;
    const vx = (p.x - p.px) * DAMP;
    const vy = (p.y - p.py) * DAMP;
    p.px = p.x; p.py = p.y;
    p.x += vx;
    p.y += vy + GRAVITY * dt * dt;
  }
  points[0].x = anchorAX; points[0].y = anchorAY;
  points[N - 1].x = anchorBX; points[N - 1].y = anchorBY;
}

function constrain(){
  for (let k = 0; k < ITER; k++){
    for (let i = 0; i < N - 1; i++){
      const a = points[i], b = points[i + 1];
      const dx = b.x - a.x, dy = b.y - a.y;
      const dist = Math.hypot(dx, dy) || 0.0001;
      const diff = (dist - segLen) / dist;
      const ox = dx * 0.5 * diff, oy = dy * 0.5 * diff;
      if (!a.pinned){ a.x += ox; a.y += oy; }
      if (!b.pinned){ b.x -= ox; b.y -= oy; }
    }
    points[0].x = anchorAX; points[0].y = anchorAY;
    points[N - 1].x = anchorBX; points[N - 1].y = anchorBY;
  }
}

let lastImpulse = 0;
function maybeImpulse(time){
  if (time - lastImpulse > 3200){
    lastImpulse = time;
    const dir = (Math.random() < 0.5 ? -1 : 1) * (180 + Math.random() * 140);
    for (let i = 1; i < N - 1; i++){
      const w = Math.sin(Math.PI * i / (N - 1));
      points[i].px -= dir * w * 0.012;
    }
  }
}

function draw(){
  ctx.clearRect(0, 0, cv.width, cv.height);
  const g = ctx.createLinearGradient(0, 0, 0, cv.height);
  g.addColorStop(0, '#0b1220'); g.addColorStop(1, '#1a2333');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, cv.width, cv.height);

  ctx.strokeStyle = '#e8c07d';
  ctx.lineWidth = 3;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < N; i++) ctx.lineTo(points[i].x, points[i].y);
  ctx.stroke();

  ctx.fillStyle = '#ffdca6';
  for (const p of points){
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.pinned ? 5 : 2.6, 0, Math.PI * 2);
    ctx.fill();
  }
}

const dt = 1 / 60;
function frame(time){
  maybeImpulse(time);
  verlet(dt);
  constrain();
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body></html>
Claude Fable 5frontier referenceOK
Show code
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Verlet Rope</title>
<style>html,body{margin:0;background:#0b0e14;overflow:hidden}canvas{display:block;margin:0 auto}</style></head>
<body><canvas id="c" width="900" height="620"></canvas>
<script>
const cv=document.getElementById('c'),ctx=cv.getContext('2d');
const W=900,H=620,N=40;
const A1={x:280,y:90},A2={x:620,y:90};
const ropeLen=560,seg=ropeLen/(N-1);
const pts=[];
for(let i=0;i<N;i++){
  const t=i/(N-1);
  const x=A1.x+(A2.x-A1.x)*t;
  const y=A1.y+Math.sin(t*Math.PI)*120;
  pts.push({x,y,px:x,py:y});
}
const G=0.45,DAMP=0.995,PASSES=35;
let frame=0;
function impulse(){
  const dir=Math.random()<0.5?-1:1;
  for(let i=4;i<N-4;i++){
    const w=Math.sin((i/(N-1))*Math.PI);
    pts[i].px-=dir*9*w;
  }
}
function step(){
  for(let i=0;i<N;i++){
    const p=pts[i];
    const vx=(p.x-p.px)*DAMP,vy=(p.y-p.py)*DAMP;
    p.px=p.x;p.py=p.y;
    p.x+=vx;p.y+=vy+G;
  }
  for(let k=0;k<PASSES;k++){
    pts[0].x=A1.x;pts[0].y=A1.y;
    pts[N-1].x=A2.x;pts[N-1].y=A2.y;
    for(let i=0;i<N-1;i++){
      const a=pts[i],b=pts[i+1];
      let dx=b.x-a.x,dy=b.y-a.y;
      const d=Math.hypot(dx,dy)||1e-6;
      const diff=(d-seg)/d*0.5;
      dx*=diff;dy*=diff;
      a.x+=dx;a.y+=dy;
      b.x-=dx;b.y-=dy;
    }
    pts[0].x=A1.x;pts[0].y=A1.y;
    pts[N-1].x=A2.x;pts[N-1].y=A2.y;
  }
}
function draw(){
  ctx.fillStyle="#0b0e14";ctx.fillRect(0,0,W,H);
  ctx.strokeStyle="#c9a25e";ctx.lineWidth=4;ctx.lineJoin="round";
  ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);
  for(let i=1;i<N;i++)ctx.lineTo(pts[i].x,pts[i].y);
  ctx.stroke();
  ctx.fillStyle="#e8d9b8";
  for(const p of pts){ctx.beginPath();ctx.arc(p.x,p.y,2.4,0,7);ctx.fill();}
  ctx.fillStyle="#556";
  ctx.fillRect(A1.x-10,A1.y-14,20,10);
  ctx.fillRect(A2.x-10,A2.y-14,20,10);
  ctx.fillStyle="#9aa4bf";ctx.font="14px monospace";
  ctx.fillText("Verlet rope · 40 masses · "+PASSES+" relaxation passes · impulse every 4s",20,26);
}
function loop(){
  frame++;
  if(frame%240===120)impulse();
  step();
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body></html>
Claude Haiku 4.5frontier referenceOK
Show code
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Verlet Rope Simulation</title>
<style>
  body { margin: 0; padding: 10px; font-family: monospace; background: #000; color: #0f0; }
  canvas { display: block; margin: 10px auto; background: #111; border: 1px solid #0f0; }
</style>
</head>
<body>
<canvas id="c" width="800" height="600"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;

const n = 40;
const gravity = 0.2;
const damping = 0.99;
const segLen = 400 / n;

let pos = [], oldPos = [], pinned = new Set();
let time = 0;

for (let i = 0; i < n; i++) {
  const x = 200 + (i * segLen);
  const y = 100;
  pos[i] = { x, y };
  oldPos[i] = { x, y };
}
pinned.add(0);
pinned.add(n - 1);

function simulate() {
  for (let i = 0; i < n; i++) {
    if (pinned.has(i)) continue;
    const vx = (pos[i].x - oldPos[i].x) * damping;
    const vy = (pos[i].y - oldPos[i].y) * damping;
    oldPos[i].x = pos[i].x;
    oldPos[i].y = pos[i].y;
    pos[i].x += vx;
    pos[i].y += vy + gravity;
  }

  if (time % 60 === 0) {
    pos[Math.floor(n / 2)].x += 15;
  }

  for (let iter = 0; iter < 4; iter++) {
    for (let i = 0; i < n - 1; i++) {
      const dx = pos[i + 1].x - pos[i].x;
      const dy = pos[i + 1].y - pos[i].y;
      const d = Math.sqrt(dx * dx + dy * dy);
      const diff = (d - segLen) / d;
      const mx = dx * diff * 0.5;
      const my = dy * diff * 0.5;
      if (!pinned.has(i)) {
        pos[i].x += mx;
        pos[i].y += my;
      }
      if (!pinned.has(i + 1)) {
        pos[i + 1].x -= mx;
        pos[i + 1].y -= my;
      }
    }
  }

  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, w, h);

  ctx.strokeStyle = '#0f0';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(pos[0].x, pos[0].y);
  for (let i = 1; i < n; i++) {
    ctx.lineTo(pos[i].x, pos[i].y);
  }
  ctx.stroke();

  ctx.fillStyle = '#0f0';
  for (let i of pinned) {
    ctx.fillRect(pos[i].x - 3, pos[i].y - 3, 6, 6);
  }

  time++;
  requestAnimationFrame(simulate);
}

simulate();
</script>
</body>
</html>
Results at a glance— every model in this round with score, response time and status.

Who wrote what

ModelBlind scoreLatencyStatus
qwen3.5:9b —25.1 sOK
gemma4:26b ——harness
deepseek-r1:14b —104.2 sOK
qwen3-coder:30b —20.6 sOK
qwen3:14b —24.5 sOK
qwen3:8b ——harness
llama3.1:8b —99.0 sOK
mistral-small:24b —97.1 sOK
command-r:35b —134.1 sOK
qwq:32b —123.3 sOK
deepseek-r1:32b —124.0 sOK

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>Verlet Rope</title>
<style>
html,body{margin:0;height:100%;background:#0b0e14;overflow:hidden}
canvas{display:block;width:100vw;height:100vh}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const N=40,ITER=12,G=1400,DAMP=0.995;
let W=0,H=0,dpr=1,pts=[],segLen=0,ax=0,ay=0,bx=0,by=0,t=0,nextKick=1.2;
function setup(){
  dpr=Math.min(window.devicePixelRatio||1,2);
  W=c.clientWidth;H=c.clientHeight;
  c.width=W*dpr;c.height=H*dpr;x.setTransform(dpr,0,0,dpr,0,0);
  const gap=Math.min(W*0.55,520);
  ax=W/2-gap/2;ay=H*0.18;bx=W/2+gap/2;by=H*0.18;
  const slack=Math.min(gap*1.55,H*0.72);
  segLen=slack/(N-1);
  pts=[];
  for(let i=0;i<N;i++){
    const u=i/(N-1);
    const sag=Math.sin(u*Math.PI)*slack*0.42;
    const px=ax+(bx-ax)*u,py=ay+(by-ay)*u+sag;
    pts.push({x:px,y:py,px:px,py:py});
  }
  t=0;nextKick=1.0;
}
function constrain(){
  pts[0].x=ax;pts[0].y=ay;
  pts[N-1].x=bx;pts[N-1].y=by;
  for(let k=0;k<ITER;k++){
    for(let i=0;i<N-1;i++){
      const a=pts[i],b=pts[i+1];
      let dx=b.x-a.x,dy=b.y-a.y;
      let d=Math.hypot(dx,dy)||1e-6;
      const diff=(d-segLen)/d*0.5;
      const ox=dx*diff,oy=dy*diff;
      if(i!==0){a.x+=ox;a.y+=oy;}else{a.x=ax;a.y=ay;}
      if(i+1!==N-1){b.x-=ox;b.y-=oy;}else{b.x=bx;b.y=by;}
    }
    pts[0].x=ax;pts[0].y=ay;
    pts[N-1].x=bx;pts[N-1].y=by;
  }
}
function step(dt){
  for(let i=1;i<N-1;i++){
    const p=pts[i];
    const vx=(p.x-p.px)*DAMP,vy=(p.y-p.py)*DAMP;
    p.px=p.x;p.py=p.y;
    p.x+=vx;p.y+=vy+G*dt*dt;
  }
  constrain();
}
function kick(){
  const dir=Math.random()<0.5?-1:1;
  const power=(9+Math.random()*9)*dir;
  for(let i=1;i<N-1;i++){
    const u=i/(N-1);
    const w=Math.sin(u*Math.PI);
    pts[i].px-=power*w;
    pts[i].py-=power*w*0.35*Math.random();
  }
}
function draw(){
  const g=x.createLinearGradient(0,0,0,H);
  g.addColorStop(0,'#0b0e14');g.addColorStop(1,'#141a26');
  x.fillStyle=g;x.fillRect(0,0,W,H);
  x.lineCap='round';x.lineJoin='round';
  x.strokeStyle='rgba(0,0,0,0.45)';x.lineWidth=11;
  x.beginPath();x.moveTo(pts[0].x,pts[0].y+5);
  for(let i=1;i<N;i++)x.lineTo(pts[i].x,pts[i].y+5);
  x.stroke();
  const rg=x.createLinearGradient(ax,ay,bx,by+200);
  rg.addColorStop(0,'#f0b96b');rg.addColorStop(0.5,'#d98d3f');rg.addColorStop(1,'#f0b96b');
  x.strokeStyle=rg;x.lineWidth=7;
  x.beginPath();x.moveTo(pts[0].x,pts[0].y);
  for(let i=1;i<N;i++)x.lineTo(pts[i].x,pts[i].y);
  x.stroke();
  x.strokeStyle='rgba(255,240,210,0.35)';x.lineWidth=2;
  x.beginPath();x.moveTo(pts[0].x,pts[0].y-1.5);
  for(let i=1;i<N;i++)x.lineTo(pts[i].x,pts[i].y-1.5);
  x.stroke();
  x.fillStyle='#e8e2d6';
  for(let i=0;i<N;i++){x.beginPath();x.arc(pts[i].x,pts[i].y,1.8,0,6.2832);x.fill();}
  x.fillStyle='#8fa3bf';
  [[ax,ay],[bx,by]].forEach(p=>{x.beginPath();x.arc(p[0],p[1],7,0,6.2832);x.fill();});
}
let last=performance.now();
function loop(now){
  let dt=(now-last)/1000;last=now;
  if(dt>0.05)dt=0.05;
  t+=dt;
  if(t>=nextKick){kick();nextKick=t+3+Math.random()*2;}
  const sub=2,sdt=dt/sub;
  for(let s=0;s<sub;s++)step(sdt);
  draw();
  requestAnimationFrame(loop);
}
window.addEventListener('resize',setup);
setup();
requestAnimationFrame(loop);
</script>
</body>
</html>