Overview › Physics Lab › R3
Physics Lab · archive round

R3 · Bouncing-ball physics: gravity, friction, elastic collisions

Archive round — this round predates the blind-first layout, so answers appear with model names attached. Newer rounds keep the models anonymous until you reveal them.
Scores on this page are the original editorial 0–10 ratings from the 2026-07-17 write-up (writing / art-direction / runtime quality) — not blind rubric scores against a documented gold answer, which is how R31 onward are scored. The underlying answers are unchanged from that write-up.
editorial scores · 2026-07-17 5/7 models answered
Show task
A self-contained HTML physics sim, NO user input: balls with gravity, air friction, elastic collisions (walls + ball-to-ball). 8 balls at start, auto-spawn to 40, live counter + FPS.

Untouched model code, executed headless for the verdicts; the stills are the posters.

The answers

🥇Claude Sonnet 5FRONTIEROK★ 9.0/10
RUNS ✓spawn ✓ 11/40 @6sairborne physics ✓

Flawless run and the most considerate code: spawn-overlap avoidance, exponential damping, floor-jitter friction.

🥈Claude Opus 4.8FRONTIEROK★ 8.8/10
RUNS ✓spawn ✓tightest file — 4.6 KB

Compact and correct: DPR-aware, framerate-independent, glowing render.

🥉Claude Fable 5HOUSEOK★ 8.0/10
RUNS ✓trails + reactive glow

Works, with style — but shipped a dead FPS block, and the "spatial grid" secretly calls indexOf per neighbour. Flair, minus rigour. Disclosed.

4qwen3-coder:30bLOCALISSUES★ 6.5/10
RUNS — BUTall balls grounded @6s

Executes cleanly, but the energy budget is wrong: within seconds, a still life. "Elastic" was the brief.

5GPT-5.5 · CodexCLIISSUES★ 5.5/10
RUNS — BUTballs nearly invisiblespawn stuck at 8

The R1+R2 leader stumbles: dark balls on a dark background, dead spawner. This is why we runtime-test.

6qwen3:14bLOCALCRASH★ 0.0/10
SyntaxError: 'lastSpawnTime' has already been declared

One duplicate declaration, total loss — the script never parses.

7qwen3.5:9bLOCALHANG★ 0.0/10
renderer timed out twice (>45 s)

Locks the rendering thread. Worse than crashing: it takes bystanders with it.

Frontier reference — outside the field

Answered the identical prompt outside the field, deliberately unscored. Collected through a context-free agent rather than the harness the other answers used, so response times are not comparable and are not shown.
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>Bouncing Balls Physics Sim</title>
<style>
  :root { color-scheme: dark; }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #0b0f1a; }
  canvas { display: block; width: 100vw; height: 100vh; }
  #hud {
    position: fixed; top: 14px; left: 14px;
    font: 600 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    color: #e8ecf6; background: rgba(14, 20, 34, .62);
    border: 1px solid rgba(255,255,255,.12); border-radius: 10px;
    padding: 10px 14px; letter-spacing: .04em;
    backdrop-filter: blur(8px); pointer-events: none; user-select: none;
  }
  #hud span { color: #7dd3fc; }
  #hud .fps { color: #86efac; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
  BALLS <span id="count">0</span> / 40<br>
  FPS <span class="fps" id="fps">--</span>
</div>
<script>
(() => {
  "use strict";

  const canvas = document.getElementById("c");
  const ctx = canvas.getContext("2d", { alpha: false });
  const countEl = document.getElementById("count");
  const fpsEl = document.getElementById("fps");

  const START_BALLS = 8;
  const MAX_BALLS   = 40;
  const SPAWN_EVERY = 0.55;   // seconds between auto-spawns
  const GRAVITY     = 900;    // px/s^2
  const AIR_DRAG    = 0.16;   // linear drag coefficient (1/s)
  const RESTITUTION = 1.0;    // fully elastic
  const SUBSTEPS    = 2;

  let W = 0, H = 0, DPR = 1;

  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth;
    H = window.innerHeight;
    canvas.width  = Math.round(W * DPR);
    canvas.height = Math.round(H * DPR);
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    // keep balls inside after a resize
    for (const b of balls) {
      b.x = Math.min(Math.max(b.x, b.r), Math.max(b.r, W - b.r));
      b.y = Math.min(Math.max(b.y, b.r), Math.max(b.r, H - b.r));
    }
  }
  window.addEventListener("resize", resize);

  const rand = (a, b) => a + Math.random() * (b - a);

  const balls = [];

  function makeBall() {
    const r = rand(12, 30);
    const hue = Math.floor(rand(0, 360));
    return {
      x: rand(r, Math.max(r + 1, W - r)),
      y: rand(r, Math.min(H * 0.45, Math.max(r + 1, H - r))),
      vx: rand(-320, 320),
      vy: rand(-160, 160),
      r,
      m: r * r,              // mass ~ area
      hue,
      fill: `hsl(${hue} 85% 58%)`,
      edge: `hsl(${hue} 90% 76%)`
    };
  }

  function spawn() {
    if (balls.length >= MAX_BALLS) return;
    // try a few times to find a non-overlapping spot
    for (let attempt = 0; attempt < 24; attempt++) {
      const b = makeBall();
      let clear = true;
      for (const o of balls) {
        const dx = o.x - b.x, dy = o.y - b.y;
        if (dx * dx + dy * dy < (o.r + b.r) * (o.r + b.r)) { clear = false; break; }
      }
      if (clear) { balls.push(b); return; }
    }
    balls.push(makeBall()); // give up on placement, overlap resolver handles it
  }

  function step(dt) {
    // integrate + air friction (exponential decay = stable at any dt)
    const damp = Math.exp(-AIR_DRAG * dt);
    for (const b of balls) {
      b.vy += GRAVITY * dt;
      b.vx *= damp;
      b.vy *= damp;
      b.x += b.vx * dt;
      b.y += b.vy * dt;
    }

    // ball <-> ball elastic collisions
    for (let i = 0; i < balls.length; i++) {
      const a = balls[i];
      for (let j = i + 1; j < balls.length; j++) {
        const b = balls[j];
        let dx = b.x - a.x, dy = b.y - a.y;
        const minD = a.r + b.r;
        let d2 = dx * dx + dy * dy;
        if (d2 >= minD * minD || d2 === 0) {
          if (d2 !== 0) continue;
          dx = 0.01; dy = 0; d2 = 0.0001;
        }
        const d = Math.sqrt(d2);
        const nx = dx / d, ny = dy / d;

        // positional correction (split by inverse mass)
        const overlap = minD - d;
        const invA = 1 / a.m, invB = 1 / b.m, invSum = invA + invB;
        a.x -= nx * overlap * (invA / invSum);
        a.y -= ny * overlap * (invA / invSum);
        b.x += nx * overlap * (invB / invSum);
        b.y += ny * overlap * (invB / invSum);

        // impulse along the normal
        const rvn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
        if (rvn > 0) continue; // already separating
        const jimp = -(1 + RESTITUTION) * rvn / invSum;
        a.vx -= jimp * invA * nx;
        a.vy -= jimp * invA * ny;
        b.vx += jimp * invB * nx;
        b.vy += jimp * invB * ny;
      }
    }

    // walls (elastic)
    for (const b of balls) {
      if (b.x - b.r < 0)      { b.x = b.r;     b.vx = Math.abs(b.vx) * RESTITUTION; }
      else if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * RESTITUTION; }
      if (b.y - b.r < 0)      { b.y = b.r;     b.vy = Math.abs(b.vy) * RESTITUTION; }
      else if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * RESTITUTION; }
    }
  }

  function draw() {
    ctx.fillStyle = "#0b0f1a";
    ctx.fillRect(0, 0, W, H);

    // subtle floor glow
    const g = ctx.createLinearGradient(0, H - 140, 0, H);
    g.addColorStop(0, "rgba(125,211,252,0)");
    g.addColorStop(1, "rgba(125,211,252,0.07)");
    ctx.fillStyle = g;
    ctx.fillRect(0, H - 140, W, 140);

    for (const b of balls) {
      const grad = ctx.createRadialGradient(
        b.x - b.r * 0.35, b.y - b.r * 0.4, b.r * 0.1,
        b.x, b.y, b.r
      );
      grad.addColorStop(0, `hsl(${b.hue} 95% 78%)`);
      grad.addColorStop(1, b.fill);
      ctx.beginPath();
      ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
      ctx.fillStyle = grad;
      ctx.fill();
      ctx.lineWidth = 1.5;
      ctx.strokeStyle = b.edge;
      ctx.stroke();
    }
  }

  // --- main loop ---
  let last = performance.now();
  let spawnTimer = 0;
  let fpsAccum = 0, fpsFrames = 0;

  function frame(now) {
    let dt = (now - last) / 1000;
    last = now;
    if (dt > 0.05) dt = 0.05;   // clamp after tab switches / stalls

    // auto-spawn
    if (balls.length < MAX_BALLS) {
      spawnTimer += dt;
      while (spawnTimer >= SPAWN_EVERY && balls.length < MAX_BALLS) {
        spawnTimer -= SPAWN_EVERY;
        spawn();
      }
    }

    const sub = dt / SUBSTEPS;
    for (let s = 0; s < SUBSTEPS; s++) step(sub);

    draw();

    // HUD
    fpsAccum += dt; fpsFrames++;
    if (fpsAccum >= 0.35) {
      fpsEl.textContent = (fpsFrames / fpsAccum).toFixed(0);
      fpsAccum = 0; fpsFrames = 0;
    }
    countEl.textContent = balls.length;

    requestAnimationFrame(frame);
  }

  resize();
  for (let i = 0; i < START_BALLS; i++) spawn();
  countEl.textContent = balls.length;
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>