OverviewPhysics Lab › R59
Physics Lab · blind round

R59 · Billiard break with spin and friction

blind round 4/8 local models answered

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

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 shows a billiard break with rolling friction on a <canvas>. A cue ball strikes a rack of exactly 10 numbered balls on a table with cushions. Model ball-to-ball elastic collisions, cushion bounces and rolling friction so the balls slow down and come to rest. When all balls have stopped, re-rack and break again automatically. 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 — blind

Local models (Brain cluster) — identical prompt, anonymized order
MODEL Aqwen3:8bDNF
No usable answer reached the harness (ERR). Counted as a did-not-finish.
MODEL Bgemma4:26bDNF
No usable answer reached the harness (TIMEOUT). Counted as a did-not-finish.
MODEL Cqwen3:14bSTATLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head><title>Billiard Break</title></head>
<body style="margin: 0; background-color: #2c3e50;"><canvas id="billiardsCanvas" width="800" height="450" style="background-color: #1abc9c; border: 10px solid #2ecc71;"></canvas><script>
const canvas = document.getElementById('billiardsCanvas');
const ctx = canvas.getContext('2d');

let balls = [];
let isBreaking = false;
let frameCount = 0;

const R = 8; // Ball radius
const FRICTION = 0.995;
const STOP_THRESHOLD = 0.1;
const TABLE_WIDTH = canvas.width - 20;
const TABLE_HEIGHT = canvas.height - 20;

function initRack() {
    balls = [];
    // Cue Ball (Ball 0)
    balls.push({ x: 50, y: 225, vx: 10, vy: 0, radius: R, id: 0, color: 'white', isCue: true });

    // Rack of 10 balls (Numbered 1 to 10)
    for (let i = 0; i < 3; i++) { // Rows (i=0 is top)
        for (let j = 0; j < 4 - Math.min(i, 2); j++) { // Columns
            const index = 1 + i * 4 + j;
            if (index <= 10) {
                balls.push({ x: 50 + 30 + j * 30, y: 225 - i * 30, vx: 0, vy: 0, radius: R, id: index, color: ['#e74c3c', '#f1c40f', '#9b59b6', '#3498db'][index % 4], isCue: false });
            }
        }
    }
    // Adjust cue ball starting position slightly for better break angle
    balls[0].x = 50; balls[0].y = 225;
}

function update() {
    if (!isBreaking) return;

    for (let i = 0; i < balls.length; i++) {
        const ball = balls[i];
        // Apply friction
        ball.vx *= FRICTION;
        ball.vy *= FRICTION;
        
        // Check if stationary
        if (Math.hypot(ball.vx, ball.vy) < STOP_THRESHOLD && Math.abs(ball.x - balls[0].x) > 10) {
             // Only mark as stopped if not the cue ball and moving slowly
            if (!ball.isCue && Math.random() < 0.95) { // Simple random check to prevent immediate re-break
                ball.vx = 0;
                ball.vy = 0;
            }
        }

        // Update position
        ball.x += ball.vx;
        ball.y += ball.vy;
    }

    let collisionsOccurred = false;

    // Cushion Collision
    for (const ball of balls) {
        if (ball.x - R < 0 || ball.x + R > canvas.width) {
            ball.vx *= -1;
            ball.x = Math.max(R, Math.min(canvas.width - R, ball.x));
            collisionsOccurred = true;
        }
        if (ball.y - R < 0 || ball.y + R > canvas.height) {
            ball.vy *= -1;
            ball.y = Math.max(R, Math.min(canvas.height - R, ball.y));
            collisionsOccurred = true;
        }
    }

    // Ball-to-Ball Collision (N^2 check)
    for (let i = 0; i < balls.length; i++) {
        const b1 = balls[i];
        for (let j = i + 1; j < balls.length; j++) {
            const b2 = balls[j];

            const dx = b2.x - b1.x;
            const dy = b2.y - b1.y;
            const distSq = dx * dx + dy * dy;
            const minDist = b1.radius + b2.radius;

            if (distSq < minDist * minDist) {
                collisionsOccurred = true;
                // Collision resolution: Overlap correction
                const distance = Math.sqrt(distSq);
                const correctionX = ((minDist / distance) - 1) * dx * 0.5;
                const correctionY = ((minDist / distance) - 1) * dy * 0.5;

                b1.x -= correctionX; b1.y -= correctionY;
                b2.x += correctionX; b2.y += correctionY;

                // Calculate relative velocity and normal vector
                const dvx = b2.vx - b1.vx;
                const dvy = b2.vy - b1.vy;
                const dotProd = dvx * dx + dvy * dy;
                
                if (dotProd > 0) continue; // Balls moving away

                // Impulse calculation (Elastic collision, masses assumed equal to 1)
                const impulse = -(1 + 1) / (1/1 + 1/1) * dotProd;
                
                b1.vx -= dvx * impulse * dx / (dx * dx + dy * dy);
                b1.vy -= dvy * impulse * dy / (dx * dx + dy * dy);
                b2.vx += dvx * impulse * dx / (dx * dx + dy * dy);
                b2.vy += dvy * impulse * dy / (dx * dx + dy * dy);
            }
        }
    }

    // Check if all balls have stopped
    const activeBalls = balls.filter(b => Math.hypot(b.vx, b.vy) >= STOP_THRESHOLD);
    if (activeBalls.length === 0 && collisionsOccurred === false && frameCount > 60) {
        isBreaking = false;
        setTimeout(() => {
            initRack();
            // Give the cue ball a strong initial hit vector after waiting 1 second
            balls[0].vx = Math.cos(Math.PI / 4) * 15;
            balls[0].vy = -Math.sin(Math.PI / 4) * 15;
            isBreaking = true;
        }, 1200);
    }
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (const ball of balls) {
        ctx.beginPath();
        ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
        ctx.fillStyle = ball.color;
        if (ball.isCue) ctx.strokeStyle = 'white'; else ctx.strokeStyle = '#333';
        ctx.lineWidth = 1;
        ctx.fill();
        ctx.stroke();

        // Draw number on balls 1-9
        if (!ball.isCue && ball.id >= 1 && ball.id <= 9) {
            ctx.fillStyle = 'black';
            ctx.font = 'bold 14px Arial';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(ball.id, ball.x, ball.y);
        }
    }
}

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

initRack(); // Initial setup
gameLoop(); // Start the animation loop
</script></body></html>
```
No usable answer reached the harness (TIMEOUT). Counted as a did-not-finish.
MODEL Edeepseek-r1:14bERRBLANKFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<canvas id="c" width=800 height=600 style="background:#4CAF50"></canvas>
<style>canvas{position:absolute; left:0; top:-30px}</style>
</head>
<body>
<script>
const c=document.getElementById('c'), ctx=c.getContext('2d');
class Ball {
  constructor(x,y) {this.x=x,this.y=y,this.vx=0,this.vy=0;
    this.r=15;this.friction=.98;this restitution=.7}
  draw() {
    ctx.fillStyle=randomColor();
    ctx.beginPath();ctx.arc(this.x,this.y,this.r,0,Math.PI*2);
    ctx.fill()
  }
  update() {
    if(Math.abs(this.vx)<.1 && Math.abs(this.vy)<.1) return;
    this.vx *= this.friction;this.vy *= this.friction;
    this.x += this.vx;this.y += this.vy
  }
}
let balls=[...Array(10)].map((_,i)=>new Ball(400+Math.random()*200-100,300));
balls[0].vx=5;balls[0].vy=-5;
const cue=new Ball(100,300);
let stopped=true;
function randomColor(){return`hsl(${Math.random()*360},70%,80%)`}
function animate(){
  ctx.clearRect(0,0,c.width,c.height);
  balls.forEach(b=>{b.update();b.draw()});
  cue.draw();
  if(!stopped && balls.every(b=>{
    return Math.abs(b.vx)<.1 && Math.abs(b.vy)<.1
  })){
    stopped=true;balls.splice(1);balls.push(new Ball(400+Math.random()*200-100,300));
    cue.x=100;cue.y=300;
  }
  requestAnimationFrame(animate)
}
function Collision(b1,b2){
  if(BBoxCollision(b1,b2)){
    let dx=b2.x-b1.x, dy=b2.y-b1.y;
    let dvx=b2.vx - b1.vx, dvy=b2.vy - b1.vy;
    let dot=(dx*dx + dy*dy);
    if(dot>0){
      let temp = (b1.vx*b2.vx + b1.vy*b2.vy) / dot * 0.5;
      b1.vx -= (dvx * temp)
      b1.vy -= (dvy * temp)
      b2.vx += (dvx * temp)
      b2.vy += (dvy * temp)
    }
  }
}
function BBoxCollision(b1,b2){
  return Math.abs(b1.x - b2.x) < b1.r + b2.r &&
         Math.abs(b1.y - b2.y) < b1.r + b2.r
}
animate()
</script>
</body>
</html>
```
MODEL Fqwen3-coder:30bBLANKLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<title>Billiard Break Simulation</title>
<style>body { margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #222; }</style>
</head>
<body>
<canvas id="billiardCanvas"></canvas>
<script>
const canvas = document.getElementById('billiardCanvas');
const ctx = canvas.getContext('2d');

let ws, balls = [];
const TABLE_WIDTH = 800;
const TABLE_HEIGHT = 400;
const BALL_RADIUS = 10;
const FRICTION = 0.995;
const DAMPING = 0.999;
const CUE_BALL_START_POS = { x: TABLE_WIDTH / 2, y: TABLE_HEIGHT - 3 * BALL_RADIUS };

canvas.width = TABLE_WIDTH;
canvas.height = TABLE_HEIGHT;

function createBalls(racked) {
    balls = [];
    const ballCount = racked ? 10 : 11;
    for (let i = 0; i < ballCount; i++) balls.push({ x: Math.random() * TABLE_WIDTH, y: Math.random() * TABLE_HEIGHT, vx: 0, vy: 0, r: BALL_RADIUS, id: i });

    // Set up the rack structure
    const startX = TABLE_WIDTH / 2 - (ballCount > 1 ? 5 * BALL_RADIUS : 0);
    const startY = TABLE_HEIGHT / 2 - 3 * BALL_RADIUS;
    for (let i = 0; i < ballCount; i++) {
        balls[i].x = startX + (i % 4) * (BALL_RADIUS * 2.5);
        balls[i].y = startY + Math.floor(i / 4) * (BALL_RADIUS * 2.5);
    }

    // Cue ball setup (last ball added is the cue ball)
    const cueBall = { x: CUE_BALL_START_POS.x, y: CUE_BALL_START_POS.y, vx: -10, vy: 0, r: BALL_RADIUS, id: 'cue' };
    balls[ballCount - 1] = cueBall;
}

function checkCollision(b1, b2) {
    const dx = b2.x - b1.x;
    const dy = b2.y - b1.y;
    const distSq = dx * dx + dy * dy;
    const minDistance = b1.r + b2.r;

    if (distSq < minDistance * minDistance) {
        let angle = Math.atan2(dy, dx);
        let overlap = minDistance - Math.sqrt(distSq);
        
        // Resolve overlap by pushing them apart along the collision normal
        b1.x -= (overlap / 2) * Math.cos(angle);
        b1.y -= (overlap / 2) * Math.sin(angle);
        b2.x += (overlap / 2) * Math.cos(angle);
        b2.y += (overlap / 2) * Math.sin(angle);

        // Calculate impulse for elastic collision
        let nx = dx / Math.sqrt(distSq);
        let ny = dy / Math.sqrt(distSq);

        let v1n = b1.vx * nx + b1.vy * ny;
        let v2n = b2.vx * nx + b2.vy * ny;

        let impulseMagnitude = 2 * (v2n - v1n) / (1/1 + 1/1); // Mass=1 for all balls
        
        b1.vx -= impulseMagnitude * nx;
        b1.vy -= impulseMagnitude * ny;
        b2.vx += impulseMagnitude * nx;
        b2.vy += impulseMagnitude * ny;

        return true;
    }
    return false;
}

function updatePhysics() {
    for (let i = 0; i < balls.length; i++) {
        const b = balls[i];
        
        // Apply friction and damping
        b.vx *= FRICTION;
        b.vy *= FRICTION;
        b.vx *= DAMPING;
        b.vy *= DAMPING;

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

        // Wall collisions (Cushions)
        if (b.x - b.r < 0 || b.x + b.r > TABLE_WIDTH) {
            b.vx *= -1;
            b.x = Math.max(b.r, Math.min(b.x, TABLE_WIDTH - b.r));
        }
        if (b.y - b.r < 0 || b.y + b.r > TABLE_HEIGHT) {
            b.vy *= -1;
            b.y = Math.max(b.r, Math.min(b.y, TABLE_HEIGHT - b.r));
        }

        // Collisions with other balls
        for (let j = i + 1; j < balls.length; j++) {
            if (checkCollision(b, balls[j])) {}
        }
    }
}

function draw() {
    ctx.fillStyle = '#004c00';
    ctx.fillRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT);

    balls.forEach(b => {
        ctx.beginPath();
        ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
        ctx.fillStyle = (b.id === 'cue') ? '#333' : (parseInt(b.id) % 2 == 0 ? '#ccc' : '#eee');
        ctx.fill();
        ctx.strokeStyle = '#000';
        ctx.lineWidth = 1;
        ctx.stroke();
    });
}

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

    let activeBalls = balls.filter(b => Math.abs(b.vx) > 0.1 || Math.abs(b.vy) > 0.1);
    if (activeBalls.length > 0) {
        requestAnimationFrame(animate);
    } else {
        setTimeout(() => {
            createBalls(true); // Re-rack and re-break
            requestAnimationFrame(animate);
        }, 1000);
    }
}

createBalls(false);
animate();
</script>
</body>
</html>
```
MODEL Gqwen3.5:9bSTATLIMFORMAT
Show code
```html
<!DOCTYPE html>
<html>
<head>
<title>Billiard Break</title>
<style>body{margin:0;overflow:hidden;background:#333}canvas{display:block}</style>
</head>
<body>
<canvas id="billiardsCanvas"></canvas>
<script>
const canvas = document.getElementById('billiardsCanvas');
const ctx = canvas.getContext('2d');
let W, H;

function setup() {
    W = canvas.width = 800;
    H = canvas.height = 500;
}
setup();

const R = 8;
const DAMPING = 0.995; // Rolling friction factor
const WALL_DAMPING = 0.85; // Energy loss on cushion bounce

let balls = [];
let frameCount = 0;

function createRack(startX, startY, spacing) {
    balls = [];
    for (let r = 0; r < 3; r++) {
        for (let c = 0; c < 4; c++) {
            // Creates a semi-rack pattern
            if ((r + c) % 2 === 0) {
                balls.push({ x: startX + c * spacing, y: startY - r * spacing, vx: (c === 3 ? 1 : 0), vy: (r === 0 ? 0.5 : 0), id: (r * 4 + c) });
            } else {
                 balls.push({ x: startX + c * spacing, y: startY - r * spacing, vx: (c === 3 ? 1 : 0), vy: (r === 0 ? 0.5 : 0), id: (r * 4 + c) });
            }
        }
    }
    // Cue ball initialization (index 0)
    balls[0] = { x: startX - spacing, y: startY, vx: 12, vy: 0 };
}

function initGame() {
    createRack(W * 0.4, H * 0.75, R * 3);
    frameCount = 0;
}

function checkCollisions(b1, b2) {
    const dx = b2.x - b1.x;
    const dy = b2.y - b1.y;
    let distSq = dx * dx + dy * dy;
    let minDistSq = 4 * R * R;

    if (distSq < minDistSq) {
        let nx = dx / Math.sqrt(distSq);
        let ny = dy / Math.sqrt(distSq);
        let overlap = Math.sqrt(minDistSq) - Math.sqrt(distSq);

        // Separate balls slightly to prevent sticking
        b1.x -= nx * overlap * 0.5;
        b1.y -= ny * overlap * 0.5;
        b2.x += nx * overlap * 0.5;
        b2.y += ny * overlap * 0.5;

        // Elastic collision response (simplified)
        let v_dot = b1.vx * nx + b1.vy * ny;
        let v_other_dot = b2.vx * nx + b2.vy * ny;

        b1.vx -= 2 * v_other_dot * nx;
        b1.vy -= 2 * v_other_dot * ny;
        b2.vx += 2 * v_dot * nx;
        b2.vy += 2 * v_dot * ny;
    }
}

function updatePhysics() {
    let allStopped = true;

    for (let i = 0; i < balls.length; i++) {
        const b = balls[i];
        if (Math.abs(b.vx) > 0.1 || Math.abs(b.vy) > 0.1) allStopped = false;

        // Apply friction damping
        b.vx *= DAMPING;
        b.vy *= DAMPING;

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

        // Wall collisions (Horizontal)
        if (Math.abs(b.x - R) < 0 || Math.abs(b.x - W - R) < 0) {
            b.vx *= -WALL_DAMPING;
            b.x = Math.max(R, Math.min(W - R, b.x));
        }

        // Wall collisions (Vertical)
        if (Math.abs(b.y - R) < 0 || Math.abs(b.y - H - R) < 0) {
            b.vy *= -WALL_DAMPING;
            b.y = Math.max(R, Math.min(H - R, b.y));
        }

        // Check collisions with other balls
        for (let j = i + 1; j < balls.length; j++) {
            checkCollisions(b, balls[j]);
        }
    }
    return allStopped;
}

function draw() {
    ctx.fillStyle = '#0B283D'; // Table color
    ctx.fillRect(0, 0, W, H);

    balls.forEach((b, i) => {
        let color = (i === 0) ? 'red' : (i % 2 === 0 ? 'white' : '#CC6633'); // Cue ball is red
        ctx.beginPath();
        ctx.arc(b.x, b.y, R, 0, Math.PI * 2);
        ctx.fillStyle = color;
        ctx.fill();
    });
}

function gameLoop() {
    const stopped = updatePhysics();
    draw();

    if (stopped && frameCount % 60 === 0) {
        setTimeout(initGame, 1000); // Re-rack after a delay
    } else if (!stopped) {
        frameCount++;
        requestAnimationFrame(gameLoop);
    }
}

window.onload = () => {
    initGame();
    requestAnimationFrame(gameLoop);
};
</script>
</body>
</html>
```
MODEL Hllama3.1:8bDNF
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:8b DNF
MODEL B gemma4:26b DNF
MODEL C qwen3:14b 45.9 sOK
MODEL D mistral-small:24b DNF
MODEL E deepseek-r1:14b 81.8 sOK
MODEL F qwen3-coder:30b 26.2 sOK
MODEL G qwen3.5:9b 49.5 sOK
MODEL H llama3.1:8b 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>Billiard Break</title>
<style>
html,body{margin:0;height:100%;background:#12100e;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif}
canvas{max-width:96vw;max-height:92vh;border-radius:14px;box-shadow:0 24px 60px #000a}
</style>
</head>
<body>
<canvas id="c" width="900" height="480"></canvas>
<script>
const cv=document.getElementById('c'),g=cv.getContext('2d');
const W=900,H=480,R=11,PAD=42,L=PAD,T=PAD,Rt=W-PAD,B=H-PAD;
const COL={1:'#e8c22a',2:'#1f5fd0',3:'#d02b25',4:'#7a2bb5',5:'#e07a1c',6:'#1d8a4a',7:'#8a2a22',8:'#141414',9:'#e8c22a',10:'#1f5fd0'};
let balls=[],rest=0;
function rack(){
  balls=[];let n=1;const ax=Rt-190,ay=H/2,d=R*2+0.6;
  for(let row=0;row<4;row++)for(let k=0;k<=row;k++)
    balls.push({x:ax+row*d*0.866,y:ay+(k-row/2)*d,vx:0,vy:0,n:n,c:COL[n++]});
  balls.push({x:L+150,y:H/2+(Math.random()-.5)*10,vx:12+Math.random()*5,vy:(Math.random()-.5)*1.4,n:0,c:'#f6f3ea'});
  rest=0;
}
function step(dt){
  for(const b of balls){b.x+=b.vx*dt;b.y+=b.vy*dt;}
  for(const b of balls){
    if(b.x-R<L){b.x=L+(L-(b.x-R))-R;b.vx=-b.vx*0.92;}
    if(b.x+R>Rt){b.x=Rt-((b.x+R)-Rt)-R;b.vx=-b.vx*0.92;}
    if(b.y-R<T){b.y=T+(T-(b.y-R))-R;b.vy=-b.vy*0.92;}
    if(b.y+R>B){b.y=B-((b.y+R)-B)-R;b.vy=-b.vy*0.92;}
  }
  for(let i=0;i<balls.length;i++)for(let j=i+1;j<balls.length;j++){
    const a=balls[i],b=balls[j];let dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy);
    if(d===0){dx=0.01;d=0.01;}
    if(d<R*2){
      const nx=dx/d,ny=dy/d,ov=(R*2-d)/2;
      a.x-=nx*ov;a.y-=ny*ov;b.x+=nx*ov;b.y+=ny*ov;
      const p=(a.vx-b.vx)*nx+(a.vy-b.vy)*ny;
      if(p>0){const e=p*0.98;a.vx-=e*nx;a.vy-=e*ny;b.vx+=e*nx;b.vy+=e*ny;}
    }
  }
}
function friction(){
  let moving=false;
  for(const b of balls){
    const s=Math.hypot(b.vx,b.vy);
    if(s<0.06){b.vx=0;b.vy=0;continue;}
    const f=Math.max(0,1-0.012-0.02/s);
    b.vx*=f;b.vy*=f;moving=true;
  }
  return moving;
}
function felt(){
  g.fillStyle='#3a2113';g.fillRect(0,0,W,H);
  g.fillStyle='#4d2c19';g.fillRect(8,8,W-16,H-16);
  const grd=g.createRadialGradient(W/2,H/2,40,W/2,H/2,W*0.62);
  grd.addColorStop(0,'#1f8a55');grd.addColorStop(1,'#0e5231');
  g.fillStyle=grd;g.fillRect(L-14,T-14,Rt-L+28,B-T+28);
  g.strokeStyle='#0a3a24';g.lineWidth=3;g.strokeRect(L-14,T-14,Rt-L+28,B-T+28);
  g.strokeStyle='rgba(255,255,255,.12)';g.lineWidth=1;
  g.beginPath();g.moveTo(L+150,T-14);g.lineTo(L+150,B+14);g.stroke();
}
function draw(){
  felt();
  for(const b of balls){
    g.beginPath();g.ellipse(b.x+4,b.y+7,R*1.05,R*0.7,0,0,7);g.fillStyle='rgba(0,0,0,.32)';g.fill();
  }
  for(const b of balls){
    g.beginPath();g.arc(b.x,b.y,R,0,7);g.fillStyle=b.c;g.fill();
    if(b.n>8){g.save();g.beginPath();g.arc(b.x,b.y,R,0,7);g.clip();
      g.fillStyle='#f6f3ea';g.fillRect(b.x-R,b.y-R,R*2,R*0.62);g.fillRect(b.x-R,b.y+R*0.38,R*2,R*0.62);g.restore();}
    const sh=g.createRadialGradient(b.x-R*0.4,b.y-R*0.45,1,b.x,b.y,R*1.3);
    sh.addColorStop(0,'rgba(255,255,255,.55)');sh.addColorStop(0.5,'rgba(255,255,255,0)');sh.addColorStop(1,'rgba(0,0,0,.4)');
    g.fillStyle=sh;g.beginPath();g.arc(b.x,b.y,R,0,7);g.fill();
    if(b.n>0){
      g.beginPath();g.arc(b.x,b.y,R*0.52,0,7);g.fillStyle='#f6f3ea';g.fill();
      g.fillStyle='#111';g.font='bold 10px system-ui';g.textAlign='center';g.textBaseline='middle';
      g.fillText(b.n,b.x,b.y+0.5);
    }
    g.strokeStyle='rgba(0,0,0,.35)';g.lineWidth=1;g.beginPath();g.arc(b.x,b.y,R-0.5,0,7);g.stroke();
  }
}
function loop(){
  for(let s=0;s<5;s++)step(0.2);
  const moving=friction();
  if(!moving){if(++rest>70)rack();}else rest=0;
  draw();
  requestAnimationFrame(loop);
}
rack();loop();
</script>
</body>
</html>